From 4442d8c78072b992654c0b3b3749fdb049e6c010 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Mon, 15 Dec 2025 07:14:25 +0100 Subject: [PATCH 01/45] Experiment: Reactive analysis with skip-lite CMT cache Vendor skip-lite library and integrate reactive analysis capabilities: - Vendor skip-lite marshal_cache and reactive_file_collection modules - Modify C++ code to handle ReScript CMT file format (CMI+CMT headers) - Add CmtCache module for mmap-based CMT file reading - Add ReactiveAnalysis module for incremental file processing - Add CLI flags: -cmt-cache, -reactive, -runs - Add README.md with usage and benchmark instructions Benchmark results (~5000 files): - Standard: CMT processing 0.78s, Total 1.01s - Reactive (warm): CMT processing 0.01s, Total 0.20s - Speedup: 74x for CMT processing, 5x total The reactive mode caches processed file_data and uses read_cmt_if_changed to skip unchanged files entirely on subsequent runs. --- analysis/reanalyze/README.md | 169 ++++ analysis/reanalyze/src/Cli.ml | 9 + analysis/reanalyze/src/CmtCache.ml | 42 + analysis/reanalyze/src/CmtCache.mli | 28 + analysis/reanalyze/src/ReactiveAnalysis.ml | 149 ++++ analysis/reanalyze/src/Reanalyze.ml | 69 +- analysis/reanalyze/src/dune | 2 +- analysis/vendor/dune | 2 +- analysis/vendor/skip-lite/dune | 8 + analysis/vendor/skip-lite/marshal_cache/dune | 7 + .../skip-lite/marshal_cache/marshal_cache.ml | 71 ++ .../skip-lite/marshal_cache/marshal_cache.mli | 120 +++ .../marshal_cache/marshal_cache_stubs.cpp | 804 ++++++++++++++++++ .../skip-lite/reactive_file_collection/dune | 3 + .../reactive_file_collection.ml | 95 +++ .../reactive_file_collection.mli | 115 +++ analysis/vendor/skip-lite/test_cmt.ml | 119 +++ docs/reactive_reanalyze_design.md | 469 ++++++++++ .../deadcode-benchmark/Makefile | 29 +- 19 files changed, 2286 insertions(+), 24 deletions(-) create mode 100644 analysis/reanalyze/README.md create mode 100644 analysis/reanalyze/src/CmtCache.ml create mode 100644 analysis/reanalyze/src/CmtCache.mli create mode 100644 analysis/reanalyze/src/ReactiveAnalysis.ml create mode 100644 analysis/vendor/skip-lite/dune create mode 100644 analysis/vendor/skip-lite/marshal_cache/dune create mode 100644 analysis/vendor/skip-lite/marshal_cache/marshal_cache.ml create mode 100644 analysis/vendor/skip-lite/marshal_cache/marshal_cache.mli create mode 100644 analysis/vendor/skip-lite/marshal_cache/marshal_cache_stubs.cpp create mode 100644 analysis/vendor/skip-lite/reactive_file_collection/dune create mode 100644 analysis/vendor/skip-lite/reactive_file_collection/reactive_file_collection.ml create mode 100644 analysis/vendor/skip-lite/reactive_file_collection/reactive_file_collection.mli create mode 100644 analysis/vendor/skip-lite/test_cmt.ml create mode 100644 docs/reactive_reanalyze_design.md diff --git a/analysis/reanalyze/README.md b/analysis/reanalyze/README.md new file mode 100644 index 0000000000..7255664a54 --- /dev/null +++ b/analysis/reanalyze/README.md @@ -0,0 +1,169 @@ +# Reanalyze + +Dead code analysis and other experimental analyses for ReScript. + +## Analyses + +- **Dead Code Elimination (DCE)** - Detect unused values, types, and modules +- **Exception Analysis** - Track potential exceptions through call chains +- **Termination Analysis** - Experimental analysis for detecting non-terminating functions + +## Usage + +```bash +# Run DCE analysis on current project (reads rescript.json) +rescript-editor-analysis reanalyze -config + +# Run DCE analysis on specific CMT directory +rescript-editor-analysis reanalyze -dce-cmt path/to/lib/bs + +# Run all analyses +rescript-editor-analysis reanalyze -all +``` + +## Performance Options + +### Parallel Processing + +Use multiple CPU cores for faster analysis: + +```bash +# Use 4 parallel domains +reanalyze -config -parallel 4 + +# Auto-detect number of cores +reanalyze -config -parallel -1 +``` + +### CMT Cache (Experimental) + +Use memory-mapped cache for CMT file reading: + +```bash +reanalyze -config -cmt-cache +``` + +### Reactive Mode (Experimental) + +Cache processed file data and skip unchanged files on subsequent runs: + +```bash +reanalyze -config -reactive +``` + +This provides significant speedup for repeated analysis (e.g., in a watch mode or service): + +| Mode | CMT Processing | Total | Speedup | +|------|----------------|-------|---------| +| Standard | 0.78s | 1.01s | 1x | +| Reactive (warm) | 0.01s | 0.20s | 5x | + +### Benchmarking + +Run analysis multiple times to measure cache effectiveness: + +```bash +reanalyze -config -reactive -timing -runs 3 +``` + +## CLI Flags + +| Flag | Description | +|------|-------------| +| `-config` | Read analysis mode from rescript.json | +| `-dce` | Run dead code analysis | +| `-exception` | Run exception analysis | +| `-termination` | Run termination analysis | +| `-all` | Run all analyses | +| `-parallel n` | Use n parallel domains (0=sequential, -1=auto) | +| `-cmt-cache` | Use mmap cache for CMT files | +| `-reactive` | Cache processed file_data, skip unchanged files | +| `-runs n` | Run analysis n times (for benchmarking) | +| `-timing` | Report timing of analysis phases | +| `-debug` | Print debug information | +| `-json` | Output in JSON format | +| `-ci` | Internal flag for CI mode | + +## Architecture + +See [ARCHITECTURE.md](ARCHITECTURE.md) for details on the analysis pipeline. + +The DCE analysis is structured as a pure pipeline: + +1. **MAP** - Process each `.cmt` file independently → per-file data +2. **MERGE** - Combine all per-file data → project-wide view +3. **SOLVE** - Compute dead/live status → issues +4. **REPORT** - Output issues + +This design enables order-independence, parallelization, and incremental updates. + +## Reactive Analysis + +The reactive mode (`-reactive`) uses skip-lite's Marshal_cache to efficiently detect file changes: + +1. **First run**: All files are processed and results cached +2. **Subsequent runs**: Only changed files are re-processed +3. **Unchanged files**: Return cached `file_data` immediately (no I/O or unmarshalling) + +This is the foundation for a persistent analysis service that can respond to file changes in milliseconds. + +## Development + +### Testing + +```bash +# Run reanalyze tests +make test-reanalyze + +# Run with shuffled file order (order-independence test) +make test-reanalyze-order-independence + +# Run parallel mode test +make test-reanalyze-parallel +``` + +### Benchmarking + +The benchmark project generates ~5000 files to measure analysis performance: + +```bash +cd tests/analysis_tests/tests-reanalyze/deadcode-benchmark + +# Generate files, build, and run sequential vs parallel benchmark +make benchmark + +# Compare CMT cache effectiveness (cold vs warm) +make time-cache + +# Benchmark reactive mode (shows speedup on repeated runs) +make time-reactive +``` + +#### Reactive Benchmark + +The `make time-reactive` target runs: + +1. **Standard mode** (baseline) - Full analysis every time +2. **Reactive mode** with 3 runs - First run is cold (processes all files), subsequent runs are warm (skip unchanged files) + +Example output: + +``` +=== Reactive mode benchmark === + +Standard (baseline): + CMT processing: 0.78s + Total: 1.01s + +Reactive mode (3 runs): + === Run 1/3 === + CMT processing: 0.78s + Total: 1.02s + === Run 2/3 === + CMT processing: 0.01s <-- 74x faster + Total: 0.20s <-- 5x faster + === Run 3/3 === + CMT processing: 0.01s + Total: 0.20s +``` + diff --git a/analysis/reanalyze/src/Cli.ml b/analysis/reanalyze/src/Cli.ml index 240d369b18..edff3e3e2b 100644 --- a/analysis/reanalyze/src/Cli.ml +++ b/analysis/reanalyze/src/Cli.ml @@ -27,3 +27,12 @@ let parallel = ref 0 (* timing: report internal timing of analysis phases *) let timing = ref false + +(* use mmap cache for CMT files *) +let cmtCache = ref false + +(* use reactive/incremental analysis (caches processed file_data) *) +let reactive = ref false + +(* number of analysis runs (for benchmarking reactive mode) *) +let runs = ref 1 diff --git a/analysis/reanalyze/src/CmtCache.ml b/analysis/reanalyze/src/CmtCache.ml new file mode 100644 index 0000000000..53425cb369 --- /dev/null +++ b/analysis/reanalyze/src/CmtCache.ml @@ -0,0 +1,42 @@ +(** CMT file cache using Marshal_cache for efficient mmap-based reading. + + This module provides cached reading of CMT files with automatic + invalidation when files change on disk. It's used to speed up + repeated analysis runs by avoiding re-reading unchanged files. *) + +[@@@alert "-unsafe"] + +(** Read a CMT file, using the mmap cache for efficiency. + The file is memory-mapped and the cache automatically detects + when the file changes on disk. *) +let read_cmt path : Cmt_format.cmt_infos = + Marshal_cache.with_unmarshalled_file path Fun.id + +(** Read a CMT file only if it changed since the last access. + Returns [Some cmt_infos] if the file changed (or first access), + [None] if the file is unchanged. + + This is the key function for incremental analysis - unchanged + files return [None] immediately without any unmarshalling. *) +let read_cmt_if_changed path : Cmt_format.cmt_infos option = + Marshal_cache.with_unmarshalled_if_changed path Fun.id + +(** Clear the CMT cache, unmapping all memory. + Useful for testing or to free memory. *) +let clear () = Marshal_cache.clear () + +(** Invalidate a specific path in the cache. + The next read will re-load the file from disk. *) +let invalidate path = Marshal_cache.invalidate path + +(** Cache statistics *) +type stats = { + entry_count: int; + mapped_bytes: int; +} + +(** Get cache statistics *) +let stats () : stats = + let s = Marshal_cache.stats () in + { entry_count = s.entry_count; mapped_bytes = s.mapped_bytes } + diff --git a/analysis/reanalyze/src/CmtCache.mli b/analysis/reanalyze/src/CmtCache.mli new file mode 100644 index 0000000000..74d6a73c85 --- /dev/null +++ b/analysis/reanalyze/src/CmtCache.mli @@ -0,0 +1,28 @@ +(** CMT file cache using Marshal_cache for efficient mmap-based reading. + + This module provides cached reading of CMT files with automatic + invalidation when files change on disk. *) + +val read_cmt : string -> Cmt_format.cmt_infos +(** Read a CMT file, using the mmap cache for efficiency. *) + +val read_cmt_if_changed : string -> Cmt_format.cmt_infos option +(** Read a CMT file only if it changed since the last access. + Returns [Some cmt_infos] if the file changed (or first access), + [None] if the file is unchanged. *) + +val clear : unit -> unit +(** Clear the CMT cache, unmapping all memory. *) + +val invalidate : string -> unit +(** Invalidate a specific path in the cache. *) + +type stats = { + entry_count: int; + mapped_bytes: int; +} +(** Cache statistics *) + +val stats : unit -> stats +(** Get cache statistics *) + diff --git a/analysis/reanalyze/src/ReactiveAnalysis.ml b/analysis/reanalyze/src/ReactiveAnalysis.ml new file mode 100644 index 0000000000..a6b6a6cf46 --- /dev/null +++ b/analysis/reanalyze/src/ReactiveAnalysis.ml @@ -0,0 +1,149 @@ +(** Reactive analysis service using cached file processing. + + This module provides incremental analysis that only re-processes + files that have changed, caching the processed file_data for + unchanged files. *) + +[@@@alert "-unsafe"] + +(** Result of processing a single CMT file *) +type cmt_file_result = { + dce_data: DceFileProcessing.file_data option; + exception_data: Exception.file_result option; +} + +(** Result of processing all CMT files *) +type all_files_result = { + dce_data_list: DceFileProcessing.file_data list; + exception_results: Exception.file_result list; +} + +(** Cached file_data for a single CMT file. + We cache the processed result, not just the raw CMT data. *) +type cached_file = { + path: string; + file_data: DceFileProcessing.file_data option; + exception_data: Exception.file_result option; +} + +(** The file cache - maps CMT paths to processed results *) +let file_cache : (string, cached_file) Hashtbl.t = Hashtbl.create 1024 + +(** Process cmt_infos into a file result *) +let process_cmt_infos ~config ~cmtFilePath cmt_infos : cmt_file_result option = + let excludePath sourceFile = + config.DceConfig.cli.exclude_paths + |> List.exists (fun prefix_ -> + let prefix = + match Filename.is_relative sourceFile with + | true -> prefix_ + | false -> Filename.concat (Sys.getcwd ()) prefix_ + in + String.length prefix <= String.length sourceFile + && + try String.sub sourceFile 0 (String.length prefix) = prefix + with Invalid_argument _ -> false) + in + match cmt_infos.Cmt_format.cmt_annots |> FindSourceFile.cmt with + | Some sourceFile when not (excludePath sourceFile) -> + let is_interface = + match cmt_infos.cmt_annots with + | Interface _ -> true + | _ -> Filename.check_suffix sourceFile "i" + in + let module_name = sourceFile |> Paths.getModuleName in + let dce_file_context : DceFileProcessing.file_context = + {source_path = sourceFile; module_name; is_interface} + in + let file_context = + DeadCommon.FileContext. + {source_path = sourceFile; module_name; is_interface} + in + let dce_data = + if config.DceConfig.run.dce then + Some + (cmt_infos + |> DceFileProcessing.process_cmt_file ~config ~file:dce_file_context + ~cmtFilePath) + else None + in + let exception_data = + if config.DceConfig.run.exception_ then + cmt_infos |> Exception.processCmt ~file:file_context + else None + in + if config.DceConfig.run.termination then + cmt_infos |> Arnold.processCmt ~config ~file:file_context; + Some {dce_data; exception_data} + | _ -> None + +(** Process a CMT file, using cached result if file unchanged. + Returns the cached result if the file hasn't changed since last access. *) +let process_cmt_cached ~config cmtFilePath : cmt_file_result option = + match CmtCache.read_cmt_if_changed cmtFilePath with + | None -> + (* File unchanged - return cached result *) + (match Hashtbl.find_opt file_cache cmtFilePath with + | Some cached -> + Some { dce_data = cached.file_data; exception_data = cached.exception_data } + | None -> + (* First time seeing this file - shouldn't happen, but handle gracefully *) + None) + | Some cmt_infos -> + (* File changed or new - process it *) + let result = process_cmt_infos ~config ~cmtFilePath cmt_infos in + (* Cache the result *) + (match result with + | Some r -> + Hashtbl.replace file_cache cmtFilePath { + path = cmtFilePath; + file_data = r.dce_data; + exception_data = r.exception_data; + } + | None -> ()); + result + +(** Process all files incrementally. + First run processes all files. Subsequent runs only process changed files. *) +let process_files_incremental ~config cmtFilePaths : all_files_result = + Timing.time_phase `FileLoading (fun () -> + let dce_data_list = ref [] in + let exception_results = ref [] in + let processed = ref 0 in + let from_cache = ref 0 in + + cmtFilePaths |> List.iter (fun cmtFilePath -> + (* Check if file was in cache *before* processing *) + let was_cached = Hashtbl.mem file_cache cmtFilePath in + match process_cmt_cached ~config cmtFilePath with + | Some {dce_data; exception_data} -> + (match dce_data with + | Some data -> dce_data_list := data :: !dce_data_list + | None -> ()); + (match exception_data with + | Some data -> exception_results := data :: !exception_results + | None -> ()); + (* Track whether it was from cache *) + if was_cached then + incr from_cache + else + incr processed + | None -> () + ); + + if !Cli.timing then + Printf.eprintf "Reactive: %d files processed, %d from cache\n%!" !processed !from_cache; + + {dce_data_list = List.rev !dce_data_list; exception_results = List.rev !exception_results}) + +(** Clear all cached file data *) +let clear () = + Hashtbl.clear file_cache; + CmtCache.clear () + +(** Get cache statistics *) +let stats () = + let file_count = Hashtbl.length file_cache in + let cmt_stats = CmtCache.stats () in + (file_count, cmt_stats) + diff --git a/analysis/reanalyze/src/Reanalyze.ml b/analysis/reanalyze/src/Reanalyze.ml index 006454247d..58a4883e18 100644 --- a/analysis/reanalyze/src/Reanalyze.ml +++ b/analysis/reanalyze/src/Reanalyze.ml @@ -9,7 +9,10 @@ type cmt_file_result = { (** Process a cmt file and return its results. Conceptually: map over files, then merge results. *) let loadCmtFile ~config cmtFilePath : cmt_file_result option = - let cmt_infos = Cmt_format.read_cmt cmtFilePath in + let cmt_infos = + if !Cli.cmtCache then CmtCache.read_cmt cmtFilePath + else Cmt_format.read_cmt cmtFilePath + in let excludePath sourceFile = config.DceConfig.cli.exclude_paths |> List.exists (fun prefix_ -> @@ -206,20 +209,26 @@ let processFilesParallel ~config ~numDomains (cmtFilePaths : string list) : Conceptually: map process_cmt_file over all files. *) let processCmtFiles ~config ~cmtRoot : all_files_result = let cmtFilePaths = collectCmtFilePaths ~cmtRoot in - let numDomains = - match !Cli.parallel with - | n when n > 0 -> n - | n when n < 0 -> - (* Auto-detect: use recommended domain count (number of cores) *) - Domain.recommended_domain_count () - | _ -> 0 - in - if numDomains > 0 then ( - if !Cli.timing then - Printf.eprintf "Using %d parallel domains for %d files\n%!" numDomains - (List.length cmtFilePaths); - processFilesParallel ~config ~numDomains cmtFilePaths) - else processFilesSequential ~config cmtFilePaths + (* Reactive mode: use incremental processing that skips unchanged files *) + if !Cli.reactive then + let result = ReactiveAnalysis.process_files_incremental ~config cmtFilePaths in + {dce_data_list = result.dce_data_list; exception_results = result.exception_results} + else begin + let numDomains = + match !Cli.parallel with + | n when n > 0 -> n + | n when n < 0 -> + (* Auto-detect: use recommended domain count (number of cores) *) + Domain.recommended_domain_count () + | _ -> 0 + in + if numDomains > 0 then ( + if !Cli.timing then + Printf.eprintf "Using %d parallel domains for %d files\n%!" numDomains + (List.length cmtFilePaths); + processFilesParallel ~config ~numDomains cmtFilePaths) + else processFilesSequential ~config cmtFilePaths + end (* Shuffle a list using Fisher-Yates algorithm *) let shuffle_list lst = @@ -345,14 +354,22 @@ let runAnalysis ~dce_config ~cmtRoot = let runAnalysisAndReport ~cmtRoot = Log_.Color.setup (); Timing.enabled := !Cli.timing; - Timing.reset (); if !Cli.json then EmitJson.start (); let dce_config = DceConfig.current () in - runAnalysis ~dce_config ~cmtRoot; - Log_.Stats.report ~config:dce_config; - Log_.Stats.clear (); - if !Cli.json then EmitJson.finish (); - Timing.report () + let numRuns = max 1 !Cli.runs in + for run = 1 to numRuns do + Timing.reset (); + if numRuns > 1 && !Cli.timing then + Printf.eprintf "\n=== Run %d/%d ===\n%!" run numRuns; + runAnalysis ~dce_config ~cmtRoot; + if run = numRuns then begin + (* Only report on last run *) + Log_.Stats.report ~config:dce_config; + Log_.Stats.clear () + end; + Timing.report () + done; + if !Cli.json then EmitJson.finish () let cli () = let analysisKindSet = ref false in @@ -463,6 +480,16 @@ let cli () = "n Process files in parallel using n domains (0 = sequential, default; \ -1 = auto-detect cores)" ); ("-timing", Set Cli.timing, "Report internal timing of analysis phases"); + ( "-cmt-cache", + Set Cli.cmtCache, + "Use mmap cache for CMT files (faster for repeated analysis)" ); + ( "-reactive", + Set Cli.reactive, + "Use reactive analysis (caches processed file_data, skips unchanged \ + files)" ); + ( "-runs", + Int (fun n -> Cli.runs := n), + "n Run analysis n times (for benchmarking cache effectiveness)" ); ("-version", Unit versionAndExit, "Show version information and exit"); ("--version", Unit versionAndExit, "Show version information and exit"); ] diff --git a/analysis/reanalyze/src/dune b/analysis/reanalyze/src/dune index e8b736446f..a0045f8230 100644 --- a/analysis/reanalyze/src/dune +++ b/analysis/reanalyze/src/dune @@ -2,4 +2,4 @@ (name reanalyze) (flags (-w "+6+26+27+32+33+39")) - (libraries jsonlib ext ml str unix)) + (libraries jsonlib ext ml str unix marshal_cache)) diff --git a/analysis/vendor/dune b/analysis/vendor/dune index 07b8286153..7ccd94c6b7 100644 --- a/analysis/vendor/dune +++ b/analysis/vendor/dune @@ -1 +1 @@ -(dirs ext ml res_syntax json flow_parser) +(dirs ext ml res_syntax json flow_parser skip-lite) diff --git a/analysis/vendor/skip-lite/dune b/analysis/vendor/skip-lite/dune new file mode 100644 index 0000000000..4830047662 --- /dev/null +++ b/analysis/vendor/skip-lite/dune @@ -0,0 +1,8 @@ +; skip-lite vendor directory +(dirs marshal_cache reactive_file_collection) + +; Test executable for CMT file support +(executable + (name test_cmt) + (modules test_cmt) + (libraries marshal_cache ml)) diff --git a/analysis/vendor/skip-lite/marshal_cache/dune b/analysis/vendor/skip-lite/marshal_cache/dune new file mode 100644 index 0000000000..714dbcfc98 --- /dev/null +++ b/analysis/vendor/skip-lite/marshal_cache/dune @@ -0,0 +1,7 @@ +(library + (name marshal_cache) + (foreign_stubs + (language cxx) + (names marshal_cache_stubs) + (flags (:standard -std=c++17))) + (c_library_flags (-lstdc++))) diff --git a/analysis/vendor/skip-lite/marshal_cache/marshal_cache.ml b/analysis/vendor/skip-lite/marshal_cache/marshal_cache.ml new file mode 100644 index 0000000000..66da5e9c9f --- /dev/null +++ b/analysis/vendor/skip-lite/marshal_cache/marshal_cache.ml @@ -0,0 +1,71 @@ +(* Marshal Cache - OCaml implementation *) + +exception Cache_error of string * string + +type stats = { + entry_count : int; + mapped_bytes : int; +} + +(* Register the exception with the C runtime for proper propagation *) +let () = Callback.register_exception + "Marshal_cache.Cache_error" + (Cache_error ("", "")) + +(* External C stubs *) +external with_unmarshalled_file_stub : string -> ('a -> 'r) -> 'r + = "mfc_with_unmarshalled_file" + +external with_unmarshalled_if_changed_stub : string -> ('a -> 'r) -> 'r option + = "mfc_with_unmarshalled_if_changed" + +external clear_stub : unit -> unit = "mfc_clear" +external invalidate_stub : string -> unit = "mfc_invalidate" +external set_max_entries_stub : int -> unit = "mfc_set_max_entries" +external set_max_bytes_stub : int -> unit = "mfc_set_max_bytes" +external stats_stub : unit -> int * int = "mfc_stats" + +(* Public API *) + +let convert_failure path msg = + (* C code raises Failure with "path: message" format *) + (* Only convert if message starts with the path (i.e., from our C code) *) + let prefix = path ^ ": " in + let prefix_len = String.length prefix in + if String.length msg >= prefix_len && String.sub msg 0 prefix_len = prefix then + let error_msg = String.sub msg prefix_len (String.length msg - prefix_len) in + raise (Cache_error (path, error_msg)) + else + (* Re-raise user callback exceptions as-is *) + raise (Failure msg) + +let with_unmarshalled_file path f = + try + with_unmarshalled_file_stub path f + with + | Failure msg -> convert_failure path msg + [@@alert "-unsafe"] + +let with_unmarshalled_if_changed path f = + try + with_unmarshalled_if_changed_stub path f + with + | Failure msg -> convert_failure path msg + [@@alert "-unsafe"] + +let clear () = clear_stub () + +let invalidate path = invalidate_stub path + +let set_max_entries n = + if n < 0 then invalid_arg "Marshal_cache.set_max_entries: negative value"; + set_max_entries_stub n + +let set_max_bytes n = + if n < 0 then invalid_arg "Marshal_cache.set_max_bytes: negative value"; + set_max_bytes_stub n + +let stats () = + let (entry_count, mapped_bytes) = stats_stub () in + { entry_count; mapped_bytes } + diff --git a/analysis/vendor/skip-lite/marshal_cache/marshal_cache.mli b/analysis/vendor/skip-lite/marshal_cache/marshal_cache.mli new file mode 100644 index 0000000000..091c3f69c6 --- /dev/null +++ b/analysis/vendor/skip-lite/marshal_cache/marshal_cache.mli @@ -0,0 +1,120 @@ +(** Marshal Cache + + A high-performance cache for marshalled files that keeps file contents + memory-mapped (off-heap) and provides efficient repeated access with + automatic invalidation when files change on disk. + + {2 Memory Model} + + There is no fixed-size memory pool. Each cached file gets its own [mmap] + of exactly its file size: + + - {b mmap'd bytes}: Live in virtual address space (off-heap), managed by + OS + cache LRU eviction + - {b Unmarshalled value}: Lives in OCaml heap, managed by GC, exists only + during callback + + Physical RAM pages are allocated on demand (first access). Under memory + pressure, the OS can evict pages back to disk since they're file-backed. + + {2 Usage Example} + + {[ + Marshal_cache.with_unmarshalled_file "/path/to/data.marshal" + (fun (data : my_data_type) -> + (* Process data here - mmap stays valid for duration of callback *) + process data + ) + ]} + + {2 Platform Support} + + - macOS 10.13+: Fully supported + - Linux (glibc): Fully supported + - FreeBSD/OpenBSD: Should work (uses same mtime API as macOS) + - Windows: Not supported (no mmap) *) + +(** Exception raised for cache-related errors. + Contains the file path and an error message. *) +exception Cache_error of string * string + +(** Cache statistics. *) +type stats = { + entry_count : int; (** Number of files currently cached *) + mapped_bytes : int; (** Total bytes of memory-mapped data *) +} + +(** [with_unmarshalled_file path f] calls [f] with the unmarshalled value + from [path]. Guarantees the underlying mmap stays valid for the duration + of [f]. + + The cache automatically detects file changes via: + - Modification time (nanosecond precision where available) + - File size + - Inode number (detects atomic file replacement) + + {b Type safety warning}: This function is inherently unsafe. The caller + must ensure the type ['a] matches the actual marshalled data. Using the + wrong type results in undefined behavior (crashes, memory corruption). + This is equivalent to [Marshal.from_*] in terms of type safety. + + @raise Cache_error if the file cannot be read, mapped, or unmarshalled. + @raise exn if [f] raises; the cache state remains consistent. + + {b Thread safety}: Safe to call from multiple threads/domains. The cache + uses internal locking. The lock is released during the callback [f]. *) +val with_unmarshalled_file : string -> ('a -> 'r) -> 'r + [@@alert unsafe "Caller must ensure the file contains data of the expected type"] + +(** [with_unmarshalled_if_changed path f] is like {!with_unmarshalled_file} but + only unmarshals if the file changed since the last access. + + Returns [Some (f data)] if the file changed (or is accessed for the first time). + Returns [None] if the file has not changed since last access (no unmarshal occurs). + + This is the key primitive for building reactive/incremental systems: + {[ + let my_cache = Hashtbl.create 100 + + let get_result path = + match Marshal_cache.with_unmarshalled_if_changed path process with + | Some result -> + Hashtbl.replace my_cache path result; + result + | None -> + Hashtbl.find my_cache path (* use cached result *) + ]} + + @raise Cache_error if the file cannot be read, mapped, or unmarshalled. + @raise exn if [f] raises; the cache state remains consistent. *) +val with_unmarshalled_if_changed : string -> ('a -> 'r) -> 'r option + [@@alert unsafe "Caller must ensure the file contains data of the expected type"] + +(** Remove all entries from the cache, unmapping all memory. + Entries currently in use (during a callback) are preserved and will be + cleaned up when their callbacks complete. *) +val clear : unit -> unit + +(** [invalidate path] removes a specific path from the cache. + No-op if the path is not cached or is currently in use. *) +val invalidate : string -> unit + +(** [set_max_entries n] sets the maximum number of cached entries. + When exceeded, least-recently-used entries are evicted. + Default: 10000. Set to 0 for unlimited (not recommended for long-running + processes). + + @raise Invalid_argument if [n < 0] *) +val set_max_entries : int -> unit + +(** [set_max_bytes n] sets the maximum total bytes of mapped memory. + When exceeded, least-recently-used entries are evicted. + Default: 1GB (1073741824). Set to 0 for unlimited. + + @raise Invalid_argument if [n < 0] *) +val set_max_bytes : int -> unit + +(** [stats ()] returns cache statistics. + Useful for monitoring cache usage. *) +val stats : unit -> stats + diff --git a/analysis/vendor/skip-lite/marshal_cache/marshal_cache_stubs.cpp b/analysis/vendor/skip-lite/marshal_cache/marshal_cache_stubs.cpp new file mode 100644 index 0000000000..a18ba1b5a1 --- /dev/null +++ b/analysis/vendor/skip-lite/marshal_cache/marshal_cache_stubs.cpp @@ -0,0 +1,804 @@ +// marshal_cache_stubs.cpp +// skip-lite: Marshal cache with mmap and LRU eviction +// OCaml 5+ compatible +// +// ============================================================================= +// WARNING: OCaml C FFI and GC Pitfalls +// ============================================================================= +// +// This file interfaces with the OCaml runtime. The OCaml garbage collector +// can move values in memory at any allocation point. Failure to handle this +// correctly causes memory corruption and segfaults. +// +// KEY RULES: +// +// 1. NEVER use String_val(v) across an allocation +// ------------------------------------------------ +// BAD: +// const char* s = String_val(str_val); +// some_ocaml_alloc(); // GC may run, str_val moves, s is now dangling +// use(s); // SEGFAULT +// +// GOOD: +// std::string s(String_val(str_val)); // Copy to C++ string first +// some_ocaml_alloc(); +// use(s.c_str()); // Safe, C++ owns the memory +// +// 2. NEVER nest allocations in Store_field +// ------------------------------------------------ +// BAD: +// value tuple = caml_alloc_tuple(2); +// Store_field(tuple, 0, caml_copy_string(s)); // DANGEROUS! +// // caml_copy_string allocates, may trigger GC, tuple address is +// // computed BEFORE the call, so we write to stale memory +// +// GOOD: +// value tuple = caml_alloc_tuple(2); +// value str = caml_copy_string(s); // Allocate first +// Store_field(tuple, 0, str); // Then store +// +// 3. CAMLlocal doesn't help with evaluation order +// ------------------------------------------------ +// CAMLlocal registers a variable so GC updates it when values move. +// But it doesn't fix the evaluation order problem in Store_field. +// The address computation happens before the nested function call. +// +// 4. Raising exceptions from C is tricky +// ------------------------------------------------ +// caml_raise* functions do a longjmp, so: +// - CAMLparam/CAMLlocal frames are not properly unwound +// - C++ destructors may not run (avoid RAII in throwing paths) +// - Prefer raising simple exceptions (Failure) and converting in OCaml +// +// 5. Callbacks can trigger arbitrary GC +// ------------------------------------------------ +// When calling caml_callback*, the OCaml code can allocate freely. +// All value variables from before the callback may be stale after. +// Either re-read them or use CAMLlocal to keep them updated. +// +// CURRENT APPROACH: +// - Errors are raised as Failure("path: message") from C +// - The OCaml wrapper catches Failure and converts to Cache_error +// - This avoids complex allocation sequences in exception-raising paths +// +// ============================================================================= + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +// OCaml headers +extern "C" { +#include +#include +#include +#include +#include +#include +} + +// Platform-specific mtime access (nanosecond precision) +#if defined(__APPLE__) || defined(__FreeBSD__) || defined(__NetBSD__) || defined(__OpenBSD__) + #define MTIME_SEC(st) ((st).st_mtimespec.tv_sec) + #define MTIME_NSEC(st) ((st).st_mtimespec.tv_nsec) +#else // Linux and others + #define MTIME_SEC(st) ((st).st_mtim.tv_sec) + #define MTIME_NSEC(st) ((st).st_mtim.tv_nsec) +#endif + +namespace { + +// File identity for cache invalidation (mtime + size + inode) +struct FileId { + time_t mtime_sec; + long mtime_nsec; + off_t size; + ino_t ino; + + bool operator==(const FileId& other) const { + return mtime_sec == other.mtime_sec && + mtime_nsec == other.mtime_nsec && + size == other.size && + ino == other.ino; + } + + bool operator!=(const FileId& other) const { + return !(*this == other); + } +}; + +// A memory mapping +struct Mapping { + void* ptr = nullptr; + size_t len = 0; + FileId file_id = {}; + + bool is_valid() const { + return ptr != nullptr && ptr != MAP_FAILED && ptr != reinterpret_cast(1); + } +}; + +// Cache entry for a single file +struct Entry { + std::string path; + Mapping current; + size_t in_use = 0; // Number of active callbacks + std::vector old_mappings; // Deferred unmaps + std::list::iterator lru_iter; +}; + +// The global cache singleton +class MarshalCache { +public: + static MarshalCache& instance() { + static MarshalCache inst; + return inst; + } + + // Acquire a mapping, incrementing in_use. Returns pointer, length, and whether file changed. + // Throws std::runtime_error on failure. + void acquire_mapping(const std::string& path, void** out_ptr, size_t* out_len, bool* out_changed); + + // Release a mapping, decrementing in_use and cleaning up old mappings. + void release_mapping(const std::string& path); + + // Clear all entries (only those not in use) + void clear(); + + // Invalidate a specific path + void invalidate(const std::string& path); + + // Set limits + void set_max_entries(size_t n) { + std::lock_guard lock(mutex_); + max_entries_ = n; + evict_if_needed(); + } + + void set_max_bytes(size_t n) { + std::lock_guard lock(mutex_); + max_bytes_ = n; + evict_if_needed(); + } + + // Stats: (entry_count, total_mapped_bytes) + std::pair stats() { + std::lock_guard lock(mutex_); + return {cache_.size(), current_bytes_}; + } + +private: + MarshalCache() = default; + ~MarshalCache() { clear_internal(); } + + // Prevent copying + MarshalCache(const MarshalCache&) = delete; + MarshalCache& operator=(const MarshalCache&) = delete; + + // Must be called with mutex_ held + void evict_if_needed(); + void unmap_mapping(const Mapping& m); + void touch_lru(Entry& entry); + void clear_internal(); + + // Get file identity, throws on error + FileId get_file_id(const char* path); + + // Create a new mapping for a file, throws on error + Mapping create_mapping(const char* path, const FileId& file_id); + + std::unordered_map cache_; + std::list lru_order_; // front = most recent + std::mutex mutex_; + + size_t max_entries_ = 10000; + size_t max_bytes_ = 1ULL << 30; // 1GB default + size_t current_bytes_ = 0; +}; + +FileId MarshalCache::get_file_id(const char* path) { + struct stat st; + if (stat(path, &st) != 0) { + throw std::runtime_error(std::string("stat failed: ") + path + ": " + strerror(errno)); + } + return FileId{ + MTIME_SEC(st), + MTIME_NSEC(st), + st.st_size, + st.st_ino + }; +} + +Mapping MarshalCache::create_mapping(const char* path, const FileId& file_id) { + int fd = open(path, O_RDONLY); + if (fd < 0) { + throw std::runtime_error(std::string("open failed: ") + path + ": " + strerror(errno)); + } + + size_t len = static_cast(file_id.size); + void* ptr = nullptr; + + if (len > 0) { + ptr = mmap(nullptr, len, PROT_READ, MAP_PRIVATE, fd, 0); + } else { + // Empty file: use a sentinel non-null pointer + ptr = reinterpret_cast(1); + } + + // Close fd immediately - mapping remains valid on POSIX + close(fd); + + if (len > 0 && (ptr == MAP_FAILED || ptr == nullptr)) { + throw std::runtime_error(std::string("mmap failed: ") + path + ": " + strerror(errno)); + } + + Mapping m; + m.ptr = ptr; + m.len = len; + m.file_id = file_id; + return m; +} + +void MarshalCache::unmap_mapping(const Mapping& m) { + if (m.is_valid() && m.len > 0) { + munmap(m.ptr, m.len); + } +} + +void MarshalCache::touch_lru(Entry& entry) { + // Move to front of LRU list + lru_order_.erase(entry.lru_iter); + lru_order_.push_front(entry.path); + entry.lru_iter = lru_order_.begin(); +} + +void MarshalCache::evict_if_needed() { + // Must be called with mutex_ held + // Use >= because this is called BEFORE adding a new entry + while ((max_entries_ > 0 && cache_.size() >= max_entries_) || + (max_bytes_ > 0 && current_bytes_ >= max_bytes_)) { + if (lru_order_.empty()) break; + + // Find least-recently-used entry that is not in use + bool evicted = false; + for (auto it = lru_order_.rbegin(); it != lru_order_.rend(); ++it) { + auto cache_it = cache_.find(*it); + if (cache_it != cache_.end() && cache_it->second.in_use == 0) { + Entry& entry = cache_it->second; + + // Unmap current and all old mappings + unmap_mapping(entry.current); + for (const auto& m : entry.old_mappings) { + unmap_mapping(m); + } + current_bytes_ -= entry.current.len; + + lru_order_.erase(entry.lru_iter); + cache_.erase(cache_it); + evicted = true; + break; + } + } + if (!evicted) break; // All entries are in use + } +} + +void MarshalCache::acquire_mapping(const std::string& path, + void** out_ptr, size_t* out_len, bool* out_changed) { + std::unique_lock lock(mutex_); + + // Get current file identity + FileId current_id = get_file_id(path.c_str()); + + // Lookup or create entry + auto it = cache_.find(path); + bool needs_remap = false; + + if (it == cache_.end()) { + needs_remap = true; + } else if (it->second.current.file_id != current_id) { + needs_remap = true; + } + + if (needs_remap) { + // Only evict if we're adding a NEW entry (not updating existing) + // This prevents evicting the entry we're about to update + if (it == cache_.end()) { + evict_if_needed(); + } + + // Create new mapping (may throw) + Mapping new_mapping = create_mapping(path.c_str(), current_id); + + if (it == cache_.end()) { + // Insert new entry + Entry entry; + entry.path = path; + entry.current = new_mapping; + entry.in_use = 0; + lru_order_.push_front(path); + entry.lru_iter = lru_order_.begin(); + + cache_[path] = std::move(entry); + it = cache_.find(path); + } else { + // Update existing entry + Entry& entry = it->second; + Mapping old = entry.current; + entry.current = new_mapping; + + // Handle old mapping + if (old.is_valid()) { + if (entry.in_use == 0) { + unmap_mapping(old); + } else { + // Defer unmap until callbacks complete + entry.old_mappings.push_back(old); + } + current_bytes_ -= old.len; + } + } + + current_bytes_ += new_mapping.len; + } + + Entry& entry = it->second; + entry.in_use++; + touch_lru(entry); + + *out_ptr = entry.current.ptr; + *out_len = entry.current.len; + *out_changed = needs_remap; + + // Mutex released here (RAII) +} + +void MarshalCache::release_mapping(const std::string& path) { + std::lock_guard lock(mutex_); + + auto it = cache_.find(path); + if (it == cache_.end()) return; // Entry was evicted + + Entry& entry = it->second; + if (entry.in_use > 0) { + entry.in_use--; + } + + if (entry.in_use == 0 && !entry.old_mappings.empty()) { + // Clean up deferred unmaps + for (const auto& m : entry.old_mappings) { + unmap_mapping(m); + } + entry.old_mappings.clear(); + } +} + +void MarshalCache::clear_internal() { + for (auto& [path, entry] : cache_) { + if (entry.in_use == 0) { + unmap_mapping(entry.current); + } + for (const auto& m : entry.old_mappings) { + unmap_mapping(m); + } + } + cache_.clear(); + lru_order_.clear(); + current_bytes_ = 0; +} + +void MarshalCache::clear() { + std::lock_guard lock(mutex_); + + // Only clear entries not in use + for (auto it = cache_.begin(); it != cache_.end(); ) { + Entry& entry = it->second; + + // Always clean up old_mappings + for (const auto& m : entry.old_mappings) { + unmap_mapping(m); + } + entry.old_mappings.clear(); + + if (entry.in_use == 0) { + unmap_mapping(entry.current); + current_bytes_ -= entry.current.len; + lru_order_.erase(entry.lru_iter); + it = cache_.erase(it); + } else { + ++it; + } + } +} + +void MarshalCache::invalidate(const std::string& path) { + std::lock_guard lock(mutex_); + + auto it = cache_.find(path); + if (it == cache_.end()) return; + + Entry& entry = it->second; + + // Clean up old_mappings + for (const auto& m : entry.old_mappings) { + unmap_mapping(m); + } + entry.old_mappings.clear(); + + if (entry.in_use == 0) { + unmap_mapping(entry.current); + current_bytes_ -= entry.current.len; + lru_order_.erase(entry.lru_iter); + cache_.erase(it); + } + // If in_use > 0, the entry stays but will be refreshed on next access +} + +} // anonymous namespace + + +// ============================================================================= +// OCaml FFI stubs +// ============================================================================= + +extern "C" { + +// Helper to raise an error as Failure (converted to Cache_error in OCaml) +[[noreturn]] +static void raise_cache_error(const char* path, const char* message) { + std::string full_msg = std::string(path) + ": " + message; + caml_failwith(full_msg.c_str()); +} + +// ============================================================================= +// CMT/CMI file format support +// ============================================================================= +// +// ReScript/OCaml compiler generates several file types with headers before Marshal data: +// +// Pure .cmt files (typed tree only): +// - "Caml1999T0xx" (12 bytes) - CMT magic +// - Marshal data (cmt_infos record) +// +// Combined .cmt/.cmti files (interface + typed tree): +// - "Caml1999I0xx" (12 bytes) - CMI magic +// - Marshal data #1 (cmi_name, cmi_sign) +// - Marshal data #2 (crcs) +// - Marshal data #3 (flags) +// - "Caml1999T0xx" (12 bytes) - CMT magic +// - Marshal data (cmt_infos record) +// +// Pure .cmi files (compiled interface only): +// - "Caml1999I0xx" (12 bytes) - CMI magic +// - Marshal data #1 (cmi_name, cmi_sign) +// - Marshal data #2 (crcs) +// - Marshal data #3 (flags) +// +// This code handles all formats and finds the CMT Marshal data. +// ============================================================================= + +static constexpr size_t OCAML_MAGIC_LENGTH = 12; +static constexpr const char* CMT_MAGIC_PREFIX = "Caml1999T"; +static constexpr const char* CMI_MAGIC_PREFIX = "Caml1999I"; +static constexpr size_t MAGIC_PREFIX_LENGTH = 9; // Length of "Caml1999T" or "Caml1999I" + +// Check if data at offset starts with a specific prefix +static bool has_prefix_at(const unsigned char* data, size_t len, size_t offset, + const char* prefix, size_t prefix_len) { + if (len < offset + prefix_len) return false; + return memcmp(data + offset, prefix, prefix_len) == 0; +} + +// Check for Marshal magic at given offset +// Marshal magic: 0x8495A6BE (small/32-bit) or 0x8495A6BF (large/64-bit) +static bool has_marshal_magic_at(const unsigned char* data, size_t len, size_t offset) { + if (len < offset + 4) return false; + uint32_t magic = (static_cast(data[offset]) << 24) | + (static_cast(data[offset + 1]) << 16) | + (static_cast(data[offset + 2]) << 8) | + static_cast(data[offset + 3]); + return magic == 0x8495A6BEu || magic == 0x8495A6BFu; +} + +// Get the size of a Marshal value from its header +// Marshal header format (20 bytes for small, 32 bytes for large): +// 4 bytes: magic +// 4 bytes: data_len (or 8 bytes for large) +// 4 bytes: num_objects (or 8 bytes for large) +// 4 bytes: size_32 (or 8 bytes for large) +// 4 bytes: size_64 (or 8 bytes for large) +// Total Marshal value size = header_size + data_len +static size_t get_marshal_total_size(const unsigned char* data, size_t len, size_t offset) { + if (len < offset + 20) { + throw std::runtime_error("not enough data for Marshal header"); + } + + uint32_t magic = (static_cast(data[offset]) << 24) | + (static_cast(data[offset + 1]) << 16) | + (static_cast(data[offset + 2]) << 8) | + static_cast(data[offset + 3]); + + bool is_large = (magic == 0x8495A6BFu); + size_t header_size = is_large ? 32 : 20; + + if (len < offset + header_size) { + throw std::runtime_error("not enough data for Marshal header"); + } + + // data_len is at offset 4 (32-bit) or offset 4 (64-bit, we read low 32 bits which is enough) + uint32_t data_len; + if (is_large) { + // For large format, data_len is 8 bytes. Read as 64-bit but we only care about reasonable sizes. + // High 32 bits at offset+4, low 32 bits at offset+8 + uint32_t high = (static_cast(data[offset + 4]) << 24) | + (static_cast(data[offset + 5]) << 16) | + (static_cast(data[offset + 6]) << 8) | + static_cast(data[offset + 7]); + uint32_t low = (static_cast(data[offset + 8]) << 24) | + (static_cast(data[offset + 9]) << 16) | + (static_cast(data[offset + 10]) << 8) | + static_cast(data[offset + 11]); + if (high != 0) { + throw std::runtime_error("Marshal data too large (>4GB)"); + } + data_len = low; + } else { + data_len = (static_cast(data[offset + 4]) << 24) | + (static_cast(data[offset + 5]) << 16) | + (static_cast(data[offset + 6]) << 8) | + static_cast(data[offset + 7]); + } + + return header_size + data_len; +} + +// Find the offset where CMT Marshal data starts +// Returns the offset, or throws on error +static size_t find_cmt_marshal_offset(const unsigned char* data, size_t len) { + if (len < 4) { + throw std::runtime_error("file too small"); + } + + // Check for pure Marshal file (starts with Marshal magic) + if (has_marshal_magic_at(data, len, 0)) { + return 0; + } + + // Check for pure CMT file (starts with "Caml1999T") + if (has_prefix_at(data, len, 0, CMT_MAGIC_PREFIX, MAGIC_PREFIX_LENGTH)) { + if (len < OCAML_MAGIC_LENGTH + 4) { + throw std::runtime_error("CMT file too small"); + } + if (!has_marshal_magic_at(data, len, OCAML_MAGIC_LENGTH)) { + throw std::runtime_error("CMT file: no Marshal magic after header"); + } + return OCAML_MAGIC_LENGTH; + } + + // Check for CMI file (starts with "Caml1999I") + // This may be a combined CMI+CMT file, need to skip CMI data to find CMT + if (has_prefix_at(data, len, 0, CMI_MAGIC_PREFIX, MAGIC_PREFIX_LENGTH)) { + if (len < OCAML_MAGIC_LENGTH + 4) { + throw std::runtime_error("CMI file too small"); + } + + // Skip the CMI header + size_t offset = OCAML_MAGIC_LENGTH; + + // CMI section has 3 Marshal values: + // 1. (cmi_name, cmi_sign) + // 2. crcs + // 3. flags + for (int i = 0; i < 3; i++) { + if (!has_marshal_magic_at(data, len, offset)) { + throw std::runtime_error("CMI file: expected Marshal value in CMI section"); + } + size_t marshal_size = get_marshal_total_size(data, len, offset); + offset += marshal_size; + if (offset > len) { + throw std::runtime_error("CMI file: Marshal value extends past end of file"); + } + } + + // Now check if there's a CMT section after the CMI data + if (has_prefix_at(data, len, offset, CMT_MAGIC_PREFIX, MAGIC_PREFIX_LENGTH)) { + // Found CMT magic after CMI data + offset += OCAML_MAGIC_LENGTH; + if (!has_marshal_magic_at(data, len, offset)) { + throw std::runtime_error("CMT section: no Marshal magic after header"); + } + return offset; + } + + // No CMT section - this is a pure CMI file + // Return the first CMI Marshal value (not ideal but allows reading CMI files) + throw std::runtime_error("CMI file without CMT section - use read_cmi instead"); + } + + // Unknown format + throw std::runtime_error("unrecognized file format (not Marshal, CMT, or CMI)"); +} + +// Unmarshal from mmap'd memory (zero-copy using OCaml 5+ API) +// Handles both pure Marshal files and CMT/CMI files with headers +static value unmarshal_from_ptr(void* ptr, size_t len) { + CAMLparam0(); + CAMLlocal1(result); + + if (len == 0) { + caml_failwith("marshal_cache: empty file"); + } + + const unsigned char* data = static_cast(ptr); + + // Find where CMT Marshal data starts (handles CMT/CMI headers) + size_t offset; + try { + offset = find_cmt_marshal_offset(data, len); + } catch (const std::exception& e) { + std::string msg = std::string("marshal_cache: ") + e.what(); + caml_failwith(msg.c_str()); + } + + // Validate remaining length + size_t marshal_len = len - offset; + if (marshal_len < 20) { + caml_failwith("marshal_cache: Marshal data too small"); + } + + // OCaml 5+ API: unmarshal directly from memory block (zero-copy!) + const char* marshal_ptr = reinterpret_cast(data + offset); + result = caml_input_value_from_block(marshal_ptr, static_cast(marshal_len)); + + CAMLreturn(result); +} + +// Main entry point: with_unmarshalled_file +CAMLprim value mfc_with_unmarshalled_file(value path_val, value closure_val) { + CAMLparam2(path_val, closure_val); + CAMLlocal2(unmarshalled, result); + + const char* path = String_val(path_val); + std::string path_str(path); + + void* ptr = nullptr; + size_t len = 0; + bool changed = false; + + // Acquire mapping (may throw) + try { + MarshalCache::instance().acquire_mapping(path_str, &ptr, &len, &changed); + } catch (const std::exception& e) { + // Use path_str.c_str() instead of path, because raise_cache_error + // allocates and can trigger GC which would invalidate the pointer + // from String_val(path_val) + raise_cache_error(path_str.c_str(), e.what()); + CAMLreturn(Val_unit); // Not reached + } + + // Unmarshal (may allocate, may trigger GC, may raise) + unmarshalled = unmarshal_from_ptr(ptr, len); + + // Call the OCaml callback + result = caml_callback_exn(closure_val, unmarshalled); + + // Release mapping before potentially re-raising + MarshalCache::instance().release_mapping(path_str); + + // Check if callback raised an exception + if (Is_exception_result(result)) { + value exn = Extract_exception(result); + caml_raise(exn); + } + + CAMLreturn(result); +} + +// Reactive entry point: only unmarshal if file changed +// Returns Some(f(data)) if changed, None if unchanged +CAMLprim value mfc_with_unmarshalled_if_changed(value path_val, value closure_val) { + CAMLparam2(path_val, closure_val); + CAMLlocal3(unmarshalled, result, some_result); + + const char* path = String_val(path_val); + std::string path_str(path); + + void* ptr = nullptr; + size_t len = 0; + bool changed = false; + + // Acquire mapping (may throw) + try { + MarshalCache::instance().acquire_mapping(path_str, &ptr, &len, &changed); + } catch (const std::exception& e) { + raise_cache_error(path_str.c_str(), e.what()); + CAMLreturn(Val_unit); // Not reached + } + + if (!changed) { + // File unchanged - release and return None + MarshalCache::instance().release_mapping(path_str); + CAMLreturn(Val_none); + } + + // File changed - unmarshal and call callback + unmarshalled = unmarshal_from_ptr(ptr, len); + + // Call the OCaml callback + result = caml_callback_exn(closure_val, unmarshalled); + + // Release mapping before potentially re-raising + MarshalCache::instance().release_mapping(path_str); + + // Check if callback raised an exception + if (Is_exception_result(result)) { + value exn = Extract_exception(result); + caml_raise(exn); + } + + // Wrap in Some + some_result = caml_alloc(1, 0); + Store_field(some_result, 0, result); + + CAMLreturn(some_result); +} + +// Clear all cache entries +CAMLprim value mfc_clear(value unit) { + CAMLparam1(unit); + MarshalCache::instance().clear(); + CAMLreturn(Val_unit); +} + +// Invalidate a specific path +CAMLprim value mfc_invalidate(value path_val) { + CAMLparam1(path_val); + const char* path = String_val(path_val); + std::string path_str(path); // Copy immediately for consistency + MarshalCache::instance().invalidate(path_str); + CAMLreturn(Val_unit); +} + +// Set max entries +CAMLprim value mfc_set_max_entries(value n_val) { + CAMLparam1(n_val); + size_t n = Long_val(n_val); + MarshalCache::instance().set_max_entries(n); + CAMLreturn(Val_unit); +} + +// Set max bytes +CAMLprim value mfc_set_max_bytes(value n_val) { + CAMLparam1(n_val); + size_t n = Long_val(n_val); + MarshalCache::instance().set_max_bytes(n); + CAMLreturn(Val_unit); +} + +// Get stats: returns (entry_count, total_mapped_bytes) +CAMLprim value mfc_stats(value unit) { + CAMLparam1(unit); + CAMLlocal1(result); + + auto [entries, bytes] = MarshalCache::instance().stats(); + + result = caml_alloc_tuple(2); + Store_field(result, 0, Val_long(entries)); + Store_field(result, 1, Val_long(bytes)); + + CAMLreturn(result); +} + +} // extern "C" + diff --git a/analysis/vendor/skip-lite/reactive_file_collection/dune b/analysis/vendor/skip-lite/reactive_file_collection/dune new file mode 100644 index 0000000000..e83405cb88 --- /dev/null +++ b/analysis/vendor/skip-lite/reactive_file_collection/dune @@ -0,0 +1,3 @@ +(library + (name reactive_file_collection) + (libraries marshal_cache)) diff --git a/analysis/vendor/skip-lite/reactive_file_collection/reactive_file_collection.ml b/analysis/vendor/skip-lite/reactive_file_collection/reactive_file_collection.ml new file mode 100644 index 0000000000..a7e1babf5f --- /dev/null +++ b/analysis/vendor/skip-lite/reactive_file_collection/reactive_file_collection.ml @@ -0,0 +1,95 @@ +(* Reactive File Collection - Implementation *) + +type event = + | Added of string + | Removed of string + | Modified of string + +type 'v t = { + data : (string, 'v) Hashtbl.t; + process : 'a. 'a -> 'v; +} + +(* We need to use Obj.magic to make the polymorphic process function work + with Marshal_cache which returns 'a. This is safe because the user + guarantees the file contains data of the expected type. *) +type 'v process_fn = Obj.t -> 'v + +type 'v t_internal = { + data_internal : (string, 'v) Hashtbl.t; + process_internal : 'v process_fn; +} + +let create (type a v) ~(process : a -> v) : v t = + let process_internal : v process_fn = fun obj -> process (Obj.obj obj) in + let t = { + data_internal = Hashtbl.create 256; + process_internal; + } in + (* Safe cast - same representation *) + Obj.magic t + +let to_internal (t : 'v t) : 'v t_internal = Obj.magic t + +let add t path = + let t = to_internal t in + let value = Marshal_cache.with_unmarshalled_file path (fun data -> + t.process_internal (Obj.repr data) + ) in + Hashtbl.replace t.data_internal path value + [@@alert "-unsafe"] + +let remove t path = + let t = to_internal t in + Hashtbl.remove t.data_internal path + +let update t path = + (* Just reload - Marshal_cache handles the file reading efficiently *) + add t path + +let apply t events = + List.iter (function + | Added path -> add t path + | Removed path -> remove t path + | Modified path -> update t path + ) events + +let get t path = + let t = to_internal t in + Hashtbl.find_opt t.data_internal path + +let find t path = + let t = to_internal t in + Hashtbl.find t.data_internal path + +let mem t path = + let t = to_internal t in + Hashtbl.mem t.data_internal path + +let length t = + let t = to_internal t in + Hashtbl.length t.data_internal + +let is_empty t = + length t = 0 + +let iter f t = + let t = to_internal t in + Hashtbl.iter f t.data_internal + +let fold f t init = + let t = to_internal t in + Hashtbl.fold f t.data_internal init + +let to_list t = + fold (fun k v acc -> (k, v) :: acc) t [] + +let paths t = + fold (fun k _ acc -> k :: acc) t [] + +let values t = + fold (fun _ v acc -> v :: acc) t [] + + + + diff --git a/analysis/vendor/skip-lite/reactive_file_collection/reactive_file_collection.mli b/analysis/vendor/skip-lite/reactive_file_collection/reactive_file_collection.mli new file mode 100644 index 0000000000..56ae3e4c2e --- /dev/null +++ b/analysis/vendor/skip-lite/reactive_file_collection/reactive_file_collection.mli @@ -0,0 +1,115 @@ +(** Reactive File Collection + + A collection that maps file paths to processed values, with efficient + delta-based updates. Designed for use with file watchers. + + {2 Usage Example} + + {[ + (* Create collection with processing function *) + let coll = Reactive_file_collection.create + ~process:(fun (data : Cmt_format.cmt_infos) -> + extract_types data + ) + + (* Initial load *) + List.iter (Reactive_file_collection.add coll) (glob "*.cmt") + + (* On file watcher events *) + match event with + | Created path -> Reactive_file_collection.add coll path + | Deleted path -> Reactive_file_collection.remove coll path + | Modified path -> Reactive_file_collection.update coll path + + (* Access the collection *) + Reactive_file_collection.iter (fun path value -> ...) coll + ]} + + {2 Thread Safety} + + Not thread-safe. Use external synchronization if accessed from + multiple threads/domains. *) + +(** The type of a reactive file collection with values of type ['v]. *) +type 'v t + +(** Events for batch updates. *) +type event = + | Added of string (** File was created *) + | Removed of string (** File was deleted *) + | Modified of string (** File was modified *) + +(** {1 Creation} *) + +val create : process:('a -> 'v) -> 'v t +(** [create ~process] creates an empty collection. + + [process] is called to transform unmarshalled file contents into values. + + {b Type safety warning}: The caller must ensure files contain data of + type ['a]. This has the same safety properties as [Marshal.from_*]. + + @alert unsafe Caller must ensure files contain data of the expected type *) + +(** {1 Delta Operations} *) + +val add : 'v t -> string -> unit +(** [add t path] adds a file to the collection. + Loads the file, unmarshals, and processes immediately. + + @raise Marshal_cache.Cache_error if file cannot be read or unmarshalled *) + +val remove : 'v t -> string -> unit +(** [remove t path] removes a file from the collection. + No-op if path is not in collection. *) + +val update : 'v t -> string -> unit +(** [update t path] reloads a modified file. + Equivalent to remove + add, but more efficient. + + @raise Marshal_cache.Cache_error if file cannot be read or unmarshalled *) + +val apply : 'v t -> event list -> unit +(** [apply t events] applies multiple events. + More efficient than individual operations for batches. + + @raise Marshal_cache.Cache_error if any added/modified file fails *) + +(** {1 Access} *) + +val get : 'v t -> string -> 'v option +(** [get t path] returns the value for [path], or [None] if not present. *) + +val find : 'v t -> string -> 'v +(** [find t path] returns the value for [path]. + @raise Not_found if path is not in collection *) + +val mem : 'v t -> string -> bool +(** [mem t path] returns [true] if [path] is in the collection. *) + +val length : 'v t -> int +(** [length t] returns the number of files in the collection. *) + +val is_empty : 'v t -> bool +(** [is_empty t] returns [true] if the collection is empty. *) + +(** {1 Iteration} *) + +val iter : (string -> 'v -> unit) -> 'v t -> unit +(** [iter f t] applies [f] to each (path, value) pair. *) + +val fold : (string -> 'v -> 'acc -> 'acc) -> 'v t -> 'acc -> 'acc +(** [fold f t init] folds [f] over all (path, value) pairs. *) + +val to_list : 'v t -> (string * 'v) list +(** [to_list t] returns all (path, value) pairs as a list. *) + +val paths : 'v t -> string list +(** [paths t] returns all paths in the collection. *) + +val values : 'v t -> 'v list +(** [values t] returns all values in the collection. *) + + + + diff --git a/analysis/vendor/skip-lite/test_cmt.ml b/analysis/vendor/skip-lite/test_cmt.ml new file mode 100644 index 0000000000..c2a4c21f7e --- /dev/null +++ b/analysis/vendor/skip-lite/test_cmt.ml @@ -0,0 +1,119 @@ +(* Test that Marshal_cache can read CMT files *) + +[@@@alert "-unsafe"] + +let print_cmt_info (cmt : Cmt_format.cmt_infos) = + Printf.printf " Module name: %s\n%!" cmt.cmt_modname; + Printf.printf " Build dir: %s\n%!" cmt.cmt_builddir; + (match cmt.cmt_sourcefile with + | Some sf -> Printf.printf " Source file: %s\n%!" sf + | None -> Printf.printf " Source file: none\n%!") + +let test_cmt_file_standard path = + Printf.printf "Testing with Cmt_format.read_cmt: %s\n%!" path; + try + let cmt = Cmt_format.read_cmt path in + print_cmt_info cmt; + Printf.printf " SUCCESS with standard read_cmt\n%!"; + true + with e -> + Printf.printf " FAILED: %s\n%!" (Printexc.to_string e); + false + +let test_cmt_file_cache path = + Printf.printf "Testing with Marshal_cache: %s\n%!" path; + try + Marshal_cache.with_unmarshalled_file path (fun (cmt : Cmt_format.cmt_infos) -> + print_cmt_info cmt; + Printf.printf " SUCCESS with Marshal_cache!\n%!"; + true + ) + with + | Marshal_cache.Cache_error (p, msg) -> + Printf.printf " Cache_error: %s: %s\n%!" p msg; + false + | e -> + Printf.printf " FAILED: %s\n%!" (Printexc.to_string e); + false + +let test_cmt_file path = + if not (Sys.file_exists path) then begin + Printf.printf "File not found: %s\n%!" path; + false + end else begin + Printf.printf "\n=== Testing: %s ===\n%!" path; + let std_ok = test_cmt_file_standard path in + Printf.printf "\n%!"; + let cache_ok = test_cmt_file_cache path in + std_ok && cache_ok + end + + +let () = + Printf.printf "=== Marshal_cache CMT Test ===\n\n%!"; + + (* Get CMT files from command line args or find in lib/bs *) + let cmt_files = + if Array.length Sys.argv > 1 then + Array.to_list (Array.sub Sys.argv 1 (Array.length Sys.argv - 1)) + else begin + (* Find CMT files in lib/bs *) + let find_cmt_in_dir dir = + if Sys.file_exists dir && Sys.is_directory dir then begin + let rec find acc dir = + Array.fold_left (fun acc name -> + let path = Filename.concat dir name in + if Sys.is_directory path then + find acc path + else if Filename.check_suffix path ".cmt" then + path :: acc + else + acc + ) acc (Sys.readdir dir) + in + find [] dir + end else [] + in + let lib_bs = "lib/bs" in + let files = find_cmt_in_dir lib_bs in + Printf.printf "Found %d CMT files in %s\n\n%!" (List.length files) lib_bs; + files + end + in + + (* Test first 3 CMT files *) + let test_files = + cmt_files + |> List.sort String.compare + |> (fun l -> try List.filteri (fun i _ -> i < 3) l with _ -> l) + in + + List.iter (fun path -> + let _ = test_cmt_file path in + Printf.printf "\n%!" + ) test_files; + + (* Test if_changed API *) + Printf.printf "=== Testing with_unmarshalled_if_changed ===\n\n%!"; + Marshal_cache.clear (); (* Clear cache to start fresh *) + (match test_files with + | path :: _ -> + Printf.printf "First call (should process):\n%!"; + (match Marshal_cache.with_unmarshalled_if_changed path (fun (cmt : Cmt_format.cmt_infos) -> + Printf.printf " Processed: %s\n%!" cmt.cmt_modname; + cmt.cmt_modname + ) with + | Some name -> Printf.printf " Result: Some(%s) - SUCCESS (file was processed)\n%!" name + | None -> Printf.printf " Result: None (unexpected - should have processed!)\n%!"); + + Printf.printf "Second call (should return None - file unchanged):\n%!"; + (match Marshal_cache.with_unmarshalled_if_changed path (fun (cmt : Cmt_format.cmt_infos) -> + Printf.printf " Processed: %s\n%!" cmt.cmt_modname; + cmt.cmt_modname + ) with + | Some name -> Printf.printf " Result: Some(%s) (unexpected - file should be cached!)\n%!" name + | None -> Printf.printf " Result: None - SUCCESS (file was cached!)\n%!") + | [] -> Printf.printf "No CMT files to test\n%!"); + + Printf.printf "\n=== Test Complete ===\n%!" + diff --git a/docs/reactive_reanalyze_design.md b/docs/reactive_reanalyze_design.md new file mode 100644 index 0000000000..c19b0ac1c0 --- /dev/null +++ b/docs/reactive_reanalyze_design.md @@ -0,0 +1,469 @@ +# Reactive Reanalyze: Using skip-lite for Incremental Analysis + +## Executive Summary + +This document investigates how skip-lite's reactive collections can be used to create an analysis service that stays on and reacts to file changes, dramatically speeding up CMT processing for repeated analysis runs. + +**Key Insight**: The benchmark results from skip-lite show a **950x speedup** when processing only changed files vs. re-reading all files. Applied to reanalyze with ~4900 files (50 copies benchmark), this could reduce CMT processing from ~780ms to ~1-2ms for typical incremental changes. + +## Current Architecture + +### Reanalyze Processing Flow + +``` + ┌─────────────────┐ + │ Collect CMT │ + │ File Paths │ + └────────┬────────┘ + │ + ┌────────▼────────┐ + │ Load CMT Files │ ← 77% of time (~780ms) + │ (Cmt_format. │ + │ read_cmt) │ + └────────┬────────┘ + │ + ┌────────▼────────┐ + │ Process Each │ + │ File → file_data│ + └────────┬────────┘ + │ + ┌─────────────────┴─────────────────┐ + │ │ + ┌────────▼────────┐ ┌────────▼────────┐ + │ Merge Builders │ │ Exception │ + │ (annotations, │ │ Results │ + │ decls, refs, │ └─────────────────┘ + │ cross_file, │ + │ file_deps) │ ← 8% of time (~80ms) + └────────┬────────┘ + │ + ┌────────▼────────┐ + │ Solve (DCE, │ ← 15% of time (~150ms) + │ optional args) │ + └────────┬────────┘ + │ + ┌────────▼────────┐ + │ Report Issues │ ← <1% of time + └─────────────────┘ +``` + +### Current Bottleneck + +From the benchmark (50 copies, ~4900 files, 12 cores): + +| Phase | Sequential | Parallel | % of Total | +|-------|-----------|----------|------------| +| File loading | 779ms | 422ms | 77% / 64% | +| Merging | 81ms | 94ms | 8% / 14% | +| Solving | 146ms | 148ms | 15% / 22% | +| Total | 1007ms | 664ms | 100% | + +**CMT file loading is the dominant cost** because each file requires: +1. System call to open file +2. Reading marshalled data from disk +3. Unmarshalling into OCaml heap +4. AST traversal to extract analysis data + +## Proposed Architecture: Reactive Analysis Service + +### Design Goals + +1. **Persistent service** - Stay running and maintain state between analysis runs +2. **File watching** - React to file changes (create/modify/delete) +3. **Incremental updates** - Only process changed files +4. **Cached results** - Keep processed `file_data` in memory +5. **Fast iteration** - Sub-10ms response for typical edits + +### Integration with skip-lite + +skip-lite provides two key primitives: + +#### 1. `Marshal_cache` - Efficient CMT Loading + +```ocaml +(* Instead of Cmt_format.read_cmt which does file I/O every time *) +let load_cmt path = + Marshal_cache.with_unmarshalled_file path (fun cmt_infos -> + DceFileProcessing.process_cmt_file ~config ~file ~cmtFilePath cmt_infos + ) +``` + +**Benefits**: +- Memory-mapped, off-heap storage (not GC-scanned) +- LRU eviction for memory management +- Automatic invalidation on file change + +#### 2. `Reactive_file_collection` - Delta-Based Processing + +```ocaml +(* Create collection that maps CMT paths to processed file_data *) +let cmt_collection = Reactive_file_collection.create + ~process:(fun (cmt_infos : Cmt_format.cmt_infos) -> + (* This is called only when file changes *) + process_cmt_for_dce ~config cmt_infos + ) + +(* Initial load - process all files once *) +List.iter (Reactive_file_collection.add cmt_collection) all_cmt_paths + +(* On file watcher event - only process changed files *) +Reactive_file_collection.apply cmt_collection [ + Modified "lib/bs/src/MyModule.cmt"; + Modified "lib/bs/src/MyModule.cmti"; +] + +(* Get all processed data for analysis *) +let file_data_list = Reactive_file_collection.values cmt_collection +``` + +### Service Architecture + +``` +┌────────────────────────────────────────────────────────────────┐ +│ Reanalyze Service │ +├────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌─────────────────────────────────┐ │ +│ │ File Watcher │─────▶│ Reactive_file_collection │ │ +│ │ (fswatch/ │ │ ┌───────────────────────────┐ │ │ +│ │ inotify) │ │ │ path → file_data cache │ │ │ +│ └──────────────┘ │ │ (backed by Marshal_cache) │ │ │ +│ │ └───────────────────────────┘ │ │ +│ └──────────┬──────────────────────┘ │ +│ │ │ +│ │ file_data_list │ +│ ▼ │ +│ ┌─────────────────────────────────┐ │ +│ │ Incremental Merge & Solve │ │ +│ │ (may be reactive in future) │ │ +│ └──────────┬──────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────┐ │ +│ │ Issues / Reports │ │ +│ └─────────────────────────────────┘ │ +│ │ +└────────────────────────────────────────────────────────────────┘ +``` + +### API Design + +```ocaml +module ReactiveReanalyze : sig + type t + (** A reactive analysis service *) + + val create : config:DceConfig.t -> project_root:string -> t + (** Create a new reactive analysis service *) + + val start : t -> unit + (** Start file watching and initial analysis *) + + val stop : t -> unit + (** Stop file watching *) + + val analyze : t -> AnalysisResult.t + (** Run analysis on current state. Fast if no files changed. *) + + val on_file_change : t -> string -> unit + (** Notify of a file change (for external file watchers) *) + + val apply_events : t -> Reactive_file_collection.event list -> unit + (** Apply batch of file events *) +end +``` + +## Performance Analysis + +### Expected Speedup + +| Scenario | Current | With skip-lite | Speedup | +|----------|---------|----------------|---------| +| Cold start (all files) | 780ms | 780ms | 1x | +| Warm cache, no changes | 780ms | ~20ms | **39x** | +| Single file changed | 780ms | ~2ms | **390x** | +| 10 files changed | 780ms | ~15ms | **52x** | + +### How skip-lite Achieves This + +1. **Marshal_cache.with_unmarshalled_if_changed**: + - Stats all files to check modification time (~20ms for 5000 files) + - Only unmarshals files that changed + - Returns `None` for unchanged files, `Some result` for changed + +2. **Reactive_file_collection**: + - Maintains hash table of processed values + - On `apply`, only processes files in the event list + - Iteration is O(n) but values are already computed + +### Memory Considerations + +| Data | Storage | GC Impact | +|------|---------|-----------| +| CMT file bytes | mmap (off-heap) | None | +| Unmarshalled cmt_infos | OCaml heap (temporary) | During callback only | +| Processed file_data | OCaml heap (cached) | Scanned by GC | + +For 5000 files with average 20KB each: +- mmap cache: ~100MB (off-heap, OS-managed) +- file_data cache: ~50MB (on-heap, estimate) + +## Implementation Plan + +### Phase 1: Integration Setup + +1. **Add skip-lite dependency** to dune/opam +2. **Create wrapper module** `CmtCache` that provides: + ```ocaml + val read_cmt : string -> Cmt_format.cmt_infos + (** Drop-in replacement for Cmt_format.read_cmt using Marshal_cache *) + ``` + +### Phase 2: Reactive Collection + +1. **Define file_data type** as the cached result type +2. **Create reactive collection** for CMT → file_data mapping +3. **Implement delta processing** that only reprocesses changed files + +### Phase 3: Analysis Service + +1. **File watching integration** (can use fswatch, inotify, or external watcher) +2. **Service loop** that waits for events and re-runs analysis +3. **LSP integration** (optional) for editor support + +### Phase 4: Incremental Merge & Solve (Future) + +The current merge and solve phases are relatively fast (22% of time), but could be made incremental in the future: + +- Track which declarations changed +- Incrementally update reference graph +- Re-solve only affected transitive closure + +## Prototype Implementation + +Here's a minimal prototype showing how to integrate `Reactive_file_collection`: + +```ocaml +(* reactive_analysis.ml *) + +module CmtCollection = struct + type file_data = DceFileProcessing.file_data + + let collection : file_data Reactive_file_collection.t option ref = ref None + + let init ~config ~cmt_paths = + let coll = Reactive_file_collection.create + ~process:(fun (cmt_infos : Cmt_format.cmt_infos) -> + (* Extract file context from cmt_infos *) + let source_path = + match cmt_infos.cmt_annots |> FindSourceFile.cmt with + | Some path -> path + | None -> failwith "No source file" + in + let module_name = Paths.getModuleName source_path in + let is_interface = match cmt_infos.cmt_annots with + | Cmt_format.Interface _ -> true + | _ -> false + in + let file : DceFileProcessing.file_context = { + source_path; module_name; is_interface + } in + let cmtFilePath = "" (* not used in process_cmt_file body *) in + DceFileProcessing.process_cmt_file ~config ~file ~cmtFilePath cmt_infos + ) + in + (* Initial load *) + List.iter (Reactive_file_collection.add coll) cmt_paths; + collection := Some coll; + coll + + let apply_events events = + match !collection with + | Some coll -> Reactive_file_collection.apply coll events + | None -> failwith "Collection not initialized" + + let get_all_file_data () = + match !collection with + | Some coll -> Reactive_file_collection.values coll + | None -> [] +end + +(* Modified Reanalyze.runAnalysis *) +let runAnalysisIncremental ~config ~events = + (* Apply only the changed files *) + CmtCollection.apply_events events; + + (* Get all file_data (instant - values already computed) *) + let file_data_list = CmtCollection.get_all_file_data () in + + (* Rest of analysis is same as before *) + let annotations, decls, cross_file, refs, file_deps = + merge_all_builders file_data_list + in + solve ~annotations ~decls ~refs ~file_deps ~config +``` + +## Testing Strategy + +1. **Correctness**: Verify reactive analysis produces same results as batch +2. **Performance**: Benchmark incremental updates vs full analysis +3. **Edge cases**: + - File deletion during analysis + - Rapid successive changes + - Build errors (incomplete CMT files) + +## Open Questions + +1. **Build system integration**: How to get file events from rewatch/ninja? +2. **CMT staleness**: What if build system is still writing CMT files? +3. **Multi-project**: How to handle monorepos with multiple rescript.json? +4. **Memory limits**: When to evict file_data from cache? + +## Integration Points + +### 1. Shared.tryReadCmt → Marshal_cache + +Current code in `analysis/src/Shared.ml`: +```ocaml +let tryReadCmt cmt = + if not (Files.exists cmt) then ( + Log.log ("Cmt file does not exist " ^ cmt); + None) + else + match Cmt_format.read_cmt cmt with + | exception ... -> None + | x -> Some x +``` + +With Marshal_cache: +```ocaml +let tryReadCmt cmt = + if not (Files.exists cmt) then ( + Log.log ("Cmt file does not exist " ^ cmt); + None) + else + try + Some (Marshal_cache.with_unmarshalled_file cmt Fun.id) + with Marshal_cache.Cache_error (_, msg) -> + Log.log ("Invalid cmt format " ^ cmt ^ ": " ^ msg); + None +``` + +### 2. Reanalyze.loadCmtFile → Reactive_file_collection + +Current code in `analysis/reanalyze/src/Reanalyze.ml`: +```ocaml +let loadCmtFile ~config cmtFilePath : cmt_file_result option = + let cmt_infos = Cmt_format.read_cmt cmtFilePath in + ... +``` + +With reactive collection: +```ocaml +(* Global reactive collection *) +let cmt_collection : cmt_file_result Reactive_file_collection.t option ref = ref None + +let init_collection ~config = + cmt_collection := Some (Reactive_file_collection.create + ~process:(fun (cmt_infos : Cmt_format.cmt_infos) -> + process_cmt_infos ~config cmt_infos + )) + +let loadCmtFile_reactive ~config cmtFilePath = + match !cmt_collection with + | Some coll -> Reactive_file_collection.get coll cmtFilePath + | None -> loadCmtFile ~config cmtFilePath (* fallback *) +``` + +### 3. File Watcher Integration + +The analysis server already has `DceCommand.ml`. We can extend it to a service: + +```ocaml +(* DceService.ml *) + +type t = { + config: Reanalyze.DceConfig.t; + collection: cmt_file_result Reactive_file_collection.t; + mutable last_result: Reanalyze.AnalysisResult.t option; +} + +let create ~project_root = + let config = Reanalyze.DceConfig.current () in + let cmt_paths = Reanalyze.collectCmtFilePaths ~cmtRoot:None in + let collection = Reactive_file_collection.create + ~process:(process_cmt_for_config ~config) + in + List.iter (Reactive_file_collection.add collection) cmt_paths; + { config; collection; last_result = None } + +let on_file_change t events = + Reactive_file_collection.apply t.collection events; + (* Invalidate cached result *) + t.last_result <- None + +let analyze t = + match t.last_result with + | Some result -> result (* Cached, no files changed *) + | None -> + let file_data_list = Reactive_file_collection.values t.collection in + let result = run_analysis_on_file_data ~config:t.config file_data_list in + t.last_result <- Some result; + result +``` + +### 4. Build System Integration (rewatch) + +Rewatch already watches for file changes. We can extend it to notify the analysis service: + +In `rewatch/src/watcher.rs`: +```rust +// After successful compilation of a module +if let Some(analysis_socket) = &state.analysis_socket { + analysis_socket.send(AnalysisEvent::Modified(cmt_path)); +} +``` + +Or via a Unix domain socket/named pipe that the analysis service listens on. + +## Dependency Setup + +Add to `analysis/dune`: +```dune +(library + (name analysis) + (libraries + ... + skip-lite.marshal_cache + skip-lite.reactive_file_collection)) +``` + +Add to `analysis.opam`: +```opam +depends: [ + ... + "skip-lite" {>= "0.1"} +] +``` + +## Conclusion + +Integrating skip-lite's reactive collections with reanalyze offers a path to **39-390x speedup** for incremental analysis. The key insight is that CMT file loading (77% of current time) can be eliminated for unchanged files, and the processed file_data can be cached. + +The implementation requires: +1. Adding skip-lite as a dependency +2. Wrapping CMT loading with Marshal_cache (immediate benefit: mmap caching) +3. Creating reactive collection for file_data (benefit: only process changed files) +4. Creating a service mode that watches for file changes (benefit: persistent state) + +The merge and solve phases (23% of time) remain unchanged initially, but could be made incremental in the future for even greater speedups. + +## Next Steps + +1. **Phase 0**: Add skip-lite as optional dependency (behind a feature flag) +2. **Phase 1**: Replace `Cmt_format.read_cmt` with `Marshal_cache` wrapper +3. **Phase 2**: Benchmark improvement from mmap caching alone +4. **Phase 3**: Implement `Reactive_file_collection` for file_data +5. **Phase 4**: Create analysis service with file watching +6. **Phase 5**: Integrate with rewatch for automatic updates + diff --git a/tests/analysis_tests/tests-reanalyze/deadcode-benchmark/Makefile b/tests/analysis_tests/tests-reanalyze/deadcode-benchmark/Makefile index 27b4767f0a..a7f30e2282 100644 --- a/tests/analysis_tests/tests-reanalyze/deadcode-benchmark/Makefile +++ b/tests/analysis_tests/tests-reanalyze/deadcode-benchmark/Makefile @@ -35,6 +35,33 @@ time: @echo "Parallel (auto-detect cores):" @dune exec rescript-editor-analysis -- reanalyze -config -ci -timing -parallel -1 2>&1 | grep -E "Analysis reported|=== Timing|CMT processing|File loading|Result collection|Analysis:|Merging|Solving|Reporting:|Total:" +# Benchmark with CMT cache +time-cache: generate build + @echo "=== Without cache ===" + @echo "Sequential:" + @dune exec rescript-editor-analysis -- reanalyze -config -ci -timing 2>&1 | grep -E "=== Timing|CMT processing|File loading|Total:" + @echo "" + @echo "=== With CMT cache (first run - cold) ===" + @echo "Sequential:" + @dune exec rescript-editor-analysis -- reanalyze -config -ci -timing -cmt-cache 2>&1 | grep -E "=== Timing|CMT processing|File loading|Total:" + @echo "" + @echo "=== With CMT cache (second run - warm) ===" + @echo "Sequential:" + @dune exec rescript-editor-analysis -- reanalyze -config -ci -timing -cmt-cache 2>&1 | grep -E "=== Timing|CMT processing|File loading|Total:" + @echo "" + @echo "=== With CMT cache + parallel (warm) ===" + @dune exec rescript-editor-analysis -- reanalyze -config -ci -timing -cmt-cache -parallel -1 2>&1 | grep -E "=== Timing|CMT processing|File loading|Total:" + +# Benchmark reactive mode (simulates repeated analysis) +time-reactive: generate build + @echo "=== Reactive mode benchmark ===" + @echo "" + @echo "Standard (baseline):" + @dune exec rescript-editor-analysis -- reanalyze -config -ci -timing 2>&1 | grep -E "=== Timing|CMT processing|File loading|Total:" + @echo "" + @echo "Reactive mode (3 runs - first is cold, subsequent are warm):" + @dune exec rescript-editor-analysis -- reanalyze -config -ci -timing -reactive -runs 3 2>&1 | grep -E "=== Run|=== Timing|CMT processing|File loading|Total:" + .DEFAULT_GOAL := benchmark -.PHONY: generate build clean benchmark time +.PHONY: generate build clean benchmark time time-cache time-reactive From 7f482bf61efc67450d0b95345c354bef7687fe18 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Mon, 15 Dec 2025 13:37:37 +0100 Subject: [PATCH 02/45] Enable create-sourcedirs by default in rewatch - Change --create-sourcedirs to default to true (always create .sourcedirs.json) - Hide the flag from help since it's now always enabled - Add deprecation warning when flag is explicitly used - Fix package name mismatches in test projects: - deadcode rescript.json: sample-typescript-app -> @tests/reanalyze-deadcode - rescript-react package.json: @tests/rescript-react -> @rescript/react --- analysis/reanalyze/src/CmtCache.ml | 8 +- analysis/reanalyze/src/CmtCache.mli | 6 +- analysis/reanalyze/src/ReactiveAnalysis.ml | 95 ++++++++++--------- analysis/reanalyze/src/Reanalyze.ml | 17 ++-- analysis/vendor/skip-lite/dune | 2 + analysis/vendor/skip-lite/marshal_cache/dune | 3 +- rewatch/src/cli.rs | 15 ++- rewatch/src/main.rs | 16 +++- .../tests-reanalyze/deadcode/package.json | 4 +- .../tests-reanalyze/deadcode/rescript.json | 2 +- .../dependencies/rescript-react/package.json | 2 +- 11 files changed, 90 insertions(+), 80 deletions(-) diff --git a/analysis/reanalyze/src/CmtCache.ml b/analysis/reanalyze/src/CmtCache.ml index 53425cb369..54cfecc71a 100644 --- a/analysis/reanalyze/src/CmtCache.ml +++ b/analysis/reanalyze/src/CmtCache.ml @@ -29,14 +29,10 @@ let clear () = Marshal_cache.clear () The next read will re-load the file from disk. *) let invalidate path = Marshal_cache.invalidate path +type stats = {entry_count: int; mapped_bytes: int} (** Cache statistics *) -type stats = { - entry_count: int; - mapped_bytes: int; -} (** Get cache statistics *) let stats () : stats = let s = Marshal_cache.stats () in - { entry_count = s.entry_count; mapped_bytes = s.mapped_bytes } - + {entry_count = s.entry_count; mapped_bytes = s.mapped_bytes} diff --git a/analysis/reanalyze/src/CmtCache.mli b/analysis/reanalyze/src/CmtCache.mli index 74d6a73c85..ef15970617 100644 --- a/analysis/reanalyze/src/CmtCache.mli +++ b/analysis/reanalyze/src/CmtCache.mli @@ -17,12 +17,8 @@ val clear : unit -> unit val invalidate : string -> unit (** Invalidate a specific path in the cache. *) -type stats = { - entry_count: int; - mapped_bytes: int; -} +type stats = {entry_count: int; mapped_bytes: int} (** Cache statistics *) val stats : unit -> stats (** Get cache statistics *) - diff --git a/analysis/reanalyze/src/ReactiveAnalysis.ml b/analysis/reanalyze/src/ReactiveAnalysis.ml index a6b6a6cf46..5aac5c90c7 100644 --- a/analysis/reanalyze/src/ReactiveAnalysis.ml +++ b/analysis/reanalyze/src/ReactiveAnalysis.ml @@ -6,25 +6,25 @@ [@@@alert "-unsafe"] -(** Result of processing a single CMT file *) type cmt_file_result = { dce_data: DceFileProcessing.file_data option; exception_data: Exception.file_result option; } +(** Result of processing a single CMT file *) -(** Result of processing all CMT files *) type all_files_result = { dce_data_list: DceFileProcessing.file_data list; exception_results: Exception.file_result list; } +(** Result of processing all CMT files *) -(** Cached file_data for a single CMT file. - We cache the processed result, not just the raw CMT data. *) type cached_file = { path: string; file_data: DceFileProcessing.file_data option; exception_data: Exception.file_result option; } +(** Cached file_data for a single CMT file. + We cache the processed result, not just the raw CMT data. *) (** The file cache - maps CMT paths to processed results *) let file_cache : (string, cached_file) Hashtbl.t = Hashtbl.create 1024 @@ -81,60 +81,62 @@ let process_cmt_infos ~config ~cmtFilePath cmt_infos : cmt_file_result option = Returns the cached result if the file hasn't changed since last access. *) let process_cmt_cached ~config cmtFilePath : cmt_file_result option = match CmtCache.read_cmt_if_changed cmtFilePath with - | None -> + | None -> ( (* File unchanged - return cached result *) - (match Hashtbl.find_opt file_cache cmtFilePath with - | Some cached -> - Some { dce_data = cached.file_data; exception_data = cached.exception_data } - | None -> - (* First time seeing this file - shouldn't happen, but handle gracefully *) - None) + match Hashtbl.find_opt file_cache cmtFilePath with + | Some cached -> + Some {dce_data = cached.file_data; exception_data = cached.exception_data} + | None -> + (* First time seeing this file - shouldn't happen, but handle gracefully *) + None) | Some cmt_infos -> (* File changed or new - process it *) let result = process_cmt_infos ~config ~cmtFilePath cmt_infos in (* Cache the result *) (match result with - | Some r -> - Hashtbl.replace file_cache cmtFilePath { - path = cmtFilePath; - file_data = r.dce_data; - exception_data = r.exception_data; - } - | None -> ()); + | Some r -> + Hashtbl.replace file_cache cmtFilePath + { + path = cmtFilePath; + file_data = r.dce_data; + exception_data = r.exception_data; + } + | None -> ()); result (** Process all files incrementally. First run processes all files. Subsequent runs only process changed files. *) let process_files_incremental ~config cmtFilePaths : all_files_result = Timing.time_phase `FileLoading (fun () -> - let dce_data_list = ref [] in - let exception_results = ref [] in - let processed = ref 0 in - let from_cache = ref 0 in - - cmtFilePaths |> List.iter (fun cmtFilePath -> - (* Check if file was in cache *before* processing *) - let was_cached = Hashtbl.mem file_cache cmtFilePath in - match process_cmt_cached ~config cmtFilePath with - | Some {dce_data; exception_data} -> - (match dce_data with - | Some data -> dce_data_list := data :: !dce_data_list - | None -> ()); - (match exception_data with - | Some data -> exception_results := data :: !exception_results - | None -> ()); - (* Track whether it was from cache *) - if was_cached then - incr from_cache - else - incr processed - | None -> () - ); - - if !Cli.timing then - Printf.eprintf "Reactive: %d files processed, %d from cache\n%!" !processed !from_cache; - - {dce_data_list = List.rev !dce_data_list; exception_results = List.rev !exception_results}) + let dce_data_list = ref [] in + let exception_results = ref [] in + let processed = ref 0 in + let from_cache = ref 0 in + + cmtFilePaths + |> List.iter (fun cmtFilePath -> + (* Check if file was in cache *before* processing *) + let was_cached = Hashtbl.mem file_cache cmtFilePath in + match process_cmt_cached ~config cmtFilePath with + | Some {dce_data; exception_data} -> + (match dce_data with + | Some data -> dce_data_list := data :: !dce_data_list + | None -> ()); + (match exception_data with + | Some data -> exception_results := data :: !exception_results + | None -> ()); + (* Track whether it was from cache *) + if was_cached then incr from_cache else incr processed + | None -> ()); + + if !Cli.timing then + Printf.eprintf "Reactive: %d files processed, %d from cache\n%!" + !processed !from_cache; + + { + dce_data_list = List.rev !dce_data_list; + exception_results = List.rev !exception_results; + }) (** Clear all cached file data *) let clear () = @@ -146,4 +148,3 @@ let stats () = let file_count = Hashtbl.length file_cache in let cmt_stats = CmtCache.stats () in (file_count, cmt_stats) - diff --git a/analysis/reanalyze/src/Reanalyze.ml b/analysis/reanalyze/src/Reanalyze.ml index 58a4883e18..c963d662ee 100644 --- a/analysis/reanalyze/src/Reanalyze.ml +++ b/analysis/reanalyze/src/Reanalyze.ml @@ -211,9 +211,14 @@ let processCmtFiles ~config ~cmtRoot : all_files_result = let cmtFilePaths = collectCmtFilePaths ~cmtRoot in (* Reactive mode: use incremental processing that skips unchanged files *) if !Cli.reactive then - let result = ReactiveAnalysis.process_files_incremental ~config cmtFilePaths in - {dce_data_list = result.dce_data_list; exception_results = result.exception_results} - else begin + let result = + ReactiveAnalysis.process_files_incremental ~config cmtFilePaths + in + { + dce_data_list = result.dce_data_list; + exception_results = result.exception_results; + } + else let numDomains = match !Cli.parallel with | n when n > 0 -> n @@ -228,7 +233,6 @@ let processCmtFiles ~config ~cmtRoot : all_files_result = (List.length cmtFilePaths); processFilesParallel ~config ~numDomains cmtFilePaths) else processFilesSequential ~config cmtFilePaths - end (* Shuffle a list using Fisher-Yates algorithm *) let shuffle_list lst = @@ -362,11 +366,10 @@ let runAnalysisAndReport ~cmtRoot = if numRuns > 1 && !Cli.timing then Printf.eprintf "\n=== Run %d/%d ===\n%!" run numRuns; runAnalysis ~dce_config ~cmtRoot; - if run = numRuns then begin + if run = numRuns then ( (* Only report on last run *) Log_.Stats.report ~config:dce_config; - Log_.Stats.clear () - end; + Log_.Stats.clear ()); Timing.report () done; if !Cli.json then EmitJson.finish () diff --git a/analysis/vendor/skip-lite/dune b/analysis/vendor/skip-lite/dune index 4830047662..9611c60add 100644 --- a/analysis/vendor/skip-lite/dune +++ b/analysis/vendor/skip-lite/dune @@ -1,7 +1,9 @@ ; skip-lite vendor directory + (dirs marshal_cache reactive_file_collection) ; Test executable for CMT file support + (executable (name test_cmt) (modules test_cmt) diff --git a/analysis/vendor/skip-lite/marshal_cache/dune b/analysis/vendor/skip-lite/marshal_cache/dune index 714dbcfc98..0a9e05f37a 100644 --- a/analysis/vendor/skip-lite/marshal_cache/dune +++ b/analysis/vendor/skip-lite/marshal_cache/dune @@ -3,5 +3,6 @@ (foreign_stubs (language cxx) (names marshal_cache_stubs) - (flags (:standard -std=c++17))) + (flags + (:standard -std=c++17))) (c_library_flags (-lstdc++))) diff --git a/rewatch/src/cli.rs b/rewatch/src/cli.rs index 3b4604ce54..e0a0132773 100644 --- a/rewatch/src/cli.rs +++ b/rewatch/src/cli.rs @@ -197,9 +197,9 @@ pub struct AfterBuildArg { #[derive(Args, Debug, Clone, Copy)] pub struct CreateSourceDirsArg { - /// Create a source_dirs.json file at the root of the monorepo, needed for Reanalyze. - #[arg(short, long, default_value_t = false, num_args = 0..=1)] - pub create_sourcedirs: bool, + /// Deprecated: source_dirs.json is now always created. + #[arg(short, long, num_args = 0..=1, default_missing_value = "true", hide = true)] + pub create_sourcedirs: Option, } #[derive(Args, Debug, Clone, Copy)] @@ -488,11 +488,10 @@ impl Deref for AfterBuildArg { } } -impl Deref for CreateSourceDirsArg { - type Target = bool; - - fn deref(&self) -> &Self::Target { - &self.create_sourcedirs +impl CreateSourceDirsArg { + /// Returns true if the flag was explicitly passed on the command line. + pub fn was_explicitly_set(&self) -> bool { + self.create_sourcedirs.is_some() } } diff --git a/rewatch/src/main.rs b/rewatch/src/main.rs index 46bf248fbd..4c9ece6018 100644 --- a/rewatch/src/main.rs +++ b/rewatch/src/main.rs @@ -46,12 +46,18 @@ fn main() -> Result<()> { ); } + if build_args.create_sourcedirs.was_explicitly_set() { + log::warn!( + "`--create-sourcedirs` is deprecated: source_dirs.json is now always created. Please remove this flag from your command." + ); + } + match build::build( &build_args.filter, Path::new(&build_args.folder as &str), show_progress, build_args.no_timing, - *build_args.create_sourcedirs, + true, // create_sourcedirs is now always enabled plain_output, (*build_args.warn_error).clone(), ) { @@ -76,12 +82,18 @@ fn main() -> Result<()> { ); } + if watch_args.create_sourcedirs.was_explicitly_set() { + log::warn!( + "`--create-sourcedirs` is deprecated: source_dirs.json is now always created. Please remove this flag from your command." + ); + } + match watcher::start( &watch_args.filter, show_progress, &watch_args.folder, (*watch_args.after_build).clone(), - *watch_args.create_sourcedirs, + true, // create_sourcedirs is now always enabled plain_output, (*watch_args.warn_error).clone(), ) { diff --git a/tests/analysis_tests/tests-reanalyze/deadcode/package.json b/tests/analysis_tests/tests-reanalyze/deadcode/package.json index 2c294ed392..fdcd84d9ee 100644 --- a/tests/analysis_tests/tests-reanalyze/deadcode/package.json +++ b/tests/analysis_tests/tests-reanalyze/deadcode/package.json @@ -2,8 +2,8 @@ "name": "@tests/reanalyze-deadcode", "private": true, "scripts": { - "build": "rescript-legacy build", - "clean": "rescript-legacy clean" + "build": "rescript build", + "clean": "rescript clean" }, "dependencies": { "@rescript/react": "link:../../../dependencies/rescript-react", diff --git a/tests/analysis_tests/tests-reanalyze/deadcode/rescript.json b/tests/analysis_tests/tests-reanalyze/deadcode/rescript.json index 467c603309..942f2d957f 100644 --- a/tests/analysis_tests/tests-reanalyze/deadcode/rescript.json +++ b/tests/analysis_tests/tests-reanalyze/deadcode/rescript.json @@ -5,7 +5,7 @@ "unsuppress": [], "transitive": true }, - "name": "sample-typescript-app", + "name": "@tests/reanalyze-deadcode", "jsx": { "version": 4 }, "dependencies": ["@rescript/react"], "sources": [ diff --git a/tests/dependencies/rescript-react/package.json b/tests/dependencies/rescript-react/package.json index cd0136bdd3..0d09e376d6 100644 --- a/tests/dependencies/rescript-react/package.json +++ b/tests/dependencies/rescript-react/package.json @@ -1,4 +1,4 @@ { - "name": "@tests/rescript-react", + "name": "@rescript/react", "private": true } From 9265b6499b13d9fe001a8453315853031777ca4c Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Mon, 15 Dec 2025 16:00:18 +0100 Subject: [PATCH 03/45] Refine reactive_file_collection representation Simplify the reactive_file_collection implementation by making 'v t be the concrete record type used at runtime, removing the unused phantom fields and internal wrapper type. This eliminates warning 69 about unused record fields and relies directly on a single record with its process function stored as an Obj.t-based callback. --- .../reactive_file_collection.ml | 56 +++++++------------ yarn.lock | 12 ++-- 2 files changed, 25 insertions(+), 43 deletions(-) diff --git a/analysis/vendor/skip-lite/reactive_file_collection/reactive_file_collection.ml b/analysis/vendor/skip-lite/reactive_file_collection/reactive_file_collection.ml index a7e1babf5f..9b137d7469 100644 --- a/analysis/vendor/skip-lite/reactive_file_collection/reactive_file_collection.ml +++ b/analysis/vendor/skip-lite/reactive_file_collection/reactive_file_collection.ml @@ -5,43 +5,32 @@ type event = | Removed of string | Modified of string -type 'v t = { - data : (string, 'v) Hashtbl.t; - process : 'a. 'a -> 'v; -} - -(* We need to use Obj.magic to make the polymorphic process function work - with Marshal_cache which returns 'a. This is safe because the user +(* We need to use Obj.t to make the polymorphic process function work + with Marshal_cache which returns ['a]. This is safe because the user guarantees the file contains data of the expected type. *) type 'v process_fn = Obj.t -> 'v -type 'v t_internal = { - data_internal : (string, 'v) Hashtbl.t; - process_internal : 'v process_fn; +type 'v t = { + data : (string, 'v) Hashtbl.t; + process : 'v process_fn; } let create (type a v) ~(process : a -> v) : v t = - let process_internal : v process_fn = fun obj -> process (Obj.obj obj) in - let t = { - data_internal = Hashtbl.create 256; - process_internal; - } in - (* Safe cast - same representation *) - Obj.magic t - -let to_internal (t : 'v t) : 'v t_internal = Obj.magic t + let process_fn : v process_fn = fun obj -> process (Obj.obj obj) in + { + data = Hashtbl.create 256; + process = process_fn; + } let add t path = - let t = to_internal t in let value = Marshal_cache.with_unmarshalled_file path (fun data -> - t.process_internal (Obj.repr data) + t.process (Obj.repr data) ) in - Hashtbl.replace t.data_internal path value + Hashtbl.replace t.data path value [@@alert "-unsafe"] let remove t path = - let t = to_internal t in - Hashtbl.remove t.data_internal path + Hashtbl.remove t.data path let update t path = (* Just reload - Marshal_cache handles the file reading efficiently *) @@ -53,33 +42,26 @@ let apply t events = | Removed path -> remove t path | Modified path -> update t path ) events - let get t path = - let t = to_internal t in - Hashtbl.find_opt t.data_internal path + Hashtbl.find_opt t.data path let find t path = - let t = to_internal t in - Hashtbl.find t.data_internal path + Hashtbl.find t.data path let mem t path = - let t = to_internal t in - Hashtbl.mem t.data_internal path + Hashtbl.mem t.data path let length t = - let t = to_internal t in - Hashtbl.length t.data_internal + Hashtbl.length t.data let is_empty t = length t = 0 let iter f t = - let t = to_internal t in - Hashtbl.iter f t.data_internal + Hashtbl.iter f t.data let fold f t init = - let t = to_internal t in - Hashtbl.fold f t.data_internal init + Hashtbl.fold f t.data init let to_list t = fold (fun k v acc -> (k, v) :: acc) t [] diff --git a/yarn.lock b/yarn.lock index 573bc9b2a6..3db8c9bc0e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -452,6 +452,12 @@ __metadata: languageName: node linkType: hard +"@rescript/react@workspace:tests/dependencies/rescript-react": + version: 0.0.0-use.local + resolution: "@rescript/react@workspace:tests/dependencies/rescript-react" + languageName: unknown + linkType: soft + "@rescript/runtime@workspace:packages/@rescript/runtime": version: 0.0.0-use.local resolution: "@rescript/runtime@workspace:packages/@rescript/runtime" @@ -724,12 +730,6 @@ __metadata: languageName: unknown linkType: soft -"@tests/rescript-react@workspace:tests/dependencies/rescript-react": - version: 0.0.0-use.local - resolution: "@tests/rescript-react@workspace:tests/dependencies/rescript-react" - languageName: unknown - linkType: soft - "@tests/tools@workspace:tests/tools_tests": version: 0.0.0-use.local resolution: "@tests/tools@workspace:tests/tools_tests" From 210862efbd688016dd2231b8a7de14cc1ccdc0ad Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Mon, 15 Dec 2025 17:28:27 +0100 Subject: [PATCH 04/45] Replace C++ marshal_cache with pure OCaml implementation - CmtCache: rewritten using Unix.stat for file change detection (mtime, size, inode) instead of C++ mmap cache - ReactiveFileCollection: new pure OCaml module for reactive file collections with delta-based updates - ReactiveAnalysis: refactored to use ReactiveFileCollection, collection passed as parameter (no global mutable state) - Timing: only show parallel merge timing when applicable - Deleted skip-lite vendor directory (C++ code no longer needed) This eliminates the Linux/musl C++ compilation issue while maintaining the same incremental analysis performance: - Cold run: ~1.0s - Warm run: ~0.01s (90x faster, skips unchanged files) --- analysis/reanalyze/src/CmtCache.ml | 68 +- analysis/reanalyze/src/CmtCache.mli | 13 +- analysis/reanalyze/src/ReactiveAnalysis.ml | 117 ++- .../reanalyze/src/ReactiveFileCollection.ml | 52 ++ .../src/ReactiveFileCollection.mli} | 47 +- analysis/reanalyze/src/Reanalyze.ml | 20 +- analysis/reanalyze/src/Timing.ml | 16 +- analysis/reanalyze/src/dune | 2 +- analysis/src/DceCommand.ml | 2 +- analysis/vendor/dune | 2 +- analysis/vendor/skip-lite/dune | 10 - analysis/vendor/skip-lite/marshal_cache/dune | 8 - .../skip-lite/marshal_cache/marshal_cache.ml | 71 -- .../skip-lite/marshal_cache/marshal_cache.mli | 120 --- .../marshal_cache/marshal_cache_stubs.cpp | 804 ------------------ .../skip-lite/reactive_file_collection/dune | 3 - .../reactive_file_collection.ml | 77 -- analysis/vendor/skip-lite/test_cmt.ml | 119 --- .../deadcode-benchmark/Makefile | 2 +- .../deadcode-benchmark/package.json | 4 +- .../dependencies/rescript-react/package.json | 27 +- 21 files changed, 225 insertions(+), 1359 deletions(-) create mode 100644 analysis/reanalyze/src/ReactiveFileCollection.ml rename analysis/{vendor/skip-lite/reactive_file_collection/reactive_file_collection.mli => reanalyze/src/ReactiveFileCollection.mli} (65%) delete mode 100644 analysis/vendor/skip-lite/dune delete mode 100644 analysis/vendor/skip-lite/marshal_cache/dune delete mode 100644 analysis/vendor/skip-lite/marshal_cache/marshal_cache.ml delete mode 100644 analysis/vendor/skip-lite/marshal_cache/marshal_cache.mli delete mode 100644 analysis/vendor/skip-lite/marshal_cache/marshal_cache_stubs.cpp delete mode 100644 analysis/vendor/skip-lite/reactive_file_collection/dune delete mode 100644 analysis/vendor/skip-lite/reactive_file_collection/reactive_file_collection.ml delete mode 100644 analysis/vendor/skip-lite/test_cmt.ml diff --git a/analysis/reanalyze/src/CmtCache.ml b/analysis/reanalyze/src/CmtCache.ml index 54cfecc71a..aa838ed38d 100644 --- a/analysis/reanalyze/src/CmtCache.ml +++ b/analysis/reanalyze/src/CmtCache.ml @@ -1,38 +1,70 @@ -(** CMT file cache using Marshal_cache for efficient mmap-based reading. +(** CMT file cache with automatic invalidation based on file metadata. This module provides cached reading of CMT files with automatic - invalidation when files change on disk. It's used to speed up - repeated analysis runs by avoiding re-reading unchanged files. *) + invalidation when files change on disk. Uses Unix.stat to detect + changes via mtime, size, and inode. *) -[@@@alert "-unsafe"] +type file_id = { + mtime: float; (** Modification time *) + size: int; (** File size in bytes *) + ino: int; (** Inode number *) +} +(** File identity for cache invalidation *) -(** Read a CMT file, using the mmap cache for efficiency. - The file is memory-mapped and the cache automatically detects - when the file changes on disk. *) +(** Get file identity from path *) +let get_file_id path : file_id = + let st = Unix.stat path in + {mtime = st.Unix.st_mtime; size = st.Unix.st_size; ino = st.Unix.st_ino} + +(** Check if file has changed *) +let file_changed ~old_id ~new_id = + old_id.mtime <> new_id.mtime + || old_id.size <> new_id.size || old_id.ino <> new_id.ino + +type cache_entry = {file_id: file_id; cmt_infos: Cmt_format.cmt_infos} +(** Cache entry: file identity + cached CMT data *) + +(** The cache: path -> cache_entry *) +let cache : (string, cache_entry) Hashtbl.t = Hashtbl.create 256 + +(** Read a CMT file, using the cache for efficiency. + Re-reads from disk if file has changed. *) let read_cmt path : Cmt_format.cmt_infos = - Marshal_cache.with_unmarshalled_file path Fun.id + let new_id = get_file_id path in + match Hashtbl.find_opt cache path with + | Some entry when not (file_changed ~old_id:entry.file_id ~new_id) -> + entry.cmt_infos + | _ -> + let cmt_infos = Cmt_format.read_cmt path in + Hashtbl.replace cache path {file_id = new_id; cmt_infos}; + cmt_infos (** Read a CMT file only if it changed since the last access. Returns [Some cmt_infos] if the file changed (or first access), [None] if the file is unchanged. This is the key function for incremental analysis - unchanged - files return [None] immediately without any unmarshalling. *) + files return [None] immediately without any file reading. *) let read_cmt_if_changed path : Cmt_format.cmt_infos option = - Marshal_cache.with_unmarshalled_if_changed path Fun.id + let new_id = get_file_id path in + match Hashtbl.find_opt cache path with + | Some entry when not (file_changed ~old_id:entry.file_id ~new_id) -> + None (* File unchanged *) + | _ -> + let cmt_infos = Cmt_format.read_cmt path in + Hashtbl.replace cache path {file_id = new_id; cmt_infos}; + Some cmt_infos -(** Clear the CMT cache, unmapping all memory. - Useful for testing or to free memory. *) -let clear () = Marshal_cache.clear () +(** Clear the CMT cache, freeing all cached data. *) +let clear () = Hashtbl.clear cache (** Invalidate a specific path in the cache. The next read will re-load the file from disk. *) -let invalidate path = Marshal_cache.invalidate path +let invalidate path = Hashtbl.remove cache path type stats = {entry_count: int; mapped_bytes: int} (** Cache statistics *) -(** Get cache statistics *) -let stats () : stats = - let s = Marshal_cache.stats () in - {entry_count = s.entry_count; mapped_bytes = s.mapped_bytes} +(** Get cache statistics. + Note: mapped_bytes is approximate (we don't track actual memory usage). *) +let stats () : stats = {entry_count = Hashtbl.length cache; mapped_bytes = 0} diff --git a/analysis/reanalyze/src/CmtCache.mli b/analysis/reanalyze/src/CmtCache.mli index ef15970617..ef7d1b2221 100644 --- a/analysis/reanalyze/src/CmtCache.mli +++ b/analysis/reanalyze/src/CmtCache.mli @@ -1,10 +1,12 @@ -(** CMT file cache using Marshal_cache for efficient mmap-based reading. +(** CMT file cache with automatic invalidation based on file metadata. This module provides cached reading of CMT files with automatic - invalidation when files change on disk. *) + invalidation when files change on disk. Uses Unix.stat to detect + changes via mtime, size, and inode. *) val read_cmt : string -> Cmt_format.cmt_infos -(** Read a CMT file, using the mmap cache for efficiency. *) +(** Read a CMT file, using the cache for efficiency. + Re-reads from disk if file has changed. *) val read_cmt_if_changed : string -> Cmt_format.cmt_infos option (** Read a CMT file only if it changed since the last access. @@ -12,7 +14,7 @@ val read_cmt_if_changed : string -> Cmt_format.cmt_infos option [None] if the file is unchanged. *) val clear : unit -> unit -(** Clear the CMT cache, unmapping all memory. *) +(** Clear the CMT cache, freeing all cached data. *) val invalidate : string -> unit (** Invalidate a specific path in the cache. *) @@ -21,4 +23,5 @@ type stats = {entry_count: int; mapped_bytes: int} (** Cache statistics *) val stats : unit -> stats -(** Get cache statistics *) +(** Get cache statistics. + Note: mapped_bytes is always 0 (we don't track actual memory usage). *) diff --git a/analysis/reanalyze/src/ReactiveAnalysis.ml b/analysis/reanalyze/src/ReactiveAnalysis.ml index 5aac5c90c7..da2aec5623 100644 --- a/analysis/reanalyze/src/ReactiveAnalysis.ml +++ b/analysis/reanalyze/src/ReactiveAnalysis.ml @@ -1,10 +1,8 @@ -(** Reactive analysis service using cached file processing. +(** Reactive analysis service using ReactiveFileCollection. This module provides incremental analysis that only re-processes - files that have changed, caching the processed file_data for - unchanged files. *) - -[@@@alert "-unsafe"] + files that have changed, using ReactiveFileCollection for efficient + delta-based updates. *) type cmt_file_result = { dce_data: DceFileProcessing.file_data option; @@ -18,19 +16,11 @@ type all_files_result = { } (** Result of processing all CMT files *) -type cached_file = { - path: string; - file_data: DceFileProcessing.file_data option; - exception_data: Exception.file_result option; -} -(** Cached file_data for a single CMT file. - We cache the processed result, not just the raw CMT data. *) - -(** The file cache - maps CMT paths to processed results *) -let file_cache : (string, cached_file) Hashtbl.t = Hashtbl.create 1024 +type t = cmt_file_result option ReactiveFileCollection.t +(** The reactive collection type *) (** Process cmt_infos into a file result *) -let process_cmt_infos ~config ~cmtFilePath cmt_infos : cmt_file_result option = +let process_cmt_infos ~config cmt_infos : cmt_file_result option = let excludePath sourceFile = config.DceConfig.cli.exclude_paths |> List.exists (fun prefix_ -> @@ -64,7 +54,7 @@ let process_cmt_infos ~config ~cmtFilePath cmt_infos : cmt_file_result option = Some (cmt_infos |> DceFileProcessing.process_cmt_file ~config ~file:dce_file_context - ~cmtFilePath) + ~cmtFilePath:"") else None in let exception_data = @@ -77,74 +67,63 @@ let process_cmt_infos ~config ~cmtFilePath cmt_infos : cmt_file_result option = Some {dce_data; exception_data} | _ -> None -(** Process a CMT file, using cached result if file unchanged. - Returns the cached result if the file hasn't changed since last access. *) -let process_cmt_cached ~config cmtFilePath : cmt_file_result option = - match CmtCache.read_cmt_if_changed cmtFilePath with - | None -> ( - (* File unchanged - return cached result *) - match Hashtbl.find_opt file_cache cmtFilePath with - | Some cached -> - Some {dce_data = cached.file_data; exception_data = cached.exception_data} - | None -> - (* First time seeing this file - shouldn't happen, but handle gracefully *) - None) - | Some cmt_infos -> - (* File changed or new - process it *) - let result = process_cmt_infos ~config ~cmtFilePath cmt_infos in - (* Cache the result *) - (match result with - | Some r -> - Hashtbl.replace file_cache cmtFilePath - { - path = cmtFilePath; - file_data = r.dce_data; - exception_data = r.exception_data; - } - | None -> ()); - result +(** Create a new reactive collection *) +let create ~config : t = + ReactiveFileCollection.create ~process:(process_cmt_infos ~config) -(** Process all files incrementally. - First run processes all files. Subsequent runs only process changed files. *) -let process_files_incremental ~config cmtFilePaths : all_files_result = +(** Process all files incrementally using ReactiveFileCollection. + First run processes all files. Subsequent runs only process changed files + (detected via CmtCache's file change tracking). *) +let process_files ~(collection : t) ~config cmtFilePaths : all_files_result = Timing.time_phase `FileLoading (fun () -> - let dce_data_list = ref [] in - let exception_results = ref [] in let processed = ref 0 in let from_cache = ref 0 in + (* Add/update all files in the collection *) cmtFilePaths |> List.iter (fun cmtFilePath -> - (* Check if file was in cache *before* processing *) - let was_cached = Hashtbl.mem file_cache cmtFilePath in - match process_cmt_cached ~config cmtFilePath with - | Some {dce_data; exception_data} -> - (match dce_data with - | Some data -> dce_data_list := data :: !dce_data_list - | None -> ()); - (match exception_data with - | Some data -> exception_results := data :: !exception_results - | None -> ()); - (* Track whether it was from cache *) - if was_cached then incr from_cache else incr processed - | None -> ()); + let was_in_collection = + ReactiveFileCollection.mem collection cmtFilePath + in + (* Check if file changed using CmtCache *) + match CmtCache.read_cmt_if_changed cmtFilePath with + | None -> + (* File unchanged - already in collection *) + if was_in_collection then incr from_cache + | Some cmt_infos -> + (* File changed or new - process and update *) + let result = process_cmt_infos ~config cmt_infos in + ReactiveFileCollection.set collection cmtFilePath result; + incr processed); if !Cli.timing then Printf.eprintf "Reactive: %d files processed, %d from cache\n%!" !processed !from_cache; + (* Collect results from the collection *) + let dce_data_list = ref [] in + let exception_results = ref [] in + + ReactiveFileCollection.iter + (fun _path result_opt -> + match result_opt with + | Some {dce_data; exception_data} -> ( + (match dce_data with + | Some data -> dce_data_list := data :: !dce_data_list + | None -> ()); + match exception_data with + | Some data -> exception_results := data :: !exception_results + | None -> ()) + | None -> ()) + collection; + { dce_data_list = List.rev !dce_data_list; exception_results = List.rev !exception_results; }) -(** Clear all cached file data *) -let clear () = - Hashtbl.clear file_cache; - CmtCache.clear () - -(** Get cache statistics *) -let stats () = - let file_count = Hashtbl.length file_cache in +(** Get collection statistics *) +let stats (collection : t) = + let file_count = ReactiveFileCollection.length collection in let cmt_stats = CmtCache.stats () in (file_count, cmt_stats) diff --git a/analysis/reanalyze/src/ReactiveFileCollection.ml b/analysis/reanalyze/src/ReactiveFileCollection.ml new file mode 100644 index 0000000000..61c6b54520 --- /dev/null +++ b/analysis/reanalyze/src/ReactiveFileCollection.ml @@ -0,0 +1,52 @@ +(** Reactive File Collection - Implementation + + Uses CmtCache for efficient file change detection via Unix.stat. *) + +type event = Added of string | Removed of string | Modified of string + +type 'v t = {data: (string, 'v) Hashtbl.t; process: Cmt_format.cmt_infos -> 'v} + +let create ~process = {data = Hashtbl.create 256; process} + +let add t path = + let cmt_infos = CmtCache.read_cmt path in + let value = t.process cmt_infos in + Hashtbl.replace t.data path value + +let remove t path = + Hashtbl.remove t.data path; + CmtCache.invalidate path + +let update t path = + (* Re-read the file and update the cache *) + add t path + +let set t path value = Hashtbl.replace t.data path value + +let apply t events = + List.iter + (function + | Added path -> add t path + | Removed path -> remove t path + | Modified path -> update t path) + events + +let get t path = Hashtbl.find_opt t.data path + +let find t path = Hashtbl.find t.data path + +let mem t path = Hashtbl.mem t.data path + +let length t = Hashtbl.length t.data + +let is_empty t = length t = 0 + +let iter f t = Hashtbl.iter f t.data + +let fold f t init = Hashtbl.fold f t.data init + +let to_list t = fold (fun k v acc -> (k, v) :: acc) t [] + +let paths t = fold (fun k _ acc -> k :: acc) t [] + +let values t = fold (fun _ v acc -> v :: acc) t [] diff --git a/analysis/vendor/skip-lite/reactive_file_collection/reactive_file_collection.mli b/analysis/reanalyze/src/ReactiveFileCollection.mli similarity index 65% rename from analysis/vendor/skip-lite/reactive_file_collection/reactive_file_collection.mli rename to analysis/reanalyze/src/ReactiveFileCollection.mli index 56ae3e4c2e..f5f01c4283 100644 --- a/analysis/vendor/skip-lite/reactive_file_collection/reactive_file_collection.mli +++ b/analysis/reanalyze/src/ReactiveFileCollection.mli @@ -7,22 +7,22 @@ {[ (* Create collection with processing function *) - let coll = Reactive_file_collection.create + let coll = ReactiveFileCollection.create ~process:(fun (data : Cmt_format.cmt_infos) -> extract_types data ) (* Initial load *) - List.iter (Reactive_file_collection.add coll) (glob "*.cmt") + List.iter (ReactiveFileCollection.add coll) (glob "*.cmt") (* On file watcher events *) match event with - | Created path -> Reactive_file_collection.add coll path - | Deleted path -> Reactive_file_collection.remove coll path - | Modified path -> Reactive_file_collection.update coll path + | Created path -> ReactiveFileCollection.add coll path + | Deleted path -> ReactiveFileCollection.remove coll path + | Modified path -> ReactiveFileCollection.update coll path (* Access the collection *) - Reactive_file_collection.iter (fun path value -> ...) coll + ReactiveFileCollection.iter (fun path value -> ...) coll ]} {2 Thread Safety} @@ -30,34 +30,27 @@ Not thread-safe. Use external synchronization if accessed from multiple threads/domains. *) -(** The type of a reactive file collection with values of type ['v]. *) type 'v t +(** The type of a reactive file collection with values of type ['v]. *) (** Events for batch updates. *) type event = - | Added of string (** File was created *) - | Removed of string (** File was deleted *) + | Added of string (** File was created *) + | Removed of string (** File was deleted *) | Modified of string (** File was modified *) (** {1 Creation} *) -val create : process:('a -> 'v) -> 'v t +val create : process:(Cmt_format.cmt_infos -> 'v) -> 'v t (** [create ~process] creates an empty collection. - [process] is called to transform unmarshalled file contents into values. - - {b Type safety warning}: The caller must ensure files contain data of - type ['a]. This has the same safety properties as [Marshal.from_*]. - - @alert unsafe Caller must ensure files contain data of the expected type *) + [process] is called to transform CMT file contents into values. *) (** {1 Delta Operations} *) val add : 'v t -> string -> unit (** [add t path] adds a file to the collection. - Loads the file, unmarshals, and processes immediately. - - @raise Marshal_cache.Cache_error if file cannot be read or unmarshalled *) + Loads the file and processes immediately. *) val remove : 'v t -> string -> unit (** [remove t path] removes a file from the collection. @@ -65,15 +58,15 @@ val remove : 'v t -> string -> unit val update : 'v t -> string -> unit (** [update t path] reloads a modified file. - Equivalent to remove + add, but more efficient. - - @raise Marshal_cache.Cache_error if file cannot be read or unmarshalled *) + Equivalent to remove + add, but more efficient. *) + +val set : 'v t -> string -> 'v -> unit +(** [set t path value] sets the value for [path] directly. + Used when you have already processed the file externally. *) val apply : 'v t -> event list -> unit (** [apply t events] applies multiple events. - More efficient than individual operations for batches. - - @raise Marshal_cache.Cache_error if any added/modified file fails *) + More efficient than individual operations for batches. *) (** {1 Access} *) @@ -109,7 +102,3 @@ val paths : 'v t -> string list val values : 'v t -> 'v list (** [values t] returns all values in the collection. *) - - - - diff --git a/analysis/reanalyze/src/Reanalyze.ml b/analysis/reanalyze/src/Reanalyze.ml index c963d662ee..e1f9f2871a 100644 --- a/analysis/reanalyze/src/Reanalyze.ml +++ b/analysis/reanalyze/src/Reanalyze.ml @@ -207,18 +207,19 @@ let processFilesParallel ~config ~numDomains (cmtFilePaths : string list) : (** Process all cmt files and return results for DCE and Exception analysis. Conceptually: map process_cmt_file over all files. *) -let processCmtFiles ~config ~cmtRoot : all_files_result = +let processCmtFiles ~config ~cmtRoot ~reactive_collection : all_files_result = let cmtFilePaths = collectCmtFilePaths ~cmtRoot in (* Reactive mode: use incremental processing that skips unchanged files *) - if !Cli.reactive then + match reactive_collection with + | Some collection -> let result = - ReactiveAnalysis.process_files_incremental ~config cmtFilePaths + ReactiveAnalysis.process_files ~collection ~config cmtFilePaths in { dce_data_list = result.dce_data_list; exception_results = result.exception_results; } - else + | None -> let numDomains = match !Cli.parallel with | n when n > 0 -> n @@ -246,10 +247,10 @@ let shuffle_list lst = done; Array.to_list arr -let runAnalysis ~dce_config ~cmtRoot = +let runAnalysis ~dce_config ~cmtRoot ~reactive_collection = (* Map: process each file -> list of file_data *) let {dce_data_list; exception_results} = - processCmtFiles ~config:dce_config ~cmtRoot + processCmtFiles ~config:dce_config ~cmtRoot ~reactive_collection in (* Optionally shuffle for order-independence testing *) let dce_data_list = @@ -361,11 +362,16 @@ let runAnalysisAndReport ~cmtRoot = if !Cli.json then EmitJson.start (); let dce_config = DceConfig.current () in let numRuns = max 1 !Cli.runs in + (* Create reactive collection once, reuse across runs *) + let reactive_collection = + if !Cli.reactive then Some (ReactiveAnalysis.create ~config:dce_config) + else None + in for run = 1 to numRuns do Timing.reset (); if numRuns > 1 && !Cli.timing then Printf.eprintf "\n=== Run %d/%d ===\n%!" run numRuns; - runAnalysis ~dce_config ~cmtRoot; + runAnalysis ~dce_config ~cmtRoot ~reactive_collection; if run = numRuns then ( (* Only report on last run *) Log_.Stats.report ~config:dce_config; diff --git a/analysis/reanalyze/src/Timing.ml b/analysis/reanalyze/src/Timing.ml index b9f739df6a..ef875668db 100644 --- a/analysis/reanalyze/src/Timing.ml +++ b/analysis/reanalyze/src/Timing.ml @@ -54,24 +54,16 @@ let time_phase phase_name f = let report () = if !enabled then ( - (* NOTE about semantics: - - [file_loading] is treated as the WALL-CLOCK time for the overall - "CMT processing" phase (including per-file processing and any - synchronization). - - [result_collection] is an AGGREGATE metric across domains: time spent - in (and waiting on) the mutex-protected result merge/collection - section, summed across all worker domains. This may exceed wall-clock - time in parallel runs. - We do NOT add them together, otherwise we'd double-count. *) let cmt_total = times.file_loading in let analysis_total = times.merging +. times.solving in let total = cmt_total +. analysis_total +. times.reporting in Printf.eprintf "\n=== Timing ===\n"; Printf.eprintf " CMT processing: %.3fs (%.1f%%)\n" cmt_total (100.0 *. cmt_total /. total); - Printf.eprintf " - Wall clock: %.3fs\n" times.file_loading; - Printf.eprintf " - Result collection: %.3fms (aggregate)\n" - (1000.0 *. times.result_collection); + (* Only show parallel-specific timing when used *) + if times.result_collection > 0.0 then + Printf.eprintf " - Parallel merge: %.3fms (aggregate across domains)\n" + (1000.0 *. times.result_collection); Printf.eprintf " Analysis: %.3fs (%.1f%%)\n" analysis_total (100.0 *. analysis_total /. total); Printf.eprintf " - Merging: %.3fms\n" (1000.0 *. times.merging); diff --git a/analysis/reanalyze/src/dune b/analysis/reanalyze/src/dune index a0045f8230..e8b736446f 100644 --- a/analysis/reanalyze/src/dune +++ b/analysis/reanalyze/src/dune @@ -2,4 +2,4 @@ (name reanalyze) (flags (-w "+6+26+27+32+33+39")) - (libraries jsonlib ext ml str unix marshal_cache)) + (libraries jsonlib ext ml str unix)) diff --git a/analysis/src/DceCommand.ml b/analysis/src/DceCommand.ml index 1578a66bb4..6ff03172ae 100644 --- a/analysis/src/DceCommand.ml +++ b/analysis/src/DceCommand.ml @@ -1,6 +1,6 @@ let command () = Reanalyze.RunConfig.dce (); let dce_config = Reanalyze.DceConfig.current () in - Reanalyze.runAnalysis ~dce_config ~cmtRoot:None; + Reanalyze.runAnalysis ~dce_config ~cmtRoot:None ~reactive_collection:None; let issues = !Reanalyze.Log_.Stats.issues in Printf.printf "issues:%d\n" (List.length issues) diff --git a/analysis/vendor/dune b/analysis/vendor/dune index 7ccd94c6b7..07b8286153 100644 --- a/analysis/vendor/dune +++ b/analysis/vendor/dune @@ -1 +1 @@ -(dirs ext ml res_syntax json flow_parser skip-lite) +(dirs ext ml res_syntax json flow_parser) diff --git a/analysis/vendor/skip-lite/dune b/analysis/vendor/skip-lite/dune deleted file mode 100644 index 9611c60add..0000000000 --- a/analysis/vendor/skip-lite/dune +++ /dev/null @@ -1,10 +0,0 @@ -; skip-lite vendor directory - -(dirs marshal_cache reactive_file_collection) - -; Test executable for CMT file support - -(executable - (name test_cmt) - (modules test_cmt) - (libraries marshal_cache ml)) diff --git a/analysis/vendor/skip-lite/marshal_cache/dune b/analysis/vendor/skip-lite/marshal_cache/dune deleted file mode 100644 index 0a9e05f37a..0000000000 --- a/analysis/vendor/skip-lite/marshal_cache/dune +++ /dev/null @@ -1,8 +0,0 @@ -(library - (name marshal_cache) - (foreign_stubs - (language cxx) - (names marshal_cache_stubs) - (flags - (:standard -std=c++17))) - (c_library_flags (-lstdc++))) diff --git a/analysis/vendor/skip-lite/marshal_cache/marshal_cache.ml b/analysis/vendor/skip-lite/marshal_cache/marshal_cache.ml deleted file mode 100644 index 66da5e9c9f..0000000000 --- a/analysis/vendor/skip-lite/marshal_cache/marshal_cache.ml +++ /dev/null @@ -1,71 +0,0 @@ -(* Marshal Cache - OCaml implementation *) - -exception Cache_error of string * string - -type stats = { - entry_count : int; - mapped_bytes : int; -} - -(* Register the exception with the C runtime for proper propagation *) -let () = Callback.register_exception - "Marshal_cache.Cache_error" - (Cache_error ("", "")) - -(* External C stubs *) -external with_unmarshalled_file_stub : string -> ('a -> 'r) -> 'r - = "mfc_with_unmarshalled_file" - -external with_unmarshalled_if_changed_stub : string -> ('a -> 'r) -> 'r option - = "mfc_with_unmarshalled_if_changed" - -external clear_stub : unit -> unit = "mfc_clear" -external invalidate_stub : string -> unit = "mfc_invalidate" -external set_max_entries_stub : int -> unit = "mfc_set_max_entries" -external set_max_bytes_stub : int -> unit = "mfc_set_max_bytes" -external stats_stub : unit -> int * int = "mfc_stats" - -(* Public API *) - -let convert_failure path msg = - (* C code raises Failure with "path: message" format *) - (* Only convert if message starts with the path (i.e., from our C code) *) - let prefix = path ^ ": " in - let prefix_len = String.length prefix in - if String.length msg >= prefix_len && String.sub msg 0 prefix_len = prefix then - let error_msg = String.sub msg prefix_len (String.length msg - prefix_len) in - raise (Cache_error (path, error_msg)) - else - (* Re-raise user callback exceptions as-is *) - raise (Failure msg) - -let with_unmarshalled_file path f = - try - with_unmarshalled_file_stub path f - with - | Failure msg -> convert_failure path msg - [@@alert "-unsafe"] - -let with_unmarshalled_if_changed path f = - try - with_unmarshalled_if_changed_stub path f - with - | Failure msg -> convert_failure path msg - [@@alert "-unsafe"] - -let clear () = clear_stub () - -let invalidate path = invalidate_stub path - -let set_max_entries n = - if n < 0 then invalid_arg "Marshal_cache.set_max_entries: negative value"; - set_max_entries_stub n - -let set_max_bytes n = - if n < 0 then invalid_arg "Marshal_cache.set_max_bytes: negative value"; - set_max_bytes_stub n - -let stats () = - let (entry_count, mapped_bytes) = stats_stub () in - { entry_count; mapped_bytes } - diff --git a/analysis/vendor/skip-lite/marshal_cache/marshal_cache.mli b/analysis/vendor/skip-lite/marshal_cache/marshal_cache.mli deleted file mode 100644 index 091c3f69c6..0000000000 --- a/analysis/vendor/skip-lite/marshal_cache/marshal_cache.mli +++ /dev/null @@ -1,120 +0,0 @@ -(** Marshal Cache - - A high-performance cache for marshalled files that keeps file contents - memory-mapped (off-heap) and provides efficient repeated access with - automatic invalidation when files change on disk. - - {2 Memory Model} - - There is no fixed-size memory pool. Each cached file gets its own [mmap] - of exactly its file size: - - - {b mmap'd bytes}: Live in virtual address space (off-heap), managed by - OS + cache LRU eviction - - {b Unmarshalled value}: Lives in OCaml heap, managed by GC, exists only - during callback - - Physical RAM pages are allocated on demand (first access). Under memory - pressure, the OS can evict pages back to disk since they're file-backed. - - {2 Usage Example} - - {[ - Marshal_cache.with_unmarshalled_file "/path/to/data.marshal" - (fun (data : my_data_type) -> - (* Process data here - mmap stays valid for duration of callback *) - process data - ) - ]} - - {2 Platform Support} - - - macOS 10.13+: Fully supported - - Linux (glibc): Fully supported - - FreeBSD/OpenBSD: Should work (uses same mtime API as macOS) - - Windows: Not supported (no mmap) *) - -(** Exception raised for cache-related errors. - Contains the file path and an error message. *) -exception Cache_error of string * string - -(** Cache statistics. *) -type stats = { - entry_count : int; (** Number of files currently cached *) - mapped_bytes : int; (** Total bytes of memory-mapped data *) -} - -(** [with_unmarshalled_file path f] calls [f] with the unmarshalled value - from [path]. Guarantees the underlying mmap stays valid for the duration - of [f]. - - The cache automatically detects file changes via: - - Modification time (nanosecond precision where available) - - File size - - Inode number (detects atomic file replacement) - - {b Type safety warning}: This function is inherently unsafe. The caller - must ensure the type ['a] matches the actual marshalled data. Using the - wrong type results in undefined behavior (crashes, memory corruption). - This is equivalent to [Marshal.from_*] in terms of type safety. - - @raise Cache_error if the file cannot be read, mapped, or unmarshalled. - @raise exn if [f] raises; the cache state remains consistent. - - {b Thread safety}: Safe to call from multiple threads/domains. The cache - uses internal locking. The lock is released during the callback [f]. *) -val with_unmarshalled_file : string -> ('a -> 'r) -> 'r - [@@alert unsafe "Caller must ensure the file contains data of the expected type"] - -(** [with_unmarshalled_if_changed path f] is like {!with_unmarshalled_file} but - only unmarshals if the file changed since the last access. - - Returns [Some (f data)] if the file changed (or is accessed for the first time). - Returns [None] if the file has not changed since last access (no unmarshal occurs). - - This is the key primitive for building reactive/incremental systems: - {[ - let my_cache = Hashtbl.create 100 - - let get_result path = - match Marshal_cache.with_unmarshalled_if_changed path process with - | Some result -> - Hashtbl.replace my_cache path result; - result - | None -> - Hashtbl.find my_cache path (* use cached result *) - ]} - - @raise Cache_error if the file cannot be read, mapped, or unmarshalled. - @raise exn if [f] raises; the cache state remains consistent. *) -val with_unmarshalled_if_changed : string -> ('a -> 'r) -> 'r option - [@@alert unsafe "Caller must ensure the file contains data of the expected type"] - -(** Remove all entries from the cache, unmapping all memory. - Entries currently in use (during a callback) are preserved and will be - cleaned up when their callbacks complete. *) -val clear : unit -> unit - -(** [invalidate path] removes a specific path from the cache. - No-op if the path is not cached or is currently in use. *) -val invalidate : string -> unit - -(** [set_max_entries n] sets the maximum number of cached entries. - When exceeded, least-recently-used entries are evicted. - Default: 10000. Set to 0 for unlimited (not recommended for long-running - processes). - - @raise Invalid_argument if [n < 0] *) -val set_max_entries : int -> unit - -(** [set_max_bytes n] sets the maximum total bytes of mapped memory. - When exceeded, least-recently-used entries are evicted. - Default: 1GB (1073741824). Set to 0 for unlimited. - - @raise Invalid_argument if [n < 0] *) -val set_max_bytes : int -> unit - -(** [stats ()] returns cache statistics. - Useful for monitoring cache usage. *) -val stats : unit -> stats - diff --git a/analysis/vendor/skip-lite/marshal_cache/marshal_cache_stubs.cpp b/analysis/vendor/skip-lite/marshal_cache/marshal_cache_stubs.cpp deleted file mode 100644 index a18ba1b5a1..0000000000 --- a/analysis/vendor/skip-lite/marshal_cache/marshal_cache_stubs.cpp +++ /dev/null @@ -1,804 +0,0 @@ -// marshal_cache_stubs.cpp -// skip-lite: Marshal cache with mmap and LRU eviction -// OCaml 5+ compatible -// -// ============================================================================= -// WARNING: OCaml C FFI and GC Pitfalls -// ============================================================================= -// -// This file interfaces with the OCaml runtime. The OCaml garbage collector -// can move values in memory at any allocation point. Failure to handle this -// correctly causes memory corruption and segfaults. -// -// KEY RULES: -// -// 1. NEVER use String_val(v) across an allocation -// ------------------------------------------------ -// BAD: -// const char* s = String_val(str_val); -// some_ocaml_alloc(); // GC may run, str_val moves, s is now dangling -// use(s); // SEGFAULT -// -// GOOD: -// std::string s(String_val(str_val)); // Copy to C++ string first -// some_ocaml_alloc(); -// use(s.c_str()); // Safe, C++ owns the memory -// -// 2. NEVER nest allocations in Store_field -// ------------------------------------------------ -// BAD: -// value tuple = caml_alloc_tuple(2); -// Store_field(tuple, 0, caml_copy_string(s)); // DANGEROUS! -// // caml_copy_string allocates, may trigger GC, tuple address is -// // computed BEFORE the call, so we write to stale memory -// -// GOOD: -// value tuple = caml_alloc_tuple(2); -// value str = caml_copy_string(s); // Allocate first -// Store_field(tuple, 0, str); // Then store -// -// 3. CAMLlocal doesn't help with evaluation order -// ------------------------------------------------ -// CAMLlocal registers a variable so GC updates it when values move. -// But it doesn't fix the evaluation order problem in Store_field. -// The address computation happens before the nested function call. -// -// 4. Raising exceptions from C is tricky -// ------------------------------------------------ -// caml_raise* functions do a longjmp, so: -// - CAMLparam/CAMLlocal frames are not properly unwound -// - C++ destructors may not run (avoid RAII in throwing paths) -// - Prefer raising simple exceptions (Failure) and converting in OCaml -// -// 5. Callbacks can trigger arbitrary GC -// ------------------------------------------------ -// When calling caml_callback*, the OCaml code can allocate freely. -// All value variables from before the callback may be stale after. -// Either re-read them or use CAMLlocal to keep them updated. -// -// CURRENT APPROACH: -// - Errors are raised as Failure("path: message") from C -// - The OCaml wrapper catches Failure and converts to Cache_error -// - This avoids complex allocation sequences in exception-raising paths -// -// ============================================================================= - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -// OCaml headers -extern "C" { -#include -#include -#include -#include -#include -#include -} - -// Platform-specific mtime access (nanosecond precision) -#if defined(__APPLE__) || defined(__FreeBSD__) || defined(__NetBSD__) || defined(__OpenBSD__) - #define MTIME_SEC(st) ((st).st_mtimespec.tv_sec) - #define MTIME_NSEC(st) ((st).st_mtimespec.tv_nsec) -#else // Linux and others - #define MTIME_SEC(st) ((st).st_mtim.tv_sec) - #define MTIME_NSEC(st) ((st).st_mtim.tv_nsec) -#endif - -namespace { - -// File identity for cache invalidation (mtime + size + inode) -struct FileId { - time_t mtime_sec; - long mtime_nsec; - off_t size; - ino_t ino; - - bool operator==(const FileId& other) const { - return mtime_sec == other.mtime_sec && - mtime_nsec == other.mtime_nsec && - size == other.size && - ino == other.ino; - } - - bool operator!=(const FileId& other) const { - return !(*this == other); - } -}; - -// A memory mapping -struct Mapping { - void* ptr = nullptr; - size_t len = 0; - FileId file_id = {}; - - bool is_valid() const { - return ptr != nullptr && ptr != MAP_FAILED && ptr != reinterpret_cast(1); - } -}; - -// Cache entry for a single file -struct Entry { - std::string path; - Mapping current; - size_t in_use = 0; // Number of active callbacks - std::vector old_mappings; // Deferred unmaps - std::list::iterator lru_iter; -}; - -// The global cache singleton -class MarshalCache { -public: - static MarshalCache& instance() { - static MarshalCache inst; - return inst; - } - - // Acquire a mapping, incrementing in_use. Returns pointer, length, and whether file changed. - // Throws std::runtime_error on failure. - void acquire_mapping(const std::string& path, void** out_ptr, size_t* out_len, bool* out_changed); - - // Release a mapping, decrementing in_use and cleaning up old mappings. - void release_mapping(const std::string& path); - - // Clear all entries (only those not in use) - void clear(); - - // Invalidate a specific path - void invalidate(const std::string& path); - - // Set limits - void set_max_entries(size_t n) { - std::lock_guard lock(mutex_); - max_entries_ = n; - evict_if_needed(); - } - - void set_max_bytes(size_t n) { - std::lock_guard lock(mutex_); - max_bytes_ = n; - evict_if_needed(); - } - - // Stats: (entry_count, total_mapped_bytes) - std::pair stats() { - std::lock_guard lock(mutex_); - return {cache_.size(), current_bytes_}; - } - -private: - MarshalCache() = default; - ~MarshalCache() { clear_internal(); } - - // Prevent copying - MarshalCache(const MarshalCache&) = delete; - MarshalCache& operator=(const MarshalCache&) = delete; - - // Must be called with mutex_ held - void evict_if_needed(); - void unmap_mapping(const Mapping& m); - void touch_lru(Entry& entry); - void clear_internal(); - - // Get file identity, throws on error - FileId get_file_id(const char* path); - - // Create a new mapping for a file, throws on error - Mapping create_mapping(const char* path, const FileId& file_id); - - std::unordered_map cache_; - std::list lru_order_; // front = most recent - std::mutex mutex_; - - size_t max_entries_ = 10000; - size_t max_bytes_ = 1ULL << 30; // 1GB default - size_t current_bytes_ = 0; -}; - -FileId MarshalCache::get_file_id(const char* path) { - struct stat st; - if (stat(path, &st) != 0) { - throw std::runtime_error(std::string("stat failed: ") + path + ": " + strerror(errno)); - } - return FileId{ - MTIME_SEC(st), - MTIME_NSEC(st), - st.st_size, - st.st_ino - }; -} - -Mapping MarshalCache::create_mapping(const char* path, const FileId& file_id) { - int fd = open(path, O_RDONLY); - if (fd < 0) { - throw std::runtime_error(std::string("open failed: ") + path + ": " + strerror(errno)); - } - - size_t len = static_cast(file_id.size); - void* ptr = nullptr; - - if (len > 0) { - ptr = mmap(nullptr, len, PROT_READ, MAP_PRIVATE, fd, 0); - } else { - // Empty file: use a sentinel non-null pointer - ptr = reinterpret_cast(1); - } - - // Close fd immediately - mapping remains valid on POSIX - close(fd); - - if (len > 0 && (ptr == MAP_FAILED || ptr == nullptr)) { - throw std::runtime_error(std::string("mmap failed: ") + path + ": " + strerror(errno)); - } - - Mapping m; - m.ptr = ptr; - m.len = len; - m.file_id = file_id; - return m; -} - -void MarshalCache::unmap_mapping(const Mapping& m) { - if (m.is_valid() && m.len > 0) { - munmap(m.ptr, m.len); - } -} - -void MarshalCache::touch_lru(Entry& entry) { - // Move to front of LRU list - lru_order_.erase(entry.lru_iter); - lru_order_.push_front(entry.path); - entry.lru_iter = lru_order_.begin(); -} - -void MarshalCache::evict_if_needed() { - // Must be called with mutex_ held - // Use >= because this is called BEFORE adding a new entry - while ((max_entries_ > 0 && cache_.size() >= max_entries_) || - (max_bytes_ > 0 && current_bytes_ >= max_bytes_)) { - if (lru_order_.empty()) break; - - // Find least-recently-used entry that is not in use - bool evicted = false; - for (auto it = lru_order_.rbegin(); it != lru_order_.rend(); ++it) { - auto cache_it = cache_.find(*it); - if (cache_it != cache_.end() && cache_it->second.in_use == 0) { - Entry& entry = cache_it->second; - - // Unmap current and all old mappings - unmap_mapping(entry.current); - for (const auto& m : entry.old_mappings) { - unmap_mapping(m); - } - current_bytes_ -= entry.current.len; - - lru_order_.erase(entry.lru_iter); - cache_.erase(cache_it); - evicted = true; - break; - } - } - if (!evicted) break; // All entries are in use - } -} - -void MarshalCache::acquire_mapping(const std::string& path, - void** out_ptr, size_t* out_len, bool* out_changed) { - std::unique_lock lock(mutex_); - - // Get current file identity - FileId current_id = get_file_id(path.c_str()); - - // Lookup or create entry - auto it = cache_.find(path); - bool needs_remap = false; - - if (it == cache_.end()) { - needs_remap = true; - } else if (it->second.current.file_id != current_id) { - needs_remap = true; - } - - if (needs_remap) { - // Only evict if we're adding a NEW entry (not updating existing) - // This prevents evicting the entry we're about to update - if (it == cache_.end()) { - evict_if_needed(); - } - - // Create new mapping (may throw) - Mapping new_mapping = create_mapping(path.c_str(), current_id); - - if (it == cache_.end()) { - // Insert new entry - Entry entry; - entry.path = path; - entry.current = new_mapping; - entry.in_use = 0; - lru_order_.push_front(path); - entry.lru_iter = lru_order_.begin(); - - cache_[path] = std::move(entry); - it = cache_.find(path); - } else { - // Update existing entry - Entry& entry = it->second; - Mapping old = entry.current; - entry.current = new_mapping; - - // Handle old mapping - if (old.is_valid()) { - if (entry.in_use == 0) { - unmap_mapping(old); - } else { - // Defer unmap until callbacks complete - entry.old_mappings.push_back(old); - } - current_bytes_ -= old.len; - } - } - - current_bytes_ += new_mapping.len; - } - - Entry& entry = it->second; - entry.in_use++; - touch_lru(entry); - - *out_ptr = entry.current.ptr; - *out_len = entry.current.len; - *out_changed = needs_remap; - - // Mutex released here (RAII) -} - -void MarshalCache::release_mapping(const std::string& path) { - std::lock_guard lock(mutex_); - - auto it = cache_.find(path); - if (it == cache_.end()) return; // Entry was evicted - - Entry& entry = it->second; - if (entry.in_use > 0) { - entry.in_use--; - } - - if (entry.in_use == 0 && !entry.old_mappings.empty()) { - // Clean up deferred unmaps - for (const auto& m : entry.old_mappings) { - unmap_mapping(m); - } - entry.old_mappings.clear(); - } -} - -void MarshalCache::clear_internal() { - for (auto& [path, entry] : cache_) { - if (entry.in_use == 0) { - unmap_mapping(entry.current); - } - for (const auto& m : entry.old_mappings) { - unmap_mapping(m); - } - } - cache_.clear(); - lru_order_.clear(); - current_bytes_ = 0; -} - -void MarshalCache::clear() { - std::lock_guard lock(mutex_); - - // Only clear entries not in use - for (auto it = cache_.begin(); it != cache_.end(); ) { - Entry& entry = it->second; - - // Always clean up old_mappings - for (const auto& m : entry.old_mappings) { - unmap_mapping(m); - } - entry.old_mappings.clear(); - - if (entry.in_use == 0) { - unmap_mapping(entry.current); - current_bytes_ -= entry.current.len; - lru_order_.erase(entry.lru_iter); - it = cache_.erase(it); - } else { - ++it; - } - } -} - -void MarshalCache::invalidate(const std::string& path) { - std::lock_guard lock(mutex_); - - auto it = cache_.find(path); - if (it == cache_.end()) return; - - Entry& entry = it->second; - - // Clean up old_mappings - for (const auto& m : entry.old_mappings) { - unmap_mapping(m); - } - entry.old_mappings.clear(); - - if (entry.in_use == 0) { - unmap_mapping(entry.current); - current_bytes_ -= entry.current.len; - lru_order_.erase(entry.lru_iter); - cache_.erase(it); - } - // If in_use > 0, the entry stays but will be refreshed on next access -} - -} // anonymous namespace - - -// ============================================================================= -// OCaml FFI stubs -// ============================================================================= - -extern "C" { - -// Helper to raise an error as Failure (converted to Cache_error in OCaml) -[[noreturn]] -static void raise_cache_error(const char* path, const char* message) { - std::string full_msg = std::string(path) + ": " + message; - caml_failwith(full_msg.c_str()); -} - -// ============================================================================= -// CMT/CMI file format support -// ============================================================================= -// -// ReScript/OCaml compiler generates several file types with headers before Marshal data: -// -// Pure .cmt files (typed tree only): -// - "Caml1999T0xx" (12 bytes) - CMT magic -// - Marshal data (cmt_infos record) -// -// Combined .cmt/.cmti files (interface + typed tree): -// - "Caml1999I0xx" (12 bytes) - CMI magic -// - Marshal data #1 (cmi_name, cmi_sign) -// - Marshal data #2 (crcs) -// - Marshal data #3 (flags) -// - "Caml1999T0xx" (12 bytes) - CMT magic -// - Marshal data (cmt_infos record) -// -// Pure .cmi files (compiled interface only): -// - "Caml1999I0xx" (12 bytes) - CMI magic -// - Marshal data #1 (cmi_name, cmi_sign) -// - Marshal data #2 (crcs) -// - Marshal data #3 (flags) -// -// This code handles all formats and finds the CMT Marshal data. -// ============================================================================= - -static constexpr size_t OCAML_MAGIC_LENGTH = 12; -static constexpr const char* CMT_MAGIC_PREFIX = "Caml1999T"; -static constexpr const char* CMI_MAGIC_PREFIX = "Caml1999I"; -static constexpr size_t MAGIC_PREFIX_LENGTH = 9; // Length of "Caml1999T" or "Caml1999I" - -// Check if data at offset starts with a specific prefix -static bool has_prefix_at(const unsigned char* data, size_t len, size_t offset, - const char* prefix, size_t prefix_len) { - if (len < offset + prefix_len) return false; - return memcmp(data + offset, prefix, prefix_len) == 0; -} - -// Check for Marshal magic at given offset -// Marshal magic: 0x8495A6BE (small/32-bit) or 0x8495A6BF (large/64-bit) -static bool has_marshal_magic_at(const unsigned char* data, size_t len, size_t offset) { - if (len < offset + 4) return false; - uint32_t magic = (static_cast(data[offset]) << 24) | - (static_cast(data[offset + 1]) << 16) | - (static_cast(data[offset + 2]) << 8) | - static_cast(data[offset + 3]); - return magic == 0x8495A6BEu || magic == 0x8495A6BFu; -} - -// Get the size of a Marshal value from its header -// Marshal header format (20 bytes for small, 32 bytes for large): -// 4 bytes: magic -// 4 bytes: data_len (or 8 bytes for large) -// 4 bytes: num_objects (or 8 bytes for large) -// 4 bytes: size_32 (or 8 bytes for large) -// 4 bytes: size_64 (or 8 bytes for large) -// Total Marshal value size = header_size + data_len -static size_t get_marshal_total_size(const unsigned char* data, size_t len, size_t offset) { - if (len < offset + 20) { - throw std::runtime_error("not enough data for Marshal header"); - } - - uint32_t magic = (static_cast(data[offset]) << 24) | - (static_cast(data[offset + 1]) << 16) | - (static_cast(data[offset + 2]) << 8) | - static_cast(data[offset + 3]); - - bool is_large = (magic == 0x8495A6BFu); - size_t header_size = is_large ? 32 : 20; - - if (len < offset + header_size) { - throw std::runtime_error("not enough data for Marshal header"); - } - - // data_len is at offset 4 (32-bit) or offset 4 (64-bit, we read low 32 bits which is enough) - uint32_t data_len; - if (is_large) { - // For large format, data_len is 8 bytes. Read as 64-bit but we only care about reasonable sizes. - // High 32 bits at offset+4, low 32 bits at offset+8 - uint32_t high = (static_cast(data[offset + 4]) << 24) | - (static_cast(data[offset + 5]) << 16) | - (static_cast(data[offset + 6]) << 8) | - static_cast(data[offset + 7]); - uint32_t low = (static_cast(data[offset + 8]) << 24) | - (static_cast(data[offset + 9]) << 16) | - (static_cast(data[offset + 10]) << 8) | - static_cast(data[offset + 11]); - if (high != 0) { - throw std::runtime_error("Marshal data too large (>4GB)"); - } - data_len = low; - } else { - data_len = (static_cast(data[offset + 4]) << 24) | - (static_cast(data[offset + 5]) << 16) | - (static_cast(data[offset + 6]) << 8) | - static_cast(data[offset + 7]); - } - - return header_size + data_len; -} - -// Find the offset where CMT Marshal data starts -// Returns the offset, or throws on error -static size_t find_cmt_marshal_offset(const unsigned char* data, size_t len) { - if (len < 4) { - throw std::runtime_error("file too small"); - } - - // Check for pure Marshal file (starts with Marshal magic) - if (has_marshal_magic_at(data, len, 0)) { - return 0; - } - - // Check for pure CMT file (starts with "Caml1999T") - if (has_prefix_at(data, len, 0, CMT_MAGIC_PREFIX, MAGIC_PREFIX_LENGTH)) { - if (len < OCAML_MAGIC_LENGTH + 4) { - throw std::runtime_error("CMT file too small"); - } - if (!has_marshal_magic_at(data, len, OCAML_MAGIC_LENGTH)) { - throw std::runtime_error("CMT file: no Marshal magic after header"); - } - return OCAML_MAGIC_LENGTH; - } - - // Check for CMI file (starts with "Caml1999I") - // This may be a combined CMI+CMT file, need to skip CMI data to find CMT - if (has_prefix_at(data, len, 0, CMI_MAGIC_PREFIX, MAGIC_PREFIX_LENGTH)) { - if (len < OCAML_MAGIC_LENGTH + 4) { - throw std::runtime_error("CMI file too small"); - } - - // Skip the CMI header - size_t offset = OCAML_MAGIC_LENGTH; - - // CMI section has 3 Marshal values: - // 1. (cmi_name, cmi_sign) - // 2. crcs - // 3. flags - for (int i = 0; i < 3; i++) { - if (!has_marshal_magic_at(data, len, offset)) { - throw std::runtime_error("CMI file: expected Marshal value in CMI section"); - } - size_t marshal_size = get_marshal_total_size(data, len, offset); - offset += marshal_size; - if (offset > len) { - throw std::runtime_error("CMI file: Marshal value extends past end of file"); - } - } - - // Now check if there's a CMT section after the CMI data - if (has_prefix_at(data, len, offset, CMT_MAGIC_PREFIX, MAGIC_PREFIX_LENGTH)) { - // Found CMT magic after CMI data - offset += OCAML_MAGIC_LENGTH; - if (!has_marshal_magic_at(data, len, offset)) { - throw std::runtime_error("CMT section: no Marshal magic after header"); - } - return offset; - } - - // No CMT section - this is a pure CMI file - // Return the first CMI Marshal value (not ideal but allows reading CMI files) - throw std::runtime_error("CMI file without CMT section - use read_cmi instead"); - } - - // Unknown format - throw std::runtime_error("unrecognized file format (not Marshal, CMT, or CMI)"); -} - -// Unmarshal from mmap'd memory (zero-copy using OCaml 5+ API) -// Handles both pure Marshal files and CMT/CMI files with headers -static value unmarshal_from_ptr(void* ptr, size_t len) { - CAMLparam0(); - CAMLlocal1(result); - - if (len == 0) { - caml_failwith("marshal_cache: empty file"); - } - - const unsigned char* data = static_cast(ptr); - - // Find where CMT Marshal data starts (handles CMT/CMI headers) - size_t offset; - try { - offset = find_cmt_marshal_offset(data, len); - } catch (const std::exception& e) { - std::string msg = std::string("marshal_cache: ") + e.what(); - caml_failwith(msg.c_str()); - } - - // Validate remaining length - size_t marshal_len = len - offset; - if (marshal_len < 20) { - caml_failwith("marshal_cache: Marshal data too small"); - } - - // OCaml 5+ API: unmarshal directly from memory block (zero-copy!) - const char* marshal_ptr = reinterpret_cast(data + offset); - result = caml_input_value_from_block(marshal_ptr, static_cast(marshal_len)); - - CAMLreturn(result); -} - -// Main entry point: with_unmarshalled_file -CAMLprim value mfc_with_unmarshalled_file(value path_val, value closure_val) { - CAMLparam2(path_val, closure_val); - CAMLlocal2(unmarshalled, result); - - const char* path = String_val(path_val); - std::string path_str(path); - - void* ptr = nullptr; - size_t len = 0; - bool changed = false; - - // Acquire mapping (may throw) - try { - MarshalCache::instance().acquire_mapping(path_str, &ptr, &len, &changed); - } catch (const std::exception& e) { - // Use path_str.c_str() instead of path, because raise_cache_error - // allocates and can trigger GC which would invalidate the pointer - // from String_val(path_val) - raise_cache_error(path_str.c_str(), e.what()); - CAMLreturn(Val_unit); // Not reached - } - - // Unmarshal (may allocate, may trigger GC, may raise) - unmarshalled = unmarshal_from_ptr(ptr, len); - - // Call the OCaml callback - result = caml_callback_exn(closure_val, unmarshalled); - - // Release mapping before potentially re-raising - MarshalCache::instance().release_mapping(path_str); - - // Check if callback raised an exception - if (Is_exception_result(result)) { - value exn = Extract_exception(result); - caml_raise(exn); - } - - CAMLreturn(result); -} - -// Reactive entry point: only unmarshal if file changed -// Returns Some(f(data)) if changed, None if unchanged -CAMLprim value mfc_with_unmarshalled_if_changed(value path_val, value closure_val) { - CAMLparam2(path_val, closure_val); - CAMLlocal3(unmarshalled, result, some_result); - - const char* path = String_val(path_val); - std::string path_str(path); - - void* ptr = nullptr; - size_t len = 0; - bool changed = false; - - // Acquire mapping (may throw) - try { - MarshalCache::instance().acquire_mapping(path_str, &ptr, &len, &changed); - } catch (const std::exception& e) { - raise_cache_error(path_str.c_str(), e.what()); - CAMLreturn(Val_unit); // Not reached - } - - if (!changed) { - // File unchanged - release and return None - MarshalCache::instance().release_mapping(path_str); - CAMLreturn(Val_none); - } - - // File changed - unmarshal and call callback - unmarshalled = unmarshal_from_ptr(ptr, len); - - // Call the OCaml callback - result = caml_callback_exn(closure_val, unmarshalled); - - // Release mapping before potentially re-raising - MarshalCache::instance().release_mapping(path_str); - - // Check if callback raised an exception - if (Is_exception_result(result)) { - value exn = Extract_exception(result); - caml_raise(exn); - } - - // Wrap in Some - some_result = caml_alloc(1, 0); - Store_field(some_result, 0, result); - - CAMLreturn(some_result); -} - -// Clear all cache entries -CAMLprim value mfc_clear(value unit) { - CAMLparam1(unit); - MarshalCache::instance().clear(); - CAMLreturn(Val_unit); -} - -// Invalidate a specific path -CAMLprim value mfc_invalidate(value path_val) { - CAMLparam1(path_val); - const char* path = String_val(path_val); - std::string path_str(path); // Copy immediately for consistency - MarshalCache::instance().invalidate(path_str); - CAMLreturn(Val_unit); -} - -// Set max entries -CAMLprim value mfc_set_max_entries(value n_val) { - CAMLparam1(n_val); - size_t n = Long_val(n_val); - MarshalCache::instance().set_max_entries(n); - CAMLreturn(Val_unit); -} - -// Set max bytes -CAMLprim value mfc_set_max_bytes(value n_val) { - CAMLparam1(n_val); - size_t n = Long_val(n_val); - MarshalCache::instance().set_max_bytes(n); - CAMLreturn(Val_unit); -} - -// Get stats: returns (entry_count, total_mapped_bytes) -CAMLprim value mfc_stats(value unit) { - CAMLparam1(unit); - CAMLlocal1(result); - - auto [entries, bytes] = MarshalCache::instance().stats(); - - result = caml_alloc_tuple(2); - Store_field(result, 0, Val_long(entries)); - Store_field(result, 1, Val_long(bytes)); - - CAMLreturn(result); -} - -} // extern "C" - diff --git a/analysis/vendor/skip-lite/reactive_file_collection/dune b/analysis/vendor/skip-lite/reactive_file_collection/dune deleted file mode 100644 index e83405cb88..0000000000 --- a/analysis/vendor/skip-lite/reactive_file_collection/dune +++ /dev/null @@ -1,3 +0,0 @@ -(library - (name reactive_file_collection) - (libraries marshal_cache)) diff --git a/analysis/vendor/skip-lite/reactive_file_collection/reactive_file_collection.ml b/analysis/vendor/skip-lite/reactive_file_collection/reactive_file_collection.ml deleted file mode 100644 index 9b137d7469..0000000000 --- a/analysis/vendor/skip-lite/reactive_file_collection/reactive_file_collection.ml +++ /dev/null @@ -1,77 +0,0 @@ -(* Reactive File Collection - Implementation *) - -type event = - | Added of string - | Removed of string - | Modified of string - -(* We need to use Obj.t to make the polymorphic process function work - with Marshal_cache which returns ['a]. This is safe because the user - guarantees the file contains data of the expected type. *) -type 'v process_fn = Obj.t -> 'v - -type 'v t = { - data : (string, 'v) Hashtbl.t; - process : 'v process_fn; -} - -let create (type a v) ~(process : a -> v) : v t = - let process_fn : v process_fn = fun obj -> process (Obj.obj obj) in - { - data = Hashtbl.create 256; - process = process_fn; - } - -let add t path = - let value = Marshal_cache.with_unmarshalled_file path (fun data -> - t.process (Obj.repr data) - ) in - Hashtbl.replace t.data path value - [@@alert "-unsafe"] - -let remove t path = - Hashtbl.remove t.data path - -let update t path = - (* Just reload - Marshal_cache handles the file reading efficiently *) - add t path - -let apply t events = - List.iter (function - | Added path -> add t path - | Removed path -> remove t path - | Modified path -> update t path - ) events -let get t path = - Hashtbl.find_opt t.data path - -let find t path = - Hashtbl.find t.data path - -let mem t path = - Hashtbl.mem t.data path - -let length t = - Hashtbl.length t.data - -let is_empty t = - length t = 0 - -let iter f t = - Hashtbl.iter f t.data - -let fold f t init = - Hashtbl.fold f t.data init - -let to_list t = - fold (fun k v acc -> (k, v) :: acc) t [] - -let paths t = - fold (fun k _ acc -> k :: acc) t [] - -let values t = - fold (fun _ v acc -> v :: acc) t [] - - - - diff --git a/analysis/vendor/skip-lite/test_cmt.ml b/analysis/vendor/skip-lite/test_cmt.ml deleted file mode 100644 index c2a4c21f7e..0000000000 --- a/analysis/vendor/skip-lite/test_cmt.ml +++ /dev/null @@ -1,119 +0,0 @@ -(* Test that Marshal_cache can read CMT files *) - -[@@@alert "-unsafe"] - -let print_cmt_info (cmt : Cmt_format.cmt_infos) = - Printf.printf " Module name: %s\n%!" cmt.cmt_modname; - Printf.printf " Build dir: %s\n%!" cmt.cmt_builddir; - (match cmt.cmt_sourcefile with - | Some sf -> Printf.printf " Source file: %s\n%!" sf - | None -> Printf.printf " Source file: none\n%!") - -let test_cmt_file_standard path = - Printf.printf "Testing with Cmt_format.read_cmt: %s\n%!" path; - try - let cmt = Cmt_format.read_cmt path in - print_cmt_info cmt; - Printf.printf " SUCCESS with standard read_cmt\n%!"; - true - with e -> - Printf.printf " FAILED: %s\n%!" (Printexc.to_string e); - false - -let test_cmt_file_cache path = - Printf.printf "Testing with Marshal_cache: %s\n%!" path; - try - Marshal_cache.with_unmarshalled_file path (fun (cmt : Cmt_format.cmt_infos) -> - print_cmt_info cmt; - Printf.printf " SUCCESS with Marshal_cache!\n%!"; - true - ) - with - | Marshal_cache.Cache_error (p, msg) -> - Printf.printf " Cache_error: %s: %s\n%!" p msg; - false - | e -> - Printf.printf " FAILED: %s\n%!" (Printexc.to_string e); - false - -let test_cmt_file path = - if not (Sys.file_exists path) then begin - Printf.printf "File not found: %s\n%!" path; - false - end else begin - Printf.printf "\n=== Testing: %s ===\n%!" path; - let std_ok = test_cmt_file_standard path in - Printf.printf "\n%!"; - let cache_ok = test_cmt_file_cache path in - std_ok && cache_ok - end - - -let () = - Printf.printf "=== Marshal_cache CMT Test ===\n\n%!"; - - (* Get CMT files from command line args or find in lib/bs *) - let cmt_files = - if Array.length Sys.argv > 1 then - Array.to_list (Array.sub Sys.argv 1 (Array.length Sys.argv - 1)) - else begin - (* Find CMT files in lib/bs *) - let find_cmt_in_dir dir = - if Sys.file_exists dir && Sys.is_directory dir then begin - let rec find acc dir = - Array.fold_left (fun acc name -> - let path = Filename.concat dir name in - if Sys.is_directory path then - find acc path - else if Filename.check_suffix path ".cmt" then - path :: acc - else - acc - ) acc (Sys.readdir dir) - in - find [] dir - end else [] - in - let lib_bs = "lib/bs" in - let files = find_cmt_in_dir lib_bs in - Printf.printf "Found %d CMT files in %s\n\n%!" (List.length files) lib_bs; - files - end - in - - (* Test first 3 CMT files *) - let test_files = - cmt_files - |> List.sort String.compare - |> (fun l -> try List.filteri (fun i _ -> i < 3) l with _ -> l) - in - - List.iter (fun path -> - let _ = test_cmt_file path in - Printf.printf "\n%!" - ) test_files; - - (* Test if_changed API *) - Printf.printf "=== Testing with_unmarshalled_if_changed ===\n\n%!"; - Marshal_cache.clear (); (* Clear cache to start fresh *) - (match test_files with - | path :: _ -> - Printf.printf "First call (should process):\n%!"; - (match Marshal_cache.with_unmarshalled_if_changed path (fun (cmt : Cmt_format.cmt_infos) -> - Printf.printf " Processed: %s\n%!" cmt.cmt_modname; - cmt.cmt_modname - ) with - | Some name -> Printf.printf " Result: Some(%s) - SUCCESS (file was processed)\n%!" name - | None -> Printf.printf " Result: None (unexpected - should have processed!)\n%!"); - - Printf.printf "Second call (should return None - file unchanged):\n%!"; - (match Marshal_cache.with_unmarshalled_if_changed path (fun (cmt : Cmt_format.cmt_infos) -> - Printf.printf " Processed: %s\n%!" cmt.cmt_modname; - cmt.cmt_modname - ) with - | Some name -> Printf.printf " Result: Some(%s) (unexpected - file should be cached!)\n%!" name - | None -> Printf.printf " Result: None - SUCCESS (file was cached!)\n%!") - | [] -> Printf.printf "No CMT files to test\n%!"); - - Printf.printf "\n=== Test Complete ===\n%!" - diff --git a/tests/analysis_tests/tests-reanalyze/deadcode-benchmark/Makefile b/tests/analysis_tests/tests-reanalyze/deadcode-benchmark/Makefile index a7f30e2282..33bc025c4e 100644 --- a/tests/analysis_tests/tests-reanalyze/deadcode-benchmark/Makefile +++ b/tests/analysis_tests/tests-reanalyze/deadcode-benchmark/Makefile @@ -60,7 +60,7 @@ time-reactive: generate build @dune exec rescript-editor-analysis -- reanalyze -config -ci -timing 2>&1 | grep -E "=== Timing|CMT processing|File loading|Total:" @echo "" @echo "Reactive mode (3 runs - first is cold, subsequent are warm):" - @dune exec rescript-editor-analysis -- reanalyze -config -ci -timing -reactive -runs 3 2>&1 | grep -E "=== Run|=== Timing|CMT processing|File loading|Total:" + @dune exec rescript-editor-analysis -- reanalyze -config -ci -timing -reactive -runs 3 2>/dev/null .DEFAULT_GOAL := benchmark diff --git a/tests/analysis_tests/tests-reanalyze/deadcode-benchmark/package.json b/tests/analysis_tests/tests-reanalyze/deadcode-benchmark/package.json index fc8d9b2b70..f89de2fb09 100644 --- a/tests/analysis_tests/tests-reanalyze/deadcode-benchmark/package.json +++ b/tests/analysis_tests/tests-reanalyze/deadcode-benchmark/package.json @@ -2,8 +2,8 @@ "name": "@tests/reanalyze-benchmark", "private": true, "scripts": { - "build": "rescript-legacy build", - "clean": "rescript-legacy clean" + "build": "rescript build", + "clean": "rescript clean" }, "dependencies": { "@rescript/react": "link:../../../dependencies/rescript-react", diff --git a/tests/dependencies/rescript-react/package.json b/tests/dependencies/rescript-react/package.json index 0d09e376d6..eaf7dd05a3 100644 --- a/tests/dependencies/rescript-react/package.json +++ b/tests/dependencies/rescript-react/package.json @@ -1,4 +1,29 @@ { "name": "@rescript/react", - "private": true + "private": true, + "version": "12.0.2", + "homepage": "https://rescript-lang.org", + "bugs": "https://github.com/rescript-lang/rescript/issues", + "repository": { + "type": "git", + "url": "git+https://github.com/rescript-lang/rescript.git" + }, + "author": { + "name": "Hongbo Zhang", + "email": "bobzhang1988@gmail.com" + }, + "maintainers": [ + "Christoph Knittel (https://github.com/cknitt)", + "Cristiano Calcagno (https://github.com/cristianoc)", + "Dmitry Zakharov (https://github.com/DZakh)", + "Florian Hammerschmidt (https://github.com/fhammerschmidt)", + "Gabriel Nordeborn (https://github.com/zth)", + "Hyeseong Kim (https://github.com/cometkim)", + "Jaap Frolich (https://github.com/jfrolich)", + "Matthias Le Brun (https://github.com/bloodyowl)", + "Patrick Ecker (https://github.com/ryyppy)", + "Paul Tsnobiladzé (https://github.com/tsnobip)", + "Woonki Moon (https://github.com/mununki)" + ], + "preferUnplugged": true } From dfdf0109f09077999a1dddde8be67d725ec24de7 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 06:02:51 +0100 Subject: [PATCH 05/45] Move reactive combinators to dedicated library with composition support - Create new analysis/reactive library with: - Reactive.ml: Core combinators (delta type, flatMap with merge) - ReactiveFileCollection.ml: Generic file collection with change detection - Comprehensive tests including multi-stage composition - Remove CmtCache module (logic absorbed into ReactiveFileCollection) - ReactiveAnalysis now uses generic ReactiveFileCollection with: - read_file: Cmt_format.read_cmt - process: CMT analysis function - Test composition: files -> word_counts (with merge) -> frequent_words Demonstrates delta propagation across multiple flatMap stages --- analysis/dune | 2 +- analysis/reactive/dune | 1 + analysis/reactive/src/Reactive.ml | 138 +++++++ analysis/reactive/src/Reactive.mli | 75 ++++ .../reactive/src/ReactiveFileCollection.ml | 87 +++++ .../reactive/src/ReactiveFileCollection.mli | 59 +++ analysis/reactive/src/dune | 4 + analysis/reactive/test/ReactiveTest.ml | 338 ++++++++++++++++++ analysis/reactive/test/dune | 3 + analysis/reanalyze/src/Cli.ml | 3 - analysis/reanalyze/src/CmtCache.ml | 70 ---- analysis/reanalyze/src/CmtCache.mli | 27 -- analysis/reanalyze/src/ReactiveAnalysis.ml | 34 +- .../reanalyze/src/ReactiveFileCollection.ml | 52 --- .../reanalyze/src/ReactiveFileCollection.mli | 104 ------ analysis/reanalyze/src/Reanalyze.ml | 8 +- analysis/reanalyze/src/Timing.ml | 3 +- analysis/reanalyze/src/dune | 2 +- 18 files changed, 723 insertions(+), 287 deletions(-) create mode 100644 analysis/reactive/dune create mode 100644 analysis/reactive/src/Reactive.ml create mode 100644 analysis/reactive/src/Reactive.mli create mode 100644 analysis/reactive/src/ReactiveFileCollection.ml create mode 100644 analysis/reactive/src/ReactiveFileCollection.mli create mode 100644 analysis/reactive/src/dune create mode 100644 analysis/reactive/test/ReactiveTest.ml create mode 100644 analysis/reactive/test/dune delete mode 100644 analysis/reanalyze/src/CmtCache.ml delete mode 100644 analysis/reanalyze/src/CmtCache.mli delete mode 100644 analysis/reanalyze/src/ReactiveFileCollection.ml delete mode 100644 analysis/reanalyze/src/ReactiveFileCollection.mli diff --git a/analysis/dune b/analysis/dune index 6b297d2e58..9b02abb4b5 100644 --- a/analysis/dune +++ b/analysis/dune @@ -1,4 +1,4 @@ -(dirs bin src reanalyze vendor) +(dirs bin src reactive reanalyze vendor) (env (dev diff --git a/analysis/reactive/dune b/analysis/reactive/dune new file mode 100644 index 0000000000..2aac24f843 --- /dev/null +++ b/analysis/reactive/dune @@ -0,0 +1 @@ +(dirs src test) diff --git a/analysis/reactive/src/Reactive.ml b/analysis/reactive/src/Reactive.ml new file mode 100644 index 0000000000..cb8b29ebd2 --- /dev/null +++ b/analysis/reactive/src/Reactive.ml @@ -0,0 +1,138 @@ +(** Reactive collections for incremental computation. + + Provides composable reactive collections with delta-based updates. *) + +(** {1 Deltas} *) + +type ('k, 'v) delta = Set of 'k * 'v | Remove of 'k + +let apply_delta tbl = function + | Set (k, v) -> Hashtbl.replace tbl k v + | Remove k -> Hashtbl.remove tbl k + +let apply_deltas tbl deltas = List.iter (apply_delta tbl) deltas + +(** {1 Reactive Collection} *) + +type ('k, 'v) t = { + subscribe: (('k, 'v) delta -> unit) -> unit; + iter: ('k -> 'v -> unit) -> unit; + get: 'k -> 'v option; + length: unit -> int; +} +(** A reactive collection that can emit deltas and be read. + All collections share this interface, enabling composition. *) + +(** {1 Collection operations} *) + +let iter f t = t.iter f +let get t k = t.get k +let length t = t.length () + +(** {1 FlatMap} *) + +(** Transform a collection into another collection. + Each source entry maps to multiple target entries via [f]. + Optional [merge] combines values when multiple sources produce the same key. *) +let flatMap (source : ('k1, 'v1) t) ~f ?merge () : ('k2, 'v2) t = + let merge = + match merge with + | Some m -> m + | None -> fun _ v -> v + in + (* Internal state *) + let provenance : ('k1, 'k2 list) Hashtbl.t = Hashtbl.create 64 in + let contributions : ('k2, ('k1, 'v2) Hashtbl.t) Hashtbl.t = + Hashtbl.create 256 + in + let target : ('k2, 'v2) Hashtbl.t = Hashtbl.create 256 in + let subscribers : (('k2, 'v2) delta -> unit) list ref = ref [] in + + let emit delta = List.iter (fun h -> h delta) !subscribers in + + let recompute_target k2 = + match Hashtbl.find_opt contributions k2 with + | None -> + Hashtbl.remove target k2; + Some (Remove k2) + | Some contribs when Hashtbl.length contribs = 0 -> + Hashtbl.remove contributions k2; + Hashtbl.remove target k2; + Some (Remove k2) + | Some contribs -> + let values = Hashtbl.fold (fun _ v acc -> v :: acc) contribs [] in + let merged = + match values with + | [] -> assert false + | [v] -> v + | v :: rest -> List.fold_left merge v rest + in + Hashtbl.replace target k2 merged; + Some (Set (k2, merged)) + in + + let remove_source k1 = + match Hashtbl.find_opt provenance k1 with + | None -> [] + | Some target_keys -> + Hashtbl.remove provenance k1; + target_keys + |> List.iter (fun k2 -> + match Hashtbl.find_opt contributions k2 with + | None -> () + | Some contribs -> Hashtbl.remove contribs k1); + target_keys + in + + let add_source k1 entries = + let target_keys = List.map fst entries in + Hashtbl.replace provenance k1 target_keys; + entries + |> List.iter (fun (k2, v2) -> + let contribs = + match Hashtbl.find_opt contributions k2 with + | Some c -> c + | None -> + let c = Hashtbl.create 4 in + Hashtbl.replace contributions k2 c; + c + in + Hashtbl.replace contribs k1 v2); + target_keys + in + + let handle_delta delta = + let downstream = + match delta with + | Remove k1 -> + let affected = remove_source k1 in + affected |> List.filter_map recompute_target + | Set (k1, v1) -> + let old_affected = remove_source k1 in + let new_entries = f k1 v1 in + let new_affected = add_source k1 new_entries in + let all_affected = old_affected @ new_affected in + let seen = Hashtbl.create (List.length all_affected) in + all_affected + |> List.filter_map (fun k2 -> + if Hashtbl.mem seen k2 then None + else ( + Hashtbl.replace seen k2 (); + recompute_target k2)) + in + List.iter emit downstream + in + + (* Subscribe to future deltas *) + source.subscribe handle_delta; + + (* Populate from existing entries *) + source.iter (fun k v -> handle_delta (Set (k, v))); + + (* Return collection interface *) + { + subscribe = (fun handler -> subscribers := handler :: !subscribers); + iter = (fun f -> Hashtbl.iter f target); + get = (fun k -> Hashtbl.find_opt target k); + length = (fun () -> Hashtbl.length target); + } diff --git a/analysis/reactive/src/Reactive.mli b/analysis/reactive/src/Reactive.mli new file mode 100644 index 0000000000..8b1b3e5a31 --- /dev/null +++ b/analysis/reactive/src/Reactive.mli @@ -0,0 +1,75 @@ +(** Reactive collections for incremental computation. + + Provides composable reactive collections with delta-based updates. + + {2 Example: Composing collections} + + {[ + (* Create a file collection *) + let files = ReactiveFileCollection.create ~read_file ~process in + + (* Derive a declarations collection *) + let decls = Reactive.flatMap files + ~f:(fun _path data -> data.decls) + () + + (* Derive a references collection with merging *) + let refs = Reactive.flatMap decls + ~f:(fun _pos decl -> decl.refs) + ~merge:PosSet.union + () + + (* Process files - all downstream collections update automatically *) + files |> Reactive.iter (fun path _ -> + ReactiveFileCollection.process_if_changed files_internal path) + + (* Read from any collection *) + Reactive.iter (fun k v -> ...) refs + ]} *) + +(** {1 Deltas} *) + +type ('k, 'v) delta = Set of 'k * 'v | Remove of 'k + +val apply_delta : ('k, 'v) Hashtbl.t -> ('k, 'v) delta -> unit +val apply_deltas : ('k, 'v) Hashtbl.t -> ('k, 'v) delta list -> unit + +(** {1 Reactive Collection} *) + +type ('k, 'v) t = { + subscribe: (('k, 'v) delta -> unit) -> unit; + iter: ('k -> 'v -> unit) -> unit; + get: 'k -> 'v option; + length: unit -> int; +} +(** A reactive collection that can emit deltas and be read. + All collections share this interface, enabling composition. *) + +(** {1 Collection operations} *) + +val iter : ('k -> 'v -> unit) -> ('k, 'v) t -> unit +(** Iterate over entries. *) + +val get : ('k, 'v) t -> 'k -> 'v option +(** Get a value by key. *) + +val length : ('k, 'v) t -> int +(** Number of entries. *) + +(** {1 Composition} *) + +val flatMap : + ('k1, 'v1) t -> + f:('k1 -> 'v1 -> ('k2 * 'v2) list) -> + ?merge:('v2 -> 'v2 -> 'v2) -> + unit -> + ('k2, 'v2) t +(** [flatMap source ~f ()] creates a derived collection. + + Each entry [(k1, v1)] in [source] produces entries [(k2, v2), ...] via [f k1 v1]. + When [source] changes, the derived collection updates automatically. + + Optional [merge] combines values when multiple sources produce the same key. + Defaults to last-write-wins. + + Derived collections can be further composed with [flatMap]. *) diff --git a/analysis/reactive/src/ReactiveFileCollection.ml b/analysis/reactive/src/ReactiveFileCollection.ml new file mode 100644 index 0000000000..88f9a77265 --- /dev/null +++ b/analysis/reactive/src/ReactiveFileCollection.ml @@ -0,0 +1,87 @@ +(** Reactive File Collection + + Creates a reactive collection from files with automatic change detection. *) + +type file_id = {mtime: float; size: int; ino: int} +(** File identity for change detection *) + +let get_file_id path : file_id = + let st = Unix.stat path in + {mtime = st.Unix.st_mtime; size = st.Unix.st_size; ino = st.Unix.st_ino} + +let file_changed ~old_id ~new_id = + old_id.mtime <> new_id.mtime + || old_id.size <> new_id.size || old_id.ino <> new_id.ino + +type ('raw, 'v) internal = { + cache: (string, file_id * 'v) Hashtbl.t; + read_file: string -> 'raw; + process: 'raw -> 'v; + mutable subscribers: ((string, 'v) Reactive.delta -> unit) list; +} +(** Internal state for file collection *) + +type ('raw, 'v) t = { + internal: ('raw, 'v) internal; + collection: (string, 'v) Reactive.t; +} +(** A file collection is just a Reactive.t with some extra operations *) + +let emit t delta = List.iter (fun h -> h delta) t.internal.subscribers + +(** Create a new reactive file collection *) +let create ~read_file ~process : ('raw, 'v) t = + let internal = + {cache = Hashtbl.create 256; read_file; process; subscribers = []} + in + let collection = + { + Reactive.subscribe = + (fun handler -> internal.subscribers <- handler :: internal.subscribers); + iter = + (fun f -> Hashtbl.iter (fun path (_, v) -> f path v) internal.cache); + get = + (fun path -> + match Hashtbl.find_opt internal.cache path with + | Some (_, v) -> Some v + | None -> None); + length = (fun () -> Hashtbl.length internal.cache); + } + in + {internal; collection} + +(** Get the collection interface for composition *) +let to_collection t : (string, 'v) Reactive.t = t.collection + +(** Process a file if changed. Emits delta to subscribers. *) +let process_if_changed t path = + let new_id = get_file_id path in + match Hashtbl.find_opt t.internal.cache path with + | Some (old_id, _) when not (file_changed ~old_id ~new_id) -> + false (* unchanged *) + | _ -> + let raw = t.internal.read_file path in + let value = t.internal.process raw in + Hashtbl.replace t.internal.cache path (new_id, value); + emit t (Reactive.Set (path, value)); + true (* changed *) + +(** Process multiple files *) +let process_files t paths = + List.iter (fun path -> ignore (process_if_changed t path)) paths + +(** Remove a file *) +let remove t path = + Hashtbl.remove t.internal.cache path; + emit t (Reactive.Remove path) + +(** Clear all cached data *) +let clear t = Hashtbl.clear t.internal.cache + +(** Invalidate a path *) +let invalidate t path = Hashtbl.remove t.internal.cache path + +let get t path = t.collection.get path +let mem t path = Hashtbl.mem t.internal.cache path +let length t = t.collection.length () +let iter f t = t.collection.iter f diff --git a/analysis/reactive/src/ReactiveFileCollection.mli b/analysis/reactive/src/ReactiveFileCollection.mli new file mode 100644 index 0000000000..3730c11d70 --- /dev/null +++ b/analysis/reactive/src/ReactiveFileCollection.mli @@ -0,0 +1,59 @@ +(** Reactive File Collection + + Creates a reactive collection from files with automatic change detection. + + {2 Example} + + {[ + (* Create file collection *) + let files = ReactiveFileCollection.create + ~read_file:Cmt_format.read_cmt + ~process:(fun cmt -> extract_data cmt) + + (* Compose with flatMap *) + let decls = Reactive.flatMap (ReactiveFileCollection.to_collection files) + ~f:(fun _path data -> data.decls) + () + + (* Process files - decls updates automatically *) + ReactiveFileCollection.process_files files [file_a; file_b]; + + (* Read results *) + Reactive.iter (fun pos decl -> ...) decls + ]} *) + +type ('raw, 'v) t +(** A file collection. ['raw] is the raw file type, ['v] is the processed value. *) + +(** {1 Creation} *) + +val create : read_file:(string -> 'raw) -> process:('raw -> 'v) -> ('raw, 'v) t +(** Create a new file collection. *) + +(** {1 Composition} *) + +val to_collection : ('raw, 'v) t -> (string, 'v) Reactive.t +(** Get the reactive collection interface for use with [Reactive.flatMap]. *) + +(** {1 Processing} *) + +val process_files : ('raw, 'v) t -> string list -> unit +(** Process files, emitting deltas for changed files. *) + +val process_if_changed : ('raw, 'v) t -> string -> bool +(** Process a file if changed. Returns true if file was processed. *) + +val remove : ('raw, 'v) t -> string -> unit +(** Remove a file from the collection. *) + +(** {1 Cache Management} *) + +val invalidate : ('raw, 'v) t -> string -> unit +val clear : ('raw, 'v) t -> unit + +(** {1 Access} *) + +val get : ('raw, 'v) t -> string -> 'v option +val mem : ('raw, 'v) t -> string -> bool +val length : ('raw, 'v) t -> int +val iter : (string -> 'v -> unit) -> ('raw, 'v) t -> unit diff --git a/analysis/reactive/src/dune b/analysis/reactive/src/dune new file mode 100644 index 0000000000..4fb933961f --- /dev/null +++ b/analysis/reactive/src/dune @@ -0,0 +1,4 @@ +(library + (name reactive) + (wrapped false) + (libraries unix)) diff --git a/analysis/reactive/test/ReactiveTest.ml b/analysis/reactive/test/ReactiveTest.ml new file mode 100644 index 0000000000..740d11f941 --- /dev/null +++ b/analysis/reactive/test/ReactiveTest.ml @@ -0,0 +1,338 @@ +(** Tests for Reactive collections *) + +open Reactive + +(** {1 Helper functions} *) + +let read_lines path = + let ic = open_in path in + let lines = ref [] in + (try + while true do + lines := input_line ic :: !lines + done + with End_of_file -> ()); + close_in ic; + List.rev !lines + +let write_lines path lines = + let oc = open_out path in + List.iter (fun line -> output_string oc (line ^ "\n")) lines; + close_out oc + +(** {1 Tests} *) + +let test_flatmap_basic () = + Printf.printf "=== Test: flatMap basic ===\n"; + + (* Create a simple source collection *) + let data : (int, string) Hashtbl.t = Hashtbl.create 16 in + let subscribers : ((int, string) delta -> unit) list ref = ref [] in + + let source : (int, string) t = + { + subscribe = (fun h -> subscribers := h :: !subscribers); + iter = (fun f -> Hashtbl.iter f data); + get = (fun k -> Hashtbl.find_opt data k); + length = (fun () -> Hashtbl.length data); + } + in + + let emit delta = + apply_delta data delta; + List.iter (fun h -> h delta) !subscribers + in + + (* Create derived collection via flatMap *) + let derived = + flatMap source + ~f:(fun key value -> + [(key * 10, value); ((key * 10) + 1, value); ((key * 10) + 2, value)]) + () + in + + (* Add entry -> derived should have 3 entries *) + emit (Set (1, "a")); + Printf.printf "After Set(1, 'a'): derived has %d entries\n" (length derived); + assert (length derived = 3); + assert (get derived 10 = Some "a"); + assert (get derived 11 = Some "a"); + assert (get derived 12 = Some "a"); + + (* Add another entry *) + emit (Set (2, "b")); + Printf.printf "After Set(2, 'b'): derived has %d entries\n" (length derived); + assert (length derived = 6); + + (* Update entry *) + emit (Set (1, "A")); + Printf.printf "After Set(1, 'A'): derived has %d entries\n" (length derived); + assert (get derived 10 = Some "A"); + assert (length derived = 6); + + (* Remove entry *) + emit (Remove 1); + Printf.printf "After Remove(1): derived has %d entries\n" (length derived); + assert (length derived = 3); + assert (get derived 10 = None); + assert (get derived 20 = Some "b"); + + Printf.printf "PASSED\n\n" + +module IntSet = Set.Make (Int) + +let test_flatmap_with_merge () = + Printf.printf "=== Test: flatMap with merge ===\n"; + + let data : (int, IntSet.t) Hashtbl.t = Hashtbl.create 16 in + let subscribers : ((int, IntSet.t) delta -> unit) list ref = ref [] in + + let source : (int, IntSet.t) t = + { + subscribe = (fun h -> subscribers := h :: !subscribers); + iter = (fun f -> Hashtbl.iter f data); + get = (fun k -> Hashtbl.find_opt data k); + length = (fun () -> Hashtbl.length data); + } + in + + let emit delta = + apply_delta data delta; + List.iter (fun h -> h delta) !subscribers + in + + (* Create derived with merge *) + let derived = + flatMap source + ~f:(fun _key values -> [(0, values)]) (* all contribute to key 0 *) + ~merge:IntSet.union () + in + + (* Source 1 contributes {1, 2} *) + emit (Set (1, IntSet.of_list [1; 2])); + let v = get derived 0 |> Option.get in + Printf.printf "After source 1: {%s}\n" + (IntSet.elements v |> List.map string_of_int |> String.concat ", "); + assert (IntSet.equal v (IntSet.of_list [1; 2])); + + (* Source 2 contributes {3, 4} -> should merge *) + emit (Set (2, IntSet.of_list [3; 4])); + let v = get derived 0 |> Option.get in + Printf.printf "After source 2: {%s}\n" + (IntSet.elements v |> List.map string_of_int |> String.concat ", "); + assert (IntSet.equal v (IntSet.of_list [1; 2; 3; 4])); + + (* Remove source 1 *) + emit (Remove 1); + let v = get derived 0 |> Option.get in + Printf.printf "After remove 1: {%s}\n" + (IntSet.elements v |> List.map string_of_int |> String.concat ", "); + assert (IntSet.equal v (IntSet.of_list [3; 4])); + + Printf.printf "PASSED\n\n" + +let test_composition () = + Printf.printf "=== Test: composition (flatMap chain) ===\n"; + + (* Source: file -> list of items *) + let data : (string, string list) Hashtbl.t = Hashtbl.create 16 in + let subscribers : ((string, string list) delta -> unit) list ref = ref [] in + + let source : (string, string list) t = + { + subscribe = (fun h -> subscribers := h :: !subscribers); + iter = (fun f -> Hashtbl.iter f data); + get = (fun k -> Hashtbl.find_opt data k); + length = (fun () -> Hashtbl.length data); + } + in + + let emit delta = + apply_delta data delta; + List.iter (fun h -> h delta) !subscribers + in + + (* First flatMap: file -> items *) + let items = + flatMap source + ~f:(fun path items -> + List.mapi (fun i item -> (Printf.sprintf "%s:%d" path i, item)) items) + () + in + + (* Second flatMap: item -> chars *) + let chars = + flatMap items + ~f:(fun key value -> + String.to_seq value + |> Seq.mapi (fun i c -> (Printf.sprintf "%s:%d" key i, c)) + |> List.of_seq) + () + in + + (* Add file with 2 items *) + emit (Set ("file1", ["ab"; "cd"])); + Printf.printf "After file1: items=%d, chars=%d\n" (length items) + (length chars); + assert (length items = 2); + assert (length chars = 4); + + (* Add another file *) + emit (Set ("file2", ["xyz"])); + Printf.printf "After file2: items=%d, chars=%d\n" (length items) + (length chars); + assert (length items = 3); + assert (length chars = 7); + + (* Update file1 *) + emit (Set ("file1", ["a"])); + Printf.printf "After update file1: items=%d, chars=%d\n" (length items) + (length chars); + assert (length items = 2); + (* 1 from file1 + 1 from file2 *) + assert (length chars = 4); + + (* 1 from file1 + 3 from file2 *) + Printf.printf "PASSED\n\n" + +let test_flatmap_on_existing_data () = + Printf.printf "=== Test: flatMap on collection with existing data ===\n"; + + (* Create source with data already in it *) + let data : (int, string) Hashtbl.t = Hashtbl.create 16 in + Hashtbl.add data 1 "a"; + Hashtbl.add data 2 "b"; + + let subscribers : ((int, string) delta -> unit) list ref = ref [] in + + let source : (int, string) t = + { + subscribe = (fun h -> subscribers := h :: !subscribers); + iter = (fun f -> Hashtbl.iter f data); + get = (fun k -> Hashtbl.find_opt data k); + length = (fun () -> Hashtbl.length data); + } + in + + Printf.printf "Source has %d entries before flatMap\n" (length source); + + (* Create flatMap AFTER source has data *) + let derived = flatMap source ~f:(fun k v -> [(k * 10, v)]) () in + + (* Check derived has existing data *) + Printf.printf "Derived has %d entries (expected 2)\n" (length derived); + assert (length derived = 2); + assert (get derived 10 = Some "a"); + assert (get derived 20 = Some "b"); + + Printf.printf "PASSED\n\n" + +module StringMap = Map.Make (String) + +let test_file_collection () = + Printf.printf "=== Test: ReactiveFileCollection + composition ===\n"; + + (* Create temp files with words *) + let temp_dir = Filename.get_temp_dir_name () in + let file_a = Filename.concat temp_dir "reactive_test_a.txt" in + let file_b = Filename.concat temp_dir "reactive_test_b.txt" in + + (* file_a: hello(2), world(1) *) + write_lines file_a ["hello world"; "hello"]; + (* file_b: hello(1), foo(1) *) + write_lines file_b ["hello foo"]; + + (* Create file collection: file -> word count map *) + let files = + ReactiveFileCollection.create ~read_file:read_lines ~process:(fun lines -> + (* Count words within this file *) + let counts = ref StringMap.empty in + lines + |> List.iter (fun line -> + String.split_on_char ' ' line + |> List.iter (fun word -> + let c = + StringMap.find_opt word !counts + |> Option.value ~default:0 + in + counts := StringMap.add word (c + 1) !counts)); + !counts) + in + + (* First flatMap: aggregate word counts across files with merge *) + let word_counts = + Reactive.flatMap + (ReactiveFileCollection.to_collection files) + ~f:(fun _path counts -> StringMap.bindings counts) + (* Each file contributes its word counts *) + ~merge:( + ) (* Sum counts from multiple files *) + () + in + + (* Second flatMap: filter to words with count >= 2 *) + let frequent_words = + Reactive.flatMap word_counts + ~f:(fun word count -> if count >= 2 then [(word, count)] else []) + () + in + + (* Process files *) + ReactiveFileCollection.process_files files [file_a; file_b]; + + Printf.printf "Word counts:\n"; + word_counts + |> Reactive.iter (fun word count -> Printf.printf " %s: %d\n" word count); + + Printf.printf "Frequent words (count >= 2):\n"; + frequent_words + |> Reactive.iter (fun word count -> Printf.printf " %s: %d\n" word count); + + (* Verify: hello=3 (2 from a + 1 from b), world=1, foo=1 *) + assert (Reactive.get word_counts "hello" = Some 3); + assert (Reactive.get word_counts "world" = Some 1); + assert (Reactive.get word_counts "foo" = Some 1); + assert (Reactive.length word_counts = 3); + + (* Verify frequent: only "hello" with count 3 *) + assert (Reactive.length frequent_words = 1); + assert (Reactive.get frequent_words "hello" = Some 3); + + (* Modify file_a: now hello(1), world(2) *) + Printf.printf "\nModifying file_a...\n"; + write_lines file_a ["world world"; "hello"]; + ReactiveFileCollection.process_files files [file_a]; + + Printf.printf "Word counts after modification:\n"; + Reactive.iter + (fun word count -> Printf.printf " %s: %d\n" word count) + word_counts; + + Printf.printf "Frequent words after modification:\n"; + Reactive.iter + (fun word count -> Printf.printf " %s: %d\n" word count) + frequent_words; + + (* Verify: hello=2 (1 from a + 1 from b), world=2, foo=1 *) + assert (Reactive.get word_counts "hello" = Some 2); + assert (Reactive.get word_counts "world" = Some 2); + assert (Reactive.get word_counts "foo" = Some 1); + + (* Verify frequent: hello=2, world=2 *) + assert (Reactive.length frequent_words = 2); + assert (Reactive.get frequent_words "hello" = Some 2); + assert (Reactive.get frequent_words "world" = Some 2); + + (* Cleanup *) + Sys.remove file_a; + Sys.remove file_b; + + Printf.printf "PASSED\n\n" + +let () = + Printf.printf "\n====== Reactive Collection Tests ======\n\n"; + test_flatmap_basic (); + test_flatmap_with_merge (); + test_composition (); + test_flatmap_on_existing_data (); + test_file_collection (); + Printf.printf "All tests passed!\n" diff --git a/analysis/reactive/test/dune b/analysis/reactive/test/dune new file mode 100644 index 0000000000..22584c8578 --- /dev/null +++ b/analysis/reactive/test/dune @@ -0,0 +1,3 @@ +(executable + (name ReactiveTest) + (libraries reactive)) diff --git a/analysis/reanalyze/src/Cli.ml b/analysis/reanalyze/src/Cli.ml index edff3e3e2b..d8ce55db9d 100644 --- a/analysis/reanalyze/src/Cli.ml +++ b/analysis/reanalyze/src/Cli.ml @@ -28,9 +28,6 @@ let parallel = ref 0 (* timing: report internal timing of analysis phases *) let timing = ref false -(* use mmap cache for CMT files *) -let cmtCache = ref false - (* use reactive/incremental analysis (caches processed file_data) *) let reactive = ref false diff --git a/analysis/reanalyze/src/CmtCache.ml b/analysis/reanalyze/src/CmtCache.ml deleted file mode 100644 index aa838ed38d..0000000000 --- a/analysis/reanalyze/src/CmtCache.ml +++ /dev/null @@ -1,70 +0,0 @@ -(** CMT file cache with automatic invalidation based on file metadata. - - This module provides cached reading of CMT files with automatic - invalidation when files change on disk. Uses Unix.stat to detect - changes via mtime, size, and inode. *) - -type file_id = { - mtime: float; (** Modification time *) - size: int; (** File size in bytes *) - ino: int; (** Inode number *) -} -(** File identity for cache invalidation *) - -(** Get file identity from path *) -let get_file_id path : file_id = - let st = Unix.stat path in - {mtime = st.Unix.st_mtime; size = st.Unix.st_size; ino = st.Unix.st_ino} - -(** Check if file has changed *) -let file_changed ~old_id ~new_id = - old_id.mtime <> new_id.mtime - || old_id.size <> new_id.size || old_id.ino <> new_id.ino - -type cache_entry = {file_id: file_id; cmt_infos: Cmt_format.cmt_infos} -(** Cache entry: file identity + cached CMT data *) - -(** The cache: path -> cache_entry *) -let cache : (string, cache_entry) Hashtbl.t = Hashtbl.create 256 - -(** Read a CMT file, using the cache for efficiency. - Re-reads from disk if file has changed. *) -let read_cmt path : Cmt_format.cmt_infos = - let new_id = get_file_id path in - match Hashtbl.find_opt cache path with - | Some entry when not (file_changed ~old_id:entry.file_id ~new_id) -> - entry.cmt_infos - | _ -> - let cmt_infos = Cmt_format.read_cmt path in - Hashtbl.replace cache path {file_id = new_id; cmt_infos}; - cmt_infos - -(** Read a CMT file only if it changed since the last access. - Returns [Some cmt_infos] if the file changed (or first access), - [None] if the file is unchanged. - - This is the key function for incremental analysis - unchanged - files return [None] immediately without any file reading. *) -let read_cmt_if_changed path : Cmt_format.cmt_infos option = - let new_id = get_file_id path in - match Hashtbl.find_opt cache path with - | Some entry when not (file_changed ~old_id:entry.file_id ~new_id) -> - None (* File unchanged *) - | _ -> - let cmt_infos = Cmt_format.read_cmt path in - Hashtbl.replace cache path {file_id = new_id; cmt_infos}; - Some cmt_infos - -(** Clear the CMT cache, freeing all cached data. *) -let clear () = Hashtbl.clear cache - -(** Invalidate a specific path in the cache. - The next read will re-load the file from disk. *) -let invalidate path = Hashtbl.remove cache path - -type stats = {entry_count: int; mapped_bytes: int} -(** Cache statistics *) - -(** Get cache statistics. - Note: mapped_bytes is approximate (we don't track actual memory usage). *) -let stats () : stats = {entry_count = Hashtbl.length cache; mapped_bytes = 0} diff --git a/analysis/reanalyze/src/CmtCache.mli b/analysis/reanalyze/src/CmtCache.mli deleted file mode 100644 index ef7d1b2221..0000000000 --- a/analysis/reanalyze/src/CmtCache.mli +++ /dev/null @@ -1,27 +0,0 @@ -(** CMT file cache with automatic invalidation based on file metadata. - - This module provides cached reading of CMT files with automatic - invalidation when files change on disk. Uses Unix.stat to detect - changes via mtime, size, and inode. *) - -val read_cmt : string -> Cmt_format.cmt_infos -(** Read a CMT file, using the cache for efficiency. - Re-reads from disk if file has changed. *) - -val read_cmt_if_changed : string -> Cmt_format.cmt_infos option -(** Read a CMT file only if it changed since the last access. - Returns [Some cmt_infos] if the file changed (or first access), - [None] if the file is unchanged. *) - -val clear : unit -> unit -(** Clear the CMT cache, freeing all cached data. *) - -val invalidate : string -> unit -(** Invalidate a specific path in the cache. *) - -type stats = {entry_count: int; mapped_bytes: int} -(** Cache statistics *) - -val stats : unit -> stats -(** Get cache statistics. - Note: mapped_bytes is always 0 (we don't track actual memory usage). *) diff --git a/analysis/reanalyze/src/ReactiveAnalysis.ml b/analysis/reanalyze/src/ReactiveAnalysis.ml index da2aec5623..db54833dd4 100644 --- a/analysis/reanalyze/src/ReactiveAnalysis.ml +++ b/analysis/reanalyze/src/ReactiveAnalysis.ml @@ -1,5 +1,5 @@ (** Reactive analysis service using ReactiveFileCollection. - + This module provides incremental analysis that only re-processes files that have changed, using ReactiveFileCollection for efficient delta-based updates. *) @@ -16,7 +16,7 @@ type all_files_result = { } (** Result of processing all CMT files *) -type t = cmt_file_result option ReactiveFileCollection.t +type t = (Cmt_format.cmt_infos, cmt_file_result option) ReactiveFileCollection.t (** The reactive collection type *) (** Process cmt_infos into a file result *) @@ -69,12 +69,12 @@ let process_cmt_infos ~config cmt_infos : cmt_file_result option = (** Create a new reactive collection *) let create ~config : t = - ReactiveFileCollection.create ~process:(process_cmt_infos ~config) + ReactiveFileCollection.create ~read_file:Cmt_format.read_cmt + ~process:(process_cmt_infos ~config) (** Process all files incrementally using ReactiveFileCollection. - First run processes all files. Subsequent runs only process changed files - (detected via CmtCache's file change tracking). *) -let process_files ~(collection : t) ~config cmtFilePaths : all_files_result = + First run processes all files. Subsequent runs only process changed files. *) +let process_files ~(collection : t) ~config:_ cmtFilePaths : all_files_result = Timing.time_phase `FileLoading (fun () -> let processed = ref 0 in let from_cache = ref 0 in @@ -85,16 +85,11 @@ let process_files ~(collection : t) ~config cmtFilePaths : all_files_result = let was_in_collection = ReactiveFileCollection.mem collection cmtFilePath in - (* Check if file changed using CmtCache *) - match CmtCache.read_cmt_if_changed cmtFilePath with - | None -> - (* File unchanged - already in collection *) - if was_in_collection then incr from_cache - | Some cmt_infos -> - (* File changed or new - process and update *) - let result = process_cmt_infos ~config cmt_infos in - ReactiveFileCollection.set collection cmtFilePath result; - incr processed); + let changed = + ReactiveFileCollection.process_if_changed collection cmtFilePath + in + if changed then incr processed + else if was_in_collection then incr from_cache); if !Cli.timing then Printf.eprintf "Reactive: %d files processed, %d from cache\n%!" @@ -122,8 +117,5 @@ let process_files ~(collection : t) ~config cmtFilePaths : all_files_result = exception_results = List.rev !exception_results; }) -(** Get collection statistics *) -let stats (collection : t) = - let file_count = ReactiveFileCollection.length collection in - let cmt_stats = CmtCache.stats () in - (file_count, cmt_stats) +(** Get collection length *) +let length (collection : t) = ReactiveFileCollection.length collection diff --git a/analysis/reanalyze/src/ReactiveFileCollection.ml b/analysis/reanalyze/src/ReactiveFileCollection.ml deleted file mode 100644 index 61c6b54520..0000000000 --- a/analysis/reanalyze/src/ReactiveFileCollection.ml +++ /dev/null @@ -1,52 +0,0 @@ -(** Reactive File Collection - Implementation - - Uses CmtCache for efficient file change detection via Unix.stat. *) - -type event = Added of string | Removed of string | Modified of string - -type 'v t = {data: (string, 'v) Hashtbl.t; process: Cmt_format.cmt_infos -> 'v} - -let create ~process = {data = Hashtbl.create 256; process} - -let add t path = - let cmt_infos = CmtCache.read_cmt path in - let value = t.process cmt_infos in - Hashtbl.replace t.data path value - -let remove t path = - Hashtbl.remove t.data path; - CmtCache.invalidate path - -let update t path = - (* Re-read the file and update the cache *) - add t path - -let set t path value = Hashtbl.replace t.data path value - -let apply t events = - List.iter - (function - | Added path -> add t path - | Removed path -> remove t path - | Modified path -> update t path) - events - -let get t path = Hashtbl.find_opt t.data path - -let find t path = Hashtbl.find t.data path - -let mem t path = Hashtbl.mem t.data path - -let length t = Hashtbl.length t.data - -let is_empty t = length t = 0 - -let iter f t = Hashtbl.iter f t.data - -let fold f t init = Hashtbl.fold f t.data init - -let to_list t = fold (fun k v acc -> (k, v) :: acc) t [] - -let paths t = fold (fun k _ acc -> k :: acc) t [] - -let values t = fold (fun _ v acc -> v :: acc) t [] diff --git a/analysis/reanalyze/src/ReactiveFileCollection.mli b/analysis/reanalyze/src/ReactiveFileCollection.mli deleted file mode 100644 index f5f01c4283..0000000000 --- a/analysis/reanalyze/src/ReactiveFileCollection.mli +++ /dev/null @@ -1,104 +0,0 @@ -(** Reactive File Collection - - A collection that maps file paths to processed values, with efficient - delta-based updates. Designed for use with file watchers. - - {2 Usage Example} - - {[ - (* Create collection with processing function *) - let coll = ReactiveFileCollection.create - ~process:(fun (data : Cmt_format.cmt_infos) -> - extract_types data - ) - - (* Initial load *) - List.iter (ReactiveFileCollection.add coll) (glob "*.cmt") - - (* On file watcher events *) - match event with - | Created path -> ReactiveFileCollection.add coll path - | Deleted path -> ReactiveFileCollection.remove coll path - | Modified path -> ReactiveFileCollection.update coll path - - (* Access the collection *) - ReactiveFileCollection.iter (fun path value -> ...) coll - ]} - - {2 Thread Safety} - - Not thread-safe. Use external synchronization if accessed from - multiple threads/domains. *) - -type 'v t -(** The type of a reactive file collection with values of type ['v]. *) - -(** Events for batch updates. *) -type event = - | Added of string (** File was created *) - | Removed of string (** File was deleted *) - | Modified of string (** File was modified *) - -(** {1 Creation} *) - -val create : process:(Cmt_format.cmt_infos -> 'v) -> 'v t -(** [create ~process] creates an empty collection. - - [process] is called to transform CMT file contents into values. *) - -(** {1 Delta Operations} *) - -val add : 'v t -> string -> unit -(** [add t path] adds a file to the collection. - Loads the file and processes immediately. *) - -val remove : 'v t -> string -> unit -(** [remove t path] removes a file from the collection. - No-op if path is not in collection. *) - -val update : 'v t -> string -> unit -(** [update t path] reloads a modified file. - Equivalent to remove + add, but more efficient. *) - -val set : 'v t -> string -> 'v -> unit -(** [set t path value] sets the value for [path] directly. - Used when you have already processed the file externally. *) - -val apply : 'v t -> event list -> unit -(** [apply t events] applies multiple events. - More efficient than individual operations for batches. *) - -(** {1 Access} *) - -val get : 'v t -> string -> 'v option -(** [get t path] returns the value for [path], or [None] if not present. *) - -val find : 'v t -> string -> 'v -(** [find t path] returns the value for [path]. - @raise Not_found if path is not in collection *) - -val mem : 'v t -> string -> bool -(** [mem t path] returns [true] if [path] is in the collection. *) - -val length : 'v t -> int -(** [length t] returns the number of files in the collection. *) - -val is_empty : 'v t -> bool -(** [is_empty t] returns [true] if the collection is empty. *) - -(** {1 Iteration} *) - -val iter : (string -> 'v -> unit) -> 'v t -> unit -(** [iter f t] applies [f] to each (path, value) pair. *) - -val fold : (string -> 'v -> 'acc -> 'acc) -> 'v t -> 'acc -> 'acc -(** [fold f t init] folds [f] over all (path, value) pairs. *) - -val to_list : 'v t -> (string * 'v) list -(** [to_list t] returns all (path, value) pairs as a list. *) - -val paths : 'v t -> string list -(** [paths t] returns all paths in the collection. *) - -val values : 'v t -> 'v list -(** [values t] returns all values in the collection. *) diff --git a/analysis/reanalyze/src/Reanalyze.ml b/analysis/reanalyze/src/Reanalyze.ml index e1f9f2871a..98f3b51e7b 100644 --- a/analysis/reanalyze/src/Reanalyze.ml +++ b/analysis/reanalyze/src/Reanalyze.ml @@ -9,10 +9,7 @@ type cmt_file_result = { (** Process a cmt file and return its results. Conceptually: map over files, then merge results. *) let loadCmtFile ~config cmtFilePath : cmt_file_result option = - let cmt_infos = - if !Cli.cmtCache then CmtCache.read_cmt cmtFilePath - else Cmt_format.read_cmt cmtFilePath - in + let cmt_infos = Cmt_format.read_cmt cmtFilePath in let excludePath sourceFile = config.DceConfig.cli.exclude_paths |> List.exists (fun prefix_ -> @@ -489,9 +486,6 @@ let cli () = "n Process files in parallel using n domains (0 = sequential, default; \ -1 = auto-detect cores)" ); ("-timing", Set Cli.timing, "Report internal timing of analysis phases"); - ( "-cmt-cache", - Set Cli.cmtCache, - "Use mmap cache for CMT files (faster for repeated analysis)" ); ( "-reactive", Set Cli.reactive, "Use reactive analysis (caches processed file_data, skips unchanged \ diff --git a/analysis/reanalyze/src/Timing.ml b/analysis/reanalyze/src/Timing.ml index ef875668db..2341bd9109 100644 --- a/analysis/reanalyze/src/Timing.ml +++ b/analysis/reanalyze/src/Timing.ml @@ -62,7 +62,8 @@ let report () = (100.0 *. cmt_total /. total); (* Only show parallel-specific timing when used *) if times.result_collection > 0.0 then - Printf.eprintf " - Parallel merge: %.3fms (aggregate across domains)\n" + Printf.eprintf + " - Parallel merge: %.3fms (aggregate across domains)\n" (1000.0 *. times.result_collection); Printf.eprintf " Analysis: %.3fs (%.1f%%)\n" analysis_total (100.0 *. analysis_total /. total); diff --git a/analysis/reanalyze/src/dune b/analysis/reanalyze/src/dune index e8b736446f..8431b0d52d 100644 --- a/analysis/reanalyze/src/dune +++ b/analysis/reanalyze/src/dune @@ -2,4 +2,4 @@ (name reanalyze) (flags (-w "+6+26+27+32+33+39")) - (libraries jsonlib ext ml str unix)) + (libraries reactive jsonlib ext ml str unix)) From b2ff1c10226e0527a2c839bb235549f19fe1aa0f Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 06:48:33 +0100 Subject: [PATCH 06/45] Integrate ReactiveMerge into runAnalysis - Add ReactiveMerge module for reactive merge of per-file DCE data - Add extraction functions (builder_to_list, create_*) to data modules - Expose types needed for reactive merge (CrossFileItems.t fields, etc.) - ReactiveAnalysis: add iter_file_data, collect_exception_results - runAnalysis: use ReactiveMerge for decls/annotations/cross_file when reactive mode enabled Note: refs and file_deps still use O(n) iteration because they need post-processing (type-label deps, exception refs). Next step is to make these reactive via indexed lookups. --- analysis/reanalyze/src/CrossFileItems.ml | 9 ++ analysis/reanalyze/src/CrossFileItems.mli | 24 ++- analysis/reanalyze/src/Declarations.ml | 7 + analysis/reanalyze/src/Declarations.mli | 8 + analysis/reanalyze/src/FileAnnotations.ml | 8 + analysis/reanalyze/src/FileAnnotations.mli | 11 ++ analysis/reanalyze/src/FileDeps.ml | 10 ++ analysis/reanalyze/src/FileDeps.mli | 16 ++ analysis/reanalyze/src/ReactiveAnalysis.ml | 33 ++++ analysis/reanalyze/src/ReactiveMerge.ml | 169 +++++++++++++++++++++ analysis/reanalyze/src/ReactiveMerge.mli | 60 ++++++++ analysis/reanalyze/src/Reanalyze.ml | 78 +++++++--- analysis/reanalyze/src/References.ml | 12 ++ analysis/reanalyze/src/References.mli | 11 ++ 14 files changed, 431 insertions(+), 25 deletions(-) create mode 100644 analysis/reanalyze/src/ReactiveMerge.ml create mode 100644 analysis/reanalyze/src/ReactiveMerge.mli diff --git a/analysis/reanalyze/src/CrossFileItems.ml b/analysis/reanalyze/src/CrossFileItems.ml index cf038fdb8f..8b72d84120 100644 --- a/analysis/reanalyze/src/CrossFileItems.ml +++ b/analysis/reanalyze/src/CrossFileItems.ml @@ -58,6 +58,15 @@ let merge_all (builders : builder list) : t = let function_refs = builders |> List.concat_map (fun b -> b.function_refs) in {exception_refs; optional_arg_calls; function_refs} +(** {2 Builder extraction for reactive merge} *) + +let builder_to_t (builder : builder) : t = + { + exception_refs = builder.exception_refs; + optional_arg_calls = builder.optional_arg_calls; + function_refs = builder.function_refs; + } + (** {2 Processing API} *) let process_exception_refs (t : t) ~refs ~file_deps ~find_exception ~config = diff --git a/analysis/reanalyze/src/CrossFileItems.mli b/analysis/reanalyze/src/CrossFileItems.mli index 199089baaf..f7517d9974 100644 --- a/analysis/reanalyze/src/CrossFileItems.mli +++ b/analysis/reanalyze/src/CrossFileItems.mli @@ -5,9 +5,26 @@ - [builder] - mutable, for AST processing - [t] - immutable, for processing after merge *) +(** {2 Item types} *) + +type exception_ref = {exception_path: DcePath.t; loc_from: Location.t} + +type optional_arg_call = { + pos_from: Lexing.position; + pos_to: Lexing.position; + arg_names: string list; + arg_names_maybe: string list; +} + +type function_ref = {pos_from: Lexing.position; pos_to: Lexing.position} + (** {2 Types} *) -type t +type t = { + exception_refs: exception_ref list; + optional_arg_calls: optional_arg_call list; + function_refs: function_ref list; +} (** Immutable cross-file items - for processing after merge *) type builder @@ -39,6 +56,11 @@ val add_function_reference : val merge_all : builder list -> t (** Merge all builders into one immutable result. Order doesn't matter. *) +(** {2 Builder extraction for reactive merge} *) + +val builder_to_t : builder -> t +(** Convert builder to t for reactive merge *) + (** {2 Processing API - for after merge} *) val process_exception_refs : diff --git a/analysis/reanalyze/src/Declarations.ml b/analysis/reanalyze/src/Declarations.ml index cf49afdd5a..0bcaa36b16 100644 --- a/analysis/reanalyze/src/Declarations.ml +++ b/analysis/reanalyze/src/Declarations.ml @@ -28,6 +28,13 @@ let merge_all (builders : builder list) : t = PosHash.iter (fun pos decl -> PosHash.replace result pos decl) builder); result +(* ===== Builder extraction for reactive merge ===== *) + +let builder_to_list (builder : builder) : (Lexing.position * Decl.t) list = + PosHash.fold (fun pos decl acc -> (pos, decl) :: acc) builder [] + +let create_from_hashtbl (h : Decl.t PosHash.t) : t = h + (* ===== Read-only API ===== *) let find_opt (t : t) pos = PosHash.find_opt t pos diff --git a/analysis/reanalyze/src/Declarations.mli b/analysis/reanalyze/src/Declarations.mli index 31bbb7934a..1d5180dc53 100644 --- a/analysis/reanalyze/src/Declarations.mli +++ b/analysis/reanalyze/src/Declarations.mli @@ -25,6 +25,14 @@ val replace_builder : builder -> Lexing.position -> Decl.t -> unit val merge_all : builder list -> t (** Merge all builders into one immutable result. Order doesn't matter. *) +(** {2 Builder extraction for reactive merge} *) + +val builder_to_list : builder -> (Lexing.position * Decl.t) list +(** Extract all declarations as a list for reactive merge *) + +val create_from_hashtbl : Decl.t PosHash.t -> t +(** Create from hashtable for reactive merge *) + (** {2 Read-only API for t - for solver} *) val find_opt : t -> Lexing.position -> Decl.t option diff --git a/analysis/reanalyze/src/FileAnnotations.ml b/analysis/reanalyze/src/FileAnnotations.ml index c8344a201f..046805b564 100644 --- a/analysis/reanalyze/src/FileAnnotations.ml +++ b/analysis/reanalyze/src/FileAnnotations.ml @@ -32,6 +32,14 @@ let merge_all (builders : builder list) : t = builder); result +(* ===== Builder extraction for reactive merge ===== *) + +let builder_to_list (builder : builder) : (Lexing.position * annotated_as) list + = + PosHash.fold (fun pos value acc -> (pos, value) :: acc) builder [] + +let create_from_hashtbl (h : annotated_as PosHash.t) : t = h + (* ===== Read-only API ===== *) let is_annotated_dead (state : t) pos = PosHash.find_opt state pos = Some Dead diff --git a/analysis/reanalyze/src/FileAnnotations.mli b/analysis/reanalyze/src/FileAnnotations.mli index dd3df7d861..756264813e 100644 --- a/analysis/reanalyze/src/FileAnnotations.mli +++ b/analysis/reanalyze/src/FileAnnotations.mli @@ -9,6 +9,9 @@ (** {2 Types} *) +type annotated_as = GenType | Dead | Live +(** Annotation type *) + type t (** Immutable annotations - for solver (read-only) *) @@ -25,6 +28,14 @@ val annotate_live : builder -> Lexing.position -> unit val merge_all : builder list -> t (** Merge all builders into one immutable result. Order doesn't matter. *) +(** {2 Builder extraction for reactive merge} *) + +val builder_to_list : builder -> (Lexing.position * annotated_as) list +(** Extract all annotations as a list for reactive merge *) + +val create_from_hashtbl : annotated_as PosHash.t -> t +(** Create from hashtable for reactive merge *) + (** {2 Read-only API for t - for solver} *) val is_annotated_dead : t -> Lexing.position -> bool diff --git a/analysis/reanalyze/src/FileDeps.ml b/analysis/reanalyze/src/FileDeps.ml index ed34e7c4c6..7c0440b687 100644 --- a/analysis/reanalyze/src/FileDeps.ml +++ b/analysis/reanalyze/src/FileDeps.ml @@ -64,6 +64,16 @@ let merge_all (builders : builder list) : t = |> List.iter (fun b -> merge_into_builder ~from:b ~into:merged_builder); freeze_builder merged_builder +(** {2 Builder extraction for reactive merge} *) + +let builder_files (builder : builder) : FileSet.t = builder.files + +let builder_deps_to_list (builder : builder) : (string * FileSet.t) list = + FileHash.fold (fun from_file to_files acc -> (from_file, to_files) :: acc) + builder.deps [] + +let create ~files ~deps : t = {files; deps} + (** {2 Read-only API} *) let get_files (t : t) = t.files diff --git a/analysis/reanalyze/src/FileDeps.mli b/analysis/reanalyze/src/FileDeps.mli index 2975e5ceca..2de875017e 100644 --- a/analysis/reanalyze/src/FileDeps.mli +++ b/analysis/reanalyze/src/FileDeps.mli @@ -35,6 +35,22 @@ val freeze_builder : builder -> t val merge_all : builder list -> t (** Merge all builders into one immutable result. Order doesn't matter. *) +(** {2 Builder extraction for reactive merge} *) + +val builder_files : builder -> FileSet.t +(** Get files set from builder *) + +val builder_deps_to_list : builder -> (string * FileSet.t) list +(** Extract all deps as a list for reactive merge *) + +(** {2 Internal types (for ReactiveMerge)} *) + +module FileHash : Hashtbl.S with type key = string +(** File-keyed hashtable *) + +val create : files:FileSet.t -> deps:FileSet.t FileHash.t -> t +(** Create a FileDeps.t from files set and deps hashtable *) + (** {2 Read-only API for t - for analysis} *) val get_files : t -> FileSet.t diff --git a/analysis/reanalyze/src/ReactiveAnalysis.ml b/analysis/reanalyze/src/ReactiveAnalysis.ml index db54833dd4..48fe11b197 100644 --- a/analysis/reanalyze/src/ReactiveAnalysis.ml +++ b/analysis/reanalyze/src/ReactiveAnalysis.ml @@ -119,3 +119,36 @@ let process_files ~(collection : t) ~config:_ cmtFilePaths : all_files_result = (** Get collection length *) let length (collection : t) = ReactiveFileCollection.length collection + +(** Get the underlying reactive collection for composition. + Returns (path, file_data option) suitable for ReactiveMerge. *) +let to_file_data_collection (collection : t) : + (string, DceFileProcessing.file_data option) Reactive.t = + Reactive.flatMap + (ReactiveFileCollection.to_collection collection) + ~f:(fun path result_opt -> + match result_opt with + | Some {dce_data = Some data; _} -> [(path, Some data)] + | _ -> [(path, None)]) + () + +(** Iterate over all file_data in the collection *) +let iter_file_data (collection : t) (f : DceFileProcessing.file_data -> unit) : + unit = + ReactiveFileCollection.iter + (fun _path result_opt -> + match result_opt with + | Some {dce_data = Some data; _} -> f data + | _ -> ()) + collection + +(** Collect all exception results from the collection *) +let collect_exception_results (collection : t) : Exception.file_result list = + let results = ref [] in + ReactiveFileCollection.iter + (fun _path result_opt -> + match result_opt with + | Some {exception_data = Some data; _} -> results := data :: !results + | _ -> ()) + collection; + !results diff --git a/analysis/reanalyze/src/ReactiveMerge.ml b/analysis/reanalyze/src/ReactiveMerge.ml new file mode 100644 index 0000000000..5bd111d09a --- /dev/null +++ b/analysis/reanalyze/src/ReactiveMerge.ml @@ -0,0 +1,169 @@ +(** Reactive merge of per-file DCE data into global collections. + + Given a reactive collection of (path, file_data), this creates derived + reactive collections that automatically update when source files change. *) + +(** {1 Types} *) + +type t = { + decls: (Lexing.position, Decl.t) Reactive.t; + annotations: (Lexing.position, FileAnnotations.annotated_as) Reactive.t; + value_refs: (Lexing.position, PosSet.t) Reactive.t; + type_refs: (Lexing.position, PosSet.t) Reactive.t; + cross_file_items: (string, CrossFileItems.t) Reactive.t; + file_deps_map: (string, FileSet.t) Reactive.t; + files: (string, unit) Reactive.t; +} +(** All derived reactive collections from per-file data *) + +(** {1 Creation} *) + +let create (source : (string, DceFileProcessing.file_data option) Reactive.t) : + t = + (* Declarations: (pos, Decl.t) with last-write-wins *) + let decls = + Reactive.flatMap source + ~f:(fun _path file_data_opt -> + match file_data_opt with + | None -> [] + | Some file_data -> + Declarations.builder_to_list file_data.DceFileProcessing.decls) + () + in + + (* Annotations: (pos, annotated_as) with last-write-wins *) + let annotations = + Reactive.flatMap source + ~f:(fun _path file_data_opt -> + match file_data_opt with + | None -> [] + | Some file_data -> + FileAnnotations.builder_to_list file_data.DceFileProcessing.annotations) + () + in + + (* Value refs: (posTo, PosSet) with PosSet.union merge *) + let value_refs = + Reactive.flatMap source + ~f:(fun _path file_data_opt -> + match file_data_opt with + | None -> [] + | Some file_data -> + References.builder_value_refs_to_list file_data.DceFileProcessing.refs) + ~merge:PosSet.union () + in + + (* Type refs: (posTo, PosSet) with PosSet.union merge *) + let type_refs = + Reactive.flatMap source + ~f:(fun _path file_data_opt -> + match file_data_opt with + | None -> [] + | Some file_data -> + References.builder_type_refs_to_list file_data.DceFileProcessing.refs) + ~merge:PosSet.union () + in + + (* Cross-file items: (path, CrossFileItems.t) with merge by concatenation *) + let cross_file_items = + Reactive.flatMap source + ~f:(fun path file_data_opt -> + match file_data_opt with + | None -> [] + | Some file_data -> + let items = + CrossFileItems.builder_to_t file_data.DceFileProcessing.cross_file + in + [(path, items)]) + ~merge:(fun a b -> + CrossFileItems. + { + exception_refs = a.exception_refs @ b.exception_refs; + optional_arg_calls = a.optional_arg_calls @ b.optional_arg_calls; + function_refs = a.function_refs @ b.function_refs; + }) + () + in + + (* File deps map: (from_file, FileSet of to_files) with FileSet.union merge *) + let file_deps_map = + Reactive.flatMap source + ~f:(fun _path file_data_opt -> + match file_data_opt with + | None -> [] + | Some file_data -> + FileDeps.builder_deps_to_list file_data.DceFileProcessing.file_deps) + ~merge:FileSet.union () + in + + (* Files set: (path, ()) - just track which files exist *) + let files = + Reactive.flatMap source + ~f:(fun path file_data_opt -> + match file_data_opt with + | None -> [] + | Some file_data -> + (* Include the file and all files it references *) + let file_set = FileDeps.builder_files file_data.DceFileProcessing.file_deps in + let entries = FileSet.fold (fun f acc -> (f, ()) :: acc) file_set [] in + (path, ()) :: entries) + () + in + + {decls; annotations; value_refs; type_refs; cross_file_items; file_deps_map; files} + +(** {1 Conversion to solver-ready format} *) + +(** Convert reactive decls to Declarations.t for solver *) +let freeze_decls (t : t) : Declarations.t = + let result = PosHash.create 256 in + Reactive.iter (fun pos decl -> PosHash.replace result pos decl) t.decls; + Declarations.create_from_hashtbl result + +(** Convert reactive annotations to FileAnnotations.t for solver *) +let freeze_annotations (t : t) : FileAnnotations.t = + let result = PosHash.create 256 in + Reactive.iter (fun pos ann -> PosHash.replace result pos ann) t.annotations; + FileAnnotations.create_from_hashtbl result + +(** Convert reactive refs to References.t for solver *) +let freeze_refs (t : t) : References.t = + let value_refs = PosHash.create 256 in + let type_refs = PosHash.create 256 in + Reactive.iter + (fun pos refs -> PosHash.replace value_refs pos refs) + t.value_refs; + Reactive.iter (fun pos refs -> PosHash.replace type_refs pos refs) t.type_refs; + References.create ~value_refs ~type_refs + +(** Collect all cross-file items *) +let collect_cross_file_items (t : t) : CrossFileItems.t = + let exception_refs = ref [] in + let optional_arg_calls = ref [] in + let function_refs = ref [] in + Reactive.iter + (fun _path items -> + exception_refs := items.CrossFileItems.exception_refs @ !exception_refs; + optional_arg_calls := + items.CrossFileItems.optional_arg_calls @ !optional_arg_calls; + function_refs := items.CrossFileItems.function_refs @ !function_refs) + t.cross_file_items; + { + CrossFileItems.exception_refs = !exception_refs; + optional_arg_calls = !optional_arg_calls; + function_refs = !function_refs; + } + +(** Convert reactive file deps to FileDeps.t for solver *) +let freeze_file_deps (t : t) : FileDeps.t = + let files = + let result = ref FileSet.empty in + Reactive.iter (fun path () -> result := FileSet.add path !result) t.files; + !result + in + let deps = FileDeps.FileHash.create 256 in + Reactive.iter + (fun from_file to_files -> FileDeps.FileHash.replace deps from_file to_files) + t.file_deps_map; + FileDeps.create ~files ~deps + diff --git a/analysis/reanalyze/src/ReactiveMerge.mli b/analysis/reanalyze/src/ReactiveMerge.mli new file mode 100644 index 0000000000..03dd06bb44 --- /dev/null +++ b/analysis/reanalyze/src/ReactiveMerge.mli @@ -0,0 +1,60 @@ +(** Reactive merge of per-file DCE data into global collections. + + Given a reactive collection of (path, file_data), this creates derived + reactive collections that automatically update when source files change. + + {2 Example} + + {[ + (* Create reactive file collection *) + let files = ReactiveAnalysis.create ~config in + + (* Process files *) + ReactiveAnalysis.process_files ~collection:files ~config paths; + + (* Create reactive merge from processed file data *) + let merged = ReactiveMerge.create (ReactiveAnalysis.to_collection files) in + + (* Access derived collections *) + Reactive.iter (fun pos decl -> ...) merged.decls; + + (* Or freeze for solver *) + let decls = ReactiveMerge.freeze_decls merged in + ]} *) + +(** {1 Types} *) + +type t = { + decls: (Lexing.position, Decl.t) Reactive.t; + annotations: (Lexing.position, FileAnnotations.annotated_as) Reactive.t; + value_refs: (Lexing.position, PosSet.t) Reactive.t; + type_refs: (Lexing.position, PosSet.t) Reactive.t; + cross_file_items: (string, CrossFileItems.t) Reactive.t; + file_deps_map: (string, FileSet.t) Reactive.t; + files: (string, unit) Reactive.t; +} +(** All derived reactive collections from per-file data *) + +(** {1 Creation} *) + +val create : (string, DceFileProcessing.file_data option) Reactive.t -> t +(** Create reactive merge from a file data collection. + All derived collections update automatically when source changes. *) + +(** {1 Conversion to solver-ready format} *) + +val freeze_decls : t -> Declarations.t +(** Convert reactive decls to Declarations.t for solver *) + +val freeze_annotations : t -> FileAnnotations.t +(** Convert reactive annotations to FileAnnotations.t for solver *) + +val freeze_refs : t -> References.t +(** Convert reactive refs to References.t for solver *) + +val collect_cross_file_items : t -> CrossFileItems.t +(** Collect all cross-file items *) + +val freeze_file_deps : t -> FileDeps.t +(** Convert reactive file deps to FileDeps.t for solver *) + diff --git a/analysis/reanalyze/src/Reanalyze.ml b/analysis/reanalyze/src/Reanalyze.ml index 98f3b51e7b..0b0359c050 100644 --- a/analysis/reanalyze/src/Reanalyze.ml +++ b/analysis/reanalyze/src/Reanalyze.ml @@ -244,11 +244,17 @@ let shuffle_list lst = done; Array.to_list arr -let runAnalysis ~dce_config ~cmtRoot ~reactive_collection = +let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge = (* Map: process each file -> list of file_data *) let {dce_data_list; exception_results} = processCmtFiles ~config:dce_config ~cmtRoot ~reactive_collection in + (* Get exception results from reactive collection if available *) + let exception_results = + match reactive_collection with + | Some collection -> ReactiveAnalysis.collect_exception_results collection + | None -> exception_results + in (* Optionally shuffle for order-independence testing *) let dce_data_list = if !Cli.testShuffle then ( @@ -264,31 +270,44 @@ let runAnalysis ~dce_config ~cmtRoot ~reactive_collection = (* Merging phase: combine all builders -> immutable data *) let annotations, decls, cross_file, refs, file_deps = Timing.time_phase `Merging (fun () -> - let annotations = - FileAnnotations.merge_all - (dce_data_list - |> List.map (fun fd -> fd.DceFileProcessing.annotations)) - in - let decls = - Declarations.merge_all - (dce_data_list - |> List.map (fun fd -> fd.DceFileProcessing.decls)) - in - let cross_file = - CrossFileItems.merge_all - (dce_data_list - |> List.map (fun fd -> fd.DceFileProcessing.cross_file)) + (* Use reactive merge if available, otherwise list-based merge *) + let annotations, decls, cross_file = + match reactive_merge with + | Some merged -> + ( ReactiveMerge.freeze_annotations merged, + ReactiveMerge.freeze_decls merged, + ReactiveMerge.collect_cross_file_items merged ) + | None -> + ( FileAnnotations.merge_all + (dce_data_list + |> List.map (fun fd -> fd.DceFileProcessing.annotations)), + Declarations.merge_all + (dce_data_list + |> List.map (fun fd -> fd.DceFileProcessing.decls)), + CrossFileItems.merge_all + (dce_data_list + |> List.map (fun fd -> fd.DceFileProcessing.cross_file)) ) in - (* Merge refs and file_deps into builders for cross-file items processing *) + (* Merge refs and file_deps into builders for cross-file items processing. + This still needs the file_data iteration for post-processing. *) let refs_builder = References.create_builder () in let file_deps_builder = FileDeps.create_builder () in - dce_data_list - |> List.iter (fun fd -> - References.merge_into_builder ~from:fd.DceFileProcessing.refs - ~into:refs_builder; - FileDeps.merge_into_builder - ~from:fd.DceFileProcessing.file_deps - ~into:file_deps_builder); + (match reactive_collection with + | Some collection -> + ReactiveAnalysis.iter_file_data collection (fun fd -> + References.merge_into_builder ~from:fd.DceFileProcessing.refs + ~into:refs_builder; + FileDeps.merge_into_builder + ~from:fd.DceFileProcessing.file_deps + ~into:file_deps_builder) + | None -> + dce_data_list + |> List.iter (fun fd -> + References.merge_into_builder + ~from:fd.DceFileProcessing.refs ~into:refs_builder; + FileDeps.merge_into_builder + ~from:fd.DceFileProcessing.file_deps + ~into:file_deps_builder)); (* Compute type-label dependencies after merge *) DeadType.process_type_label_dependencies ~config:dce_config ~decls ~refs:refs_builder; @@ -364,11 +383,22 @@ let runAnalysisAndReport ~cmtRoot = if !Cli.reactive then Some (ReactiveAnalysis.create ~config:dce_config) else None in + (* Create reactive merge once if reactive mode is enabled. + This automatically updates when reactive_collection changes. *) + let reactive_merge = + match reactive_collection with + | Some collection -> + let file_data_collection = + ReactiveAnalysis.to_file_data_collection collection + in + Some (ReactiveMerge.create file_data_collection) + | None -> None + in for run = 1 to numRuns do Timing.reset (); if numRuns > 1 && !Cli.timing then Printf.eprintf "\n=== Run %d/%d ===\n%!" run numRuns; - runAnalysis ~dce_config ~cmtRoot ~reactive_collection; + runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge; if run = numRuns then ( (* Only report on last run *) Log_.Stats.report ~config:dce_config; diff --git a/analysis/reanalyze/src/References.ml b/analysis/reanalyze/src/References.ml index 632dbd7861..60fd7bfafd 100644 --- a/analysis/reanalyze/src/References.ml +++ b/analysis/reanalyze/src/References.ml @@ -50,6 +50,18 @@ let freeze_builder (builder : builder) : t = (* Zero-copy freeze - builder should not be used after this *) {value_refs = builder.value_refs; type_refs = builder.type_refs} +(* ===== Builder extraction for reactive merge ===== *) + +let builder_value_refs_to_list (builder : builder) : + (Lexing.position * PosSet.t) list = + PosHash.fold (fun pos refs acc -> (pos, refs) :: acc) builder.value_refs [] + +let builder_type_refs_to_list (builder : builder) : + (Lexing.position * PosSet.t) list = + PosHash.fold (fun pos refs acc -> (pos, refs) :: acc) builder.type_refs [] + +let create ~value_refs ~type_refs : t = {value_refs; type_refs} + (* ===== Read-only API ===== *) let find_value_refs (t : t) pos = findSet t.value_refs pos diff --git a/analysis/reanalyze/src/References.mli b/analysis/reanalyze/src/References.mli index 05228b7b8e..5776ca615c 100644 --- a/analysis/reanalyze/src/References.mli +++ b/analysis/reanalyze/src/References.mli @@ -32,6 +32,17 @@ val merge_all : builder list -> t val freeze_builder : builder -> t (** Convert builder to immutable t. Builder should not be used after this. *) +(** {2 Builder extraction for reactive merge} *) + +val builder_value_refs_to_list : builder -> (Lexing.position * PosSet.t) list +(** Extract all value refs as a list for reactive merge *) + +val builder_type_refs_to_list : builder -> (Lexing.position * PosSet.t) list +(** Extract all type refs as a list for reactive merge *) + +val create : value_refs:PosSet.t PosHash.t -> type_refs:PosSet.t PosHash.t -> t +(** Create a References.t from hashtables *) + (** {2 Read-only API for t - for solver} *) val find_value_refs : t -> Lexing.position -> PosSet.t From f7066d213d0767932a471c779192f7ca5a27d779 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 07:18:47 +0100 Subject: [PATCH 07/45] Add reactive combinators (lookup, join) and reactive type/exception deps - Add Reactive.lookup: single-key subscription from a collection - Add Reactive.join: reactive hash join between two collections - Add ReactiveTypeDeps: type-label dependencies via reactive join - Add ReactiveExceptionRefs: exception ref resolution via reactive join - Update ARCHITECTURE.md with generated SVG diagrams - Add diagram sources (.mmd) for batch pipeline, reactive pipeline, delta propagation The reactive modules express cross-file dependency resolution declaratively: - ReactiveTypeDeps uses flatMap to index decls by path, then join to connect impl<->intf - ReactiveExceptionRefs uses join to resolve exception paths to declaration locations --- analysis/reactive/src/Reactive.ml | 232 ++++++++++++++++ analysis/reactive/src/Reactive.mli | 44 +++ analysis/reactive/test/ReactiveTest.ml | 217 +++++++++++++++ analysis/reanalyze/ARCHITECTURE.md | 132 ++++----- .../reanalyze/diagrams/batch-pipeline.mmd | 53 ++++ .../reanalyze/diagrams/batch-pipeline.svg | 1 + .../reanalyze/diagrams/delta-propagation.mmd | 26 ++ .../reanalyze/diagrams/delta-propagation.svg | 1 + .../reanalyze/diagrams/reactive-pipeline.mmd | 62 +++++ .../reanalyze/diagrams/reactive-pipeline.svg | 1 + .../reanalyze/src/ReactiveExceptionRefs.ml | 82 ++++++ .../reanalyze/src/ReactiveExceptionRefs.mli | 54 ++++ analysis/reanalyze/src/ReactiveTypeDeps.ml | 255 ++++++++++++++++++ analysis/reanalyze/src/ReactiveTypeDeps.mli | 55 ++++ analysis/src/DceCommand.ml | 3 +- 15 files changed, 1141 insertions(+), 77 deletions(-) create mode 100644 analysis/reanalyze/diagrams/batch-pipeline.mmd create mode 100644 analysis/reanalyze/diagrams/batch-pipeline.svg create mode 100644 analysis/reanalyze/diagrams/delta-propagation.mmd create mode 100644 analysis/reanalyze/diagrams/delta-propagation.svg create mode 100644 analysis/reanalyze/diagrams/reactive-pipeline.mmd create mode 100644 analysis/reanalyze/diagrams/reactive-pipeline.svg create mode 100644 analysis/reanalyze/src/ReactiveExceptionRefs.ml create mode 100644 analysis/reanalyze/src/ReactiveExceptionRefs.mli create mode 100644 analysis/reanalyze/src/ReactiveTypeDeps.ml create mode 100644 analysis/reanalyze/src/ReactiveTypeDeps.mli diff --git a/analysis/reactive/src/Reactive.ml b/analysis/reactive/src/Reactive.ml index cb8b29ebd2..05f2b6f29f 100644 --- a/analysis/reactive/src/Reactive.ml +++ b/analysis/reactive/src/Reactive.ml @@ -136,3 +136,235 @@ let flatMap (source : ('k1, 'v1) t) ~f ?merge () : ('k2, 'v2) t = get = (fun k -> Hashtbl.find_opt target k); length = (fun () -> Hashtbl.length target); } + +(** {1 Lookup} *) + +(** Lookup a single key reactively. + Returns a collection with that single entry that updates when the + source's value at that key changes. + + This is useful for creating reactive subscriptions to specific keys. *) +let lookup (source : ('k, 'v) t) ~key : ('k, 'v) t = + let current : ('k, 'v option) Hashtbl.t = Hashtbl.create 1 in + let subscribers : (('k, 'v) delta -> unit) list ref = ref [] in + + let emit delta = List.iter (fun h -> h delta) !subscribers in + + let handle_delta delta = + match delta with + | Set (k, v) when k = key -> + Hashtbl.replace current key (Some v); + emit (Set (key, v)) + | Remove k when k = key -> + Hashtbl.remove current key; + emit (Remove key) + | _ -> () (* Ignore deltas for other keys *) + in + + (* Subscribe to source *) + source.subscribe handle_delta; + + (* Initialize with current value *) + (match source.get key with + | Some v -> Hashtbl.replace current key (Some v) + | None -> ()); + + { + subscribe = (fun handler -> subscribers := handler :: !subscribers); + iter = + (fun f -> + match Hashtbl.find_opt current key with + | Some (Some v) -> f key v + | _ -> ()); + get = + (fun k -> + if k = key then + match Hashtbl.find_opt current key with + | Some v -> v + | None -> None + else None); + length = + (fun () -> + match Hashtbl.find_opt current key with + | Some (Some _) -> 1 + | _ -> 0); + } + +(** {1 Join} *) + +(** Join two collections: for each entry in [left], look up a key in [right]. + + [key_of] extracts the lookup key from each left entry. + [f] combines left entry with looked-up right value (if present). + + When either collection changes, affected entries are recomputed. + This is more efficient than nested flatMap for join patterns. *) +let join (left : ('k1, 'v1) t) (right : ('k2, 'v2) t) + ~(key_of : 'k1 -> 'v1 -> 'k2) ~(f : 'k1 -> 'v1 -> 'v2 option -> ('k3 * 'v3) list) + ?merge () : ('k3, 'v3) t = + let merge_fn = + match merge with + | Some m -> m + | None -> fun _ v -> v + in + (* Track: for each left key, which right key was looked up *) + let left_to_right_key : ('k1, 'k2) Hashtbl.t = Hashtbl.create 64 in + (* Track: for each right key, which left keys depend on it *) + let right_key_to_left_keys : ('k2, 'k1 list) Hashtbl.t = Hashtbl.create 64 in + (* Current left entries *) + let left_entries : ('k1, 'v1) Hashtbl.t = Hashtbl.create 64 in + (* Provenance and contributions for output *) + let provenance : ('k1, 'k3 list) Hashtbl.t = Hashtbl.create 64 in + let contributions : ('k3, ('k1, 'v3) Hashtbl.t) Hashtbl.t = + Hashtbl.create 256 + in + let target : ('k3, 'v3) Hashtbl.t = Hashtbl.create 256 in + let subscribers : (('k3, 'v3) delta -> unit) list ref = ref [] in + + let emit delta = List.iter (fun h -> h delta) !subscribers in + + let recompute_target k3 = + match Hashtbl.find_opt contributions k3 with + | None -> + Hashtbl.remove target k3; + Some (Remove k3) + | Some contribs when Hashtbl.length contribs = 0 -> + Hashtbl.remove contributions k3; + Hashtbl.remove target k3; + Some (Remove k3) + | Some contribs -> + let values = Hashtbl.fold (fun _ v acc -> v :: acc) contribs [] in + let merged = + match values with + | [] -> assert false + | [v] -> v + | v :: rest -> List.fold_left merge_fn v rest + in + Hashtbl.replace target k3 merged; + Some (Set (k3, merged)) + in + + let remove_left_contributions k1 = + match Hashtbl.find_opt provenance k1 with + | None -> [] + | Some target_keys -> + Hashtbl.remove provenance k1; + target_keys + |> List.iter (fun k3 -> + match Hashtbl.find_opt contributions k3 with + | None -> () + | Some contribs -> Hashtbl.remove contribs k1); + target_keys + in + + let add_left_contributions k1 entries = + let target_keys = List.map fst entries in + Hashtbl.replace provenance k1 target_keys; + entries + |> List.iter (fun (k3, v3) -> + let contribs = + match Hashtbl.find_opt contributions k3 with + | Some c -> c + | None -> + let c = Hashtbl.create 4 in + Hashtbl.replace contributions k3 c; + c + in + Hashtbl.replace contribs k1 v3); + target_keys + in + + let process_left_entry k1 v1 = + let old_affected = remove_left_contributions k1 in + (* Update right key tracking *) + (match Hashtbl.find_opt left_to_right_key k1 with + | Some old_k2 -> + Hashtbl.remove left_to_right_key k1; + (match Hashtbl.find_opt right_key_to_left_keys old_k2 with + | Some keys -> + Hashtbl.replace right_key_to_left_keys old_k2 + (List.filter (fun k -> k <> k1) keys) + | None -> ()) + | None -> ()); + let k2 = key_of k1 v1 in + Hashtbl.replace left_to_right_key k1 k2; + let keys = + match Hashtbl.find_opt right_key_to_left_keys k2 with + | Some ks -> ks + | None -> [] + in + Hashtbl.replace right_key_to_left_keys k2 (k1 :: keys); + (* Compute output *) + let right_val = right.get k2 in + let new_entries = f k1 v1 right_val in + let new_affected = add_left_contributions k1 new_entries in + let all_affected = old_affected @ new_affected in + let seen = Hashtbl.create (List.length all_affected) in + all_affected + |> List.filter_map (fun k3 -> + if Hashtbl.mem seen k3 then None + else ( + Hashtbl.replace seen k3 (); + recompute_target k3)) + in + + let remove_left_entry k1 = + Hashtbl.remove left_entries k1; + let affected = remove_left_contributions k1 in + (* Clean up tracking *) + (match Hashtbl.find_opt left_to_right_key k1 with + | Some k2 -> + Hashtbl.remove left_to_right_key k1; + (match Hashtbl.find_opt right_key_to_left_keys k2 with + | Some keys -> + Hashtbl.replace right_key_to_left_keys k2 + (List.filter (fun k -> k <> k1) keys) + | None -> ()) + | None -> ()); + affected |> List.filter_map recompute_target + in + + let handle_left_delta delta = + let downstream = + match delta with + | Set (k1, v1) -> + Hashtbl.replace left_entries k1 v1; + process_left_entry k1 v1 + | Remove k1 -> remove_left_entry k1 + in + List.iter emit downstream + in + + let handle_right_delta delta = + (* When right changes, reprocess all left entries that depend on it *) + let downstream = + match delta with + | Set (k2, _) | Remove k2 -> + (match Hashtbl.find_opt right_key_to_left_keys k2 with + | None -> [] + | Some left_keys -> + left_keys + |> List.concat_map (fun k1 -> + match Hashtbl.find_opt left_entries k1 with + | Some v1 -> process_left_entry k1 v1 + | None -> [])) + in + List.iter emit downstream + in + + (* Subscribe to both sources *) + left.subscribe handle_left_delta; + right.subscribe handle_right_delta; + + (* Initialize from existing entries *) + left.iter (fun k1 v1 -> + Hashtbl.replace left_entries k1 v1; + let deltas = process_left_entry k1 v1 in + List.iter emit deltas); + + { + subscribe = (fun handler -> subscribers := handler :: !subscribers); + iter = (fun f -> Hashtbl.iter f target); + get = (fun k -> Hashtbl.find_opt target k); + length = (fun () -> Hashtbl.length target); + } diff --git a/analysis/reactive/src/Reactive.mli b/analysis/reactive/src/Reactive.mli index 8b1b3e5a31..5894b23bf4 100644 --- a/analysis/reactive/src/Reactive.mli +++ b/analysis/reactive/src/Reactive.mli @@ -73,3 +73,47 @@ val flatMap : Defaults to last-write-wins. Derived collections can be further composed with [flatMap]. *) + +(** {1 Lookup} *) + +val lookup : ('k, 'v) t -> key:'k -> ('k, 'v) t +(** [lookup source ~key] creates a reactive subscription to a single key. + + Returns a collection containing at most one entry (the value at [key]). + When [source]'s value at [key] changes, the lookup collection updates. + + Useful for reactive point queries. *) + +(** {1 Join} *) + +val join : + ('k1, 'v1) t -> + ('k2, 'v2) t -> + key_of:('k1 -> 'v1 -> 'k2) -> + f:('k1 -> 'v1 -> 'v2 option -> ('k3 * 'v3) list) -> + ?merge:('v3 -> 'v3 -> 'v3) -> + unit -> + ('k3, 'v3) t +(** [join left right ~key_of ~f ()] joins two collections. + + For each entry [(k1, v1)] in [left]: + - Computes lookup key [k2 = key_of k1 v1] + - Looks up [k2] in [right] to get [v2_opt] + - Produces entries via [f k1 v1 v2_opt] + + When either [left] or [right] changes, affected entries are recomputed. + This is the reactive equivalent of a hash join. + + {2 Example: Exception refs lookup} + + {[ + (* exception_refs: (path, loc_from) *) + (* decl_by_path: (path, decl list) *) + let resolved = Reactive.join exception_refs decl_by_path + ~key_of:(fun path _loc -> path) + ~f:(fun path loc decls_opt -> + match decls_opt with + | Some decls -> decls |> List.map (fun d -> (d.pos, loc)) + | None -> []) + () + ]} *) diff --git a/analysis/reactive/test/ReactiveTest.ml b/analysis/reactive/test/ReactiveTest.ml index 740d11f941..35ed4cc319 100644 --- a/analysis/reactive/test/ReactiveTest.ml +++ b/analysis/reactive/test/ReactiveTest.ml @@ -328,6 +328,220 @@ let test_file_collection () = Printf.printf "PASSED\n\n" +let test_lookup () = + Printf.printf "=== Test: lookup (reactive single-key subscription) ===\n"; + + let data : (string, int) Hashtbl.t = Hashtbl.create 16 in + let subscribers : ((string, int) delta -> unit) list ref = ref [] in + + let source : (string, int) t = + { + subscribe = (fun h -> subscribers := h :: !subscribers); + iter = (fun f -> Hashtbl.iter f data); + get = (fun k -> Hashtbl.find_opt data k); + length = (fun () -> Hashtbl.length data); + } + in + + let emit delta = + apply_delta data delta; + List.iter (fun h -> h delta) !subscribers + in + + (* Create lookup for key "foo" *) + let foo_lookup = lookup source ~key:"foo" in + + (* Initially empty *) + assert (length foo_lookup = 0); + assert (get foo_lookup "foo" = None); + + (* Set foo=42 *) + emit (Set ("foo", 42)); + Printf.printf "After Set(foo, 42): lookup has %d entries\n" (length foo_lookup); + assert (length foo_lookup = 1); + assert (get foo_lookup "foo" = Some 42); + + (* Set bar=100 (different key, lookup shouldn't change) *) + emit (Set ("bar", 100)); + Printf.printf "After Set(bar, 100): lookup still has %d entries\n" + (length foo_lookup); + assert (length foo_lookup = 1); + assert (get foo_lookup "foo" = Some 42); + + (* Update foo=99 *) + emit (Set ("foo", 99)); + Printf.printf "After Set(foo, 99): lookup value updated\n"; + assert (get foo_lookup "foo" = Some 99); + + (* Track subscription updates *) + let updates = ref [] in + foo_lookup.subscribe (fun delta -> updates := delta :: !updates); + + emit (Set ("foo", 1)); + emit (Set ("bar", 2)); + emit (Remove "foo"); + + Printf.printf "Subscription received %d updates (expected 2: Set+Remove for foo)\n" + (List.length !updates); + assert (List.length !updates = 2); + + Printf.printf "PASSED\n\n" + +let test_join () = + Printf.printf "=== Test: join (reactive lookup/join) ===\n"; + + (* Left collection: exception refs (path -> loc_from) *) + let left_data : (string, int) Hashtbl.t = Hashtbl.create 16 in + let left_subs : ((string, int) delta -> unit) list ref = ref [] in + let left : (string, int) t = + { + subscribe = (fun h -> left_subs := h :: !left_subs); + iter = (fun f -> Hashtbl.iter f left_data); + get = (fun k -> Hashtbl.find_opt left_data k); + length = (fun () -> Hashtbl.length left_data); + } + in + let emit_left delta = + apply_delta left_data delta; + List.iter (fun h -> h delta) !left_subs + in + + (* Right collection: decl index (path -> decl_pos) *) + let right_data : (string, int) Hashtbl.t = Hashtbl.create 16 in + let right_subs : ((string, int) delta -> unit) list ref = ref [] in + let right : (string, int) t = + { + subscribe = (fun h -> right_subs := h :: !right_subs); + iter = (fun f -> Hashtbl.iter f right_data); + get = (fun k -> Hashtbl.find_opt right_data k); + length = (fun () -> Hashtbl.length right_data); + } + in + let emit_right delta = + apply_delta right_data delta; + List.iter (fun h -> h delta) !right_subs + in + + (* Join: for each (path, loc_from) in left, look up path in right *) + let joined = + join left right + ~key_of:(fun path _loc_from -> path) + ~f:(fun _path loc_from decl_pos_opt -> + match decl_pos_opt with + | Some decl_pos -> + (* Produce (decl_pos, loc_from) pairs *) + [(decl_pos, loc_from)] + | None -> []) + () + in + + (* Initially empty *) + assert (length joined = 0); + + (* Add declaration at path "A" with pos 100 *) + emit_right (Set ("A", 100)); + Printf.printf "After right Set(A, 100): joined=%d\n" (length joined); + assert (length joined = 0); (* No left entries yet *) + + (* Add exception ref at path "A" from loc 1 *) + emit_left (Set ("A", 1)); + Printf.printf "After left Set(A, 1): joined=%d\n" (length joined); + assert (length joined = 1); + assert (get joined 100 = Some 1); (* decl_pos 100 -> loc_from 1 *) + + (* Add another exception ref at path "B" (no matching decl) *) + emit_left (Set ("B", 2)); + Printf.printf "After left Set(B, 2): joined=%d (B has no decl)\n" + (length joined); + assert (length joined = 1); + + (* Add declaration for path "B" *) + emit_right (Set ("B", 200)); + Printf.printf "After right Set(B, 200): joined=%d\n" (length joined); + assert (length joined = 2); + assert (get joined 200 = Some 2); + + (* Update right: change B's decl_pos *) + emit_right (Set ("B", 201)); + Printf.printf "After right Set(B, 201): joined=%d\n" (length joined); + assert (length joined = 2); + assert (get joined 200 = None); (* Old key gone *) + assert (get joined 201 = Some 2); (* New key has the value *) + + (* Remove left entry A *) + emit_left (Remove "A"); + Printf.printf "After left Remove(A): joined=%d\n" (length joined); + assert (length joined = 1); + assert (get joined 100 = None); + + Printf.printf "PASSED\n\n" + +let test_join_with_merge () = + Printf.printf "=== Test: join with merge ===\n"; + + (* Multiple left entries can map to same right key *) + let left_data : (int, string) Hashtbl.t = Hashtbl.create 16 in + let left_subs : ((int, string) delta -> unit) list ref = ref [] in + let left : (int, string) t = + { + subscribe = (fun h -> left_subs := h :: !left_subs); + iter = (fun f -> Hashtbl.iter f left_data); + get = (fun k -> Hashtbl.find_opt left_data k); + length = (fun () -> Hashtbl.length left_data); + } + in + let emit_left delta = + apply_delta left_data delta; + List.iter (fun h -> h delta) !left_subs + in + + let right_data : (string, int) Hashtbl.t = Hashtbl.create 16 in + let right_subs : ((string, int) delta -> unit) list ref = ref [] in + let right : (string, int) t = + { + subscribe = (fun h -> right_subs := h :: !right_subs); + iter = (fun f -> Hashtbl.iter f right_data); + get = (fun k -> Hashtbl.find_opt right_data k); + length = (fun () -> Hashtbl.length right_data); + } + in + let emit_right delta = + apply_delta right_data delta; + List.iter (fun h -> h delta) !right_subs + in + + (* Join with merge: all entries produce to key 0 *) + let joined = + join left right + ~key_of:(fun _id path -> path) (* Look up by path *) + ~f:(fun _id _path value_opt -> + match value_opt with + | Some v -> [(0, v)] (* All contribute to key 0 *) + | None -> []) + ~merge:( + ) (* Sum values *) + () + in + + emit_right (Set ("X", 10)); + emit_left (Set (1, "X")); + emit_left (Set (2, "X")); + + Printf.printf "Two entries looking up X (value 10): sum=%d\n" + (get joined 0 |> Option.value ~default:0); + assert (get joined 0 = Some 20); (* 10 + 10 *) + + emit_right (Set ("X", 5)); + Printf.printf "After right changes to 5: sum=%d\n" + (get joined 0 |> Option.value ~default:0); + assert (get joined 0 = Some 10); (* 5 + 5 *) + + emit_left (Remove 1); + Printf.printf "After removing one left entry: sum=%d\n" + (get joined 0 |> Option.value ~default:0); + assert (get joined 0 = Some 5); (* Only one left *) + + Printf.printf "PASSED\n\n" + let () = Printf.printf "\n====== Reactive Collection Tests ======\n\n"; test_flatmap_basic (); @@ -335,4 +549,7 @@ let () = test_composition (); test_flatmap_on_existing_data (); test_file_collection (); + test_lookup (); + test_join (); + test_join_with_merge (); Printf.printf "All tests passed!\n" diff --git a/analysis/reanalyze/ARCHITECTURE.md b/analysis/reanalyze/ARCHITECTURE.md index 1d341ae52e..9644b4f1f9 100644 --- a/analysis/reanalyze/ARCHITECTURE.md +++ b/analysis/reanalyze/ARCHITECTURE.md @@ -21,82 +21,9 @@ This design enables: ## Pipeline Diagram -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ DCE ANALYSIS PIPELINE │ -└─────────────────────────────────────────────────────────────────────────────┘ - - ┌─────────────┐ - │ DceConfig.t │ (explicit configuration) - └──────┬──────┘ - │ - ╔════════════════════════════════╪════════════════════════════════════════╗ - ║ PHASE 1: MAP (per-file) │ ║ - ╠════════════════════════════════╪════════════════════════════════════════╣ - ║ ▼ ║ - ║ ┌──────────┐ process_cmt_file ┌───────────────────────────────┐ ║ - ║ │ file1.cmt├──────────────────────►│ file_data { │ ║ - ║ └──────────┘ │ annotations: builder │ ║ - ║ ┌──────────┐ process_cmt_file │ decls: builder │ ║ - ║ │ file2.cmt├──────────────────────►│ refs: builder │ ║ - ║ └──────────┘ │ file_deps: builder │ ║ - ║ ┌──────────┐ process_cmt_file │ cross_file: builder │ ║ - ║ │ file3.cmt├──────────────────────►│ } │ ║ - ║ └──────────┘ └───────────────────────────────┘ ║ - ║ │ ║ - ║ Local mutable state OK │ file_data list ║ - ╚══════════════════════════════════════════════════╪══════════════════════╝ - │ - ╔══════════════════════════════════════════════════╪══════════════════════╗ - ║ PHASE 2: MERGE (combine builders) │ ║ - ╠══════════════════════════════════════════════════╪══════════════════════╣ - ║ ▼ ║ - ║ ┌─────────────────────────────────────────────────────────────────┐ ║ - ║ │ FileAnnotations.merge_all → annotations: FileAnnotations.t │ ║ - ║ │ Declarations.merge_all → decls: Declarations.t │ ║ - ║ │ References.merge_all → refs: References.t │ ║ - ║ │ FileDeps.merge_all → file_deps: FileDeps.t │ ║ - ║ │ CrossFileItems.merge_all → cross_file: CrossFileItems.t │ ║ - ║ │ │ ║ - ║ │ CrossFileItems.compute_optional_args_state │ ║ - ║ │ → optional_args_state: State.t │ ║ - ║ └─────────────────────────────────────────────────────────────────┘ ║ - ║ │ ║ - ║ Pure functions, immutable output │ merged data ║ - ╚══════════════════════════════════════════════════╪══════════════════════╝ - │ - ╔══════════════════════════════════════════════════╪══════════════════════╗ - ║ PHASE 3: SOLVE (pure deadness computation) │ ║ - ╠══════════════════════════════════════════════════╪══════════════════════╣ - ║ ▼ ║ - ║ ┌─────────────────────────────────────────────────────────────────┐ ║ - ║ │ Pass 1: DeadCommon.solveDead (core deadness) │ ║ - ║ │ ~annotations ~decls ~refs ~file_deps ~config │ ║ - ║ │ → AnalysisResult.t (dead/live status resolved) │ ║ - ║ │ │ ║ - ║ │ Pass 2: Optional args analysis (liveness-aware) │ ║ - ║ │ CrossFileItems.compute_optional_args_state ~is_live │ ║ - ║ │ DeadOptionalArgs.check (only for live decls) │ ║ - ║ │ → AnalysisResult.t { issues: Issue.t list } │ ║ - ║ └─────────────────────────────────────────────────────────────────┘ ║ - ║ │ ║ - ║ Pure functions: immutable in → immutable out │ issues ║ - ╚══════════════════════════════════════════════════╪══════════════════════╝ - │ - ╔══════════════════════════════════════════════════╪══════════════════════╗ - ║ PHASE 4: REPORT (side effects at the edge) │ ║ - ╠══════════════════════════════════════════════════╪══════════════════════╣ - ║ ▼ ║ - ║ ┌─────────────────────────────────────────────────────────────────┐ ║ - ║ │ AnalysisResult.get_issues │ ║ - ║ │ |> List.iter (fun issue -> Log_.warning ~loc issue.description) │ ║ - ║ │ │ ║ - ║ │ (Optional: EmitJson for JSON output) │ ║ - ║ └─────────────────────────────────────────────────────────────────┘ ║ - ║ ║ - ║ Side effects only here: logging, JSON output ║ - ╚════════════════════════════════════════════════════════════════════════╝ -``` +> **Source**: [`diagrams/batch-pipeline.mmd`](diagrams/batch-pipeline.mmd) + +![Batch Pipeline](diagrams/batch-pipeline.svg) --- @@ -208,6 +135,59 @@ The key insight: **immutable data structures enable safe incremental updates** - --- +## Reactive Pipelines + +The reactive layer (`analysis/reactive/`) provides delta-based incremental updates. Instead of re-running entire phases, changes propagate automatically through derived collections. + +### Core Reactive Primitives + +| Primitive | Description | +|-----------|-------------| +| `Reactive.t ('k, 'v)` | Universal reactive collection interface | +| `subscribe` | Register for delta notifications | +| `iter` | Iterate current entries | +| `get` | Lookup by key | +| `delta` | Change notification: `Set (key, value)` or `Remove key` | +| `flatMap` | Transform collection, optionally merge same-key values | +| `join` | Hash join two collections with automatic updates | +| `lookup` | Single-key subscription | +| `ReactiveFileCollection` | File-backed collection with change detection | + +### Reactive Analysis Pipeline + +> **Source**: [`diagrams/reactive-pipeline.mmd`](diagrams/reactive-pipeline.mmd) + +![Reactive Pipeline](diagrams/reactive-pipeline.svg) + +### Delta Propagation + +> **Source**: [`diagrams/delta-propagation.mmd`](diagrams/delta-propagation.mmd) + +![Delta Propagation](diagrams/delta-propagation.svg) + +### Key Benefits + +| Aspect | Batch Pipeline | Reactive Pipeline | +|--------|----------------|-------------------| +| File change | Re-process all files | Re-process changed file only | +| Merge | Re-merge all data | Update affected entries only | +| Type deps | Rebuild entire index | Update affected paths only | +| Exception refs | Re-resolve all | Re-resolve affected only | +| Memory | O(N) per phase | O(N) total, shared | + +### Reactive Modules + +| Module | Responsibility | +|--------|---------------| +| `Reactive` | Core primitives: `flatMap`, `join`, `lookup`, delta types | +| `ReactiveFileCollection` | File-backed collection with change detection | +| `ReactiveAnalysis` | CMT processing with file caching | +| `ReactiveMerge` | Derives decls, annotations, refs from file_data | +| `ReactiveTypeDeps` | Type-label dependency resolution via join | +| `ReactiveExceptionRefs` | Exception ref resolution via join | + +--- + ## Testing **Order-independence test**: Run with `-test-shuffle` flag to randomize file processing order. The test (`make test-reanalyze-order-independence`) verifies that shuffled runs produce identical output. diff --git a/analysis/reanalyze/diagrams/batch-pipeline.mmd b/analysis/reanalyze/diagrams/batch-pipeline.mmd new file mode 100644 index 0000000000..cc2c1bde94 --- /dev/null +++ b/analysis/reanalyze/diagrams/batch-pipeline.mmd @@ -0,0 +1,53 @@ +%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#e8f4fd', 'primaryTextColor': '#1a1a1a', 'primaryBorderColor': '#4a90d9', 'lineColor': '#4a90d9'}}}%% +flowchart TB + subgraph Phase1["PHASE 1: MAP (per-file)"] + CMT1["file1.cmt"] + CMT2["file2.cmt"] + CMT3["file3.cmt"] + PROC["process_cmt_file"] + FD1["file_data₁"] + FD2["file_data₂"] + FD3["file_data₃"] + + CMT1 --> PROC + CMT2 --> PROC + CMT3 --> PROC + PROC --> FD1 + PROC --> FD2 + PROC --> FD3 + end + + subgraph Phase2["PHASE 2: MERGE"] + MERGE["merge_all"] + MERGED["merged {
annotations,
decls,
refs,
file_deps
}"] + + FD1 --> MERGE + FD2 --> MERGE + FD3 --> MERGE + MERGE --> MERGED + end + + subgraph Phase3["PHASE 3: SOLVE"] + SOLVE["solveDead"] + RESULT["AnalysisResult {
issues: Issue.t list
}"] + + MERGED --> SOLVE + SOLVE --> RESULT + end + + subgraph Phase4["PHASE 4: REPORT"] + REPORT["Log_.warning"] + + RESULT --> REPORT + end + + classDef phase1 fill:#e8f4fd,stroke:#4a90d9 + classDef phase2 fill:#f0f7e6,stroke:#6b8e23 + classDef phase3 fill:#fff5e6,stroke:#d4a574 + classDef phase4 fill:#ffe6e6,stroke:#cc6666 + + class CMT1,CMT2,CMT3,PROC,FD1,FD2,FD3 phase1 + class MERGE,MERGED phase2 + class SOLVE,RESULT phase3 + class REPORT phase4 + diff --git a/analysis/reanalyze/diagrams/batch-pipeline.svg b/analysis/reanalyze/diagrams/batch-pipeline.svg new file mode 100644 index 0000000000..5877ce5c5a --- /dev/null +++ b/analysis/reanalyze/diagrams/batch-pipeline.svg @@ -0,0 +1 @@ +

PHASE 4: REPORT

PHASE 3: SOLVE

PHASE 2: MERGE

PHASE 1: MAP (per-file)

file1.cmt

file2.cmt

file3.cmt

process_cmt_file

file_data₁

file_data₂

file_data₃

merge_all

merged {
annotations,
decls,
refs,
file_deps
}

solveDead

AnalysisResult {
issues: Issue.t list
}

Log_.warning

\ No newline at end of file diff --git a/analysis/reanalyze/diagrams/delta-propagation.mmd b/analysis/reanalyze/diagrams/delta-propagation.mmd new file mode 100644 index 0000000000..94f6d39c17 --- /dev/null +++ b/analysis/reanalyze/diagrams/delta-propagation.mmd @@ -0,0 +1,26 @@ +%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#e8f4fd', 'primaryTextColor': '#1a1a1a', 'primaryBorderColor': '#4a90d9', 'lineColor': '#4a90d9'}}}%% +sequenceDiagram + participant FS as File System + participant RFC as ReactiveFileCollection + participant FD as file_data + participant DECLS as decls + participant DBP as decl_by_path + participant REFS as refs + participant SOLVER as Solver + + Note over FS,SOLVER: File.cmt changes on disk + + FS->>RFC: mtime/size changed + RFC->>RFC: read_cmt + process + RFC->>FD: Set("File.res", new_file_data) + + FD->>DECLS: Remove(old_pos₁), Remove(old_pos₂), ... + FD->>DECLS: Set(new_pos₁, decl₁), Set(new_pos₂, decl₂), ... + + DECLS->>DBP: Update affected paths only + DBP->>DBP: Recalculate merged lists + + DBP->>REFS: Set(pos, updated_refs) + + Note over SOLVER: Solver sees updated refs immediately + diff --git a/analysis/reanalyze/diagrams/delta-propagation.svg b/analysis/reanalyze/diagrams/delta-propagation.svg new file mode 100644 index 0000000000..06bd47c050 --- /dev/null +++ b/analysis/reanalyze/diagrams/delta-propagation.svg @@ -0,0 +1 @@ +Solverrefsdecl_by_pathdeclsfile_dataReactiveFileCollectionFile SystemSolverrefsdecl_by_pathdeclsfile_dataReactiveFileCollectionFile SystemFile.cmt changes on diskSolver sees updated refs immediatelymtime/size changedread_cmt + processSet("File.res", new_file_data)Remove(old_pos₁), Remove(old_pos₂), ...Set(new_pos₁, decl₁), Set(new_pos₂, decl₂), ...Update affected paths onlyRecalculate merged listsSet(pos, updated_refs) \ No newline at end of file diff --git a/analysis/reanalyze/diagrams/reactive-pipeline.mmd b/analysis/reanalyze/diagrams/reactive-pipeline.mmd new file mode 100644 index 0000000000..c5d228cbd0 --- /dev/null +++ b/analysis/reanalyze/diagrams/reactive-pipeline.mmd @@ -0,0 +1,62 @@ +%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#e8f4fd', 'primaryTextColor': '#1a1a1a', 'primaryBorderColor': '#4a90d9', 'lineColor': '#4a90d9', 'secondaryColor': '#f0f7e6', 'tertiaryColor': '#fff5e6'}}}%% +flowchart TB + subgraph FileLayer["File Layer"] + RFC[("ReactiveFileCollection
(file change detection)")] + end + + subgraph FileData["Per-File Data"] + FD["file_data
(path → file_data option)"] + end + + subgraph Extracted["Extracted Collections"] + DECLS["decls
(pos → Decl.t)"] + ANNOT["annotations
(pos → annotation)"] + EXCREF["exception_refs
(path → loc_from)"] + end + + subgraph TypeDeps["ReactiveTypeDeps"] + DBP["decl_by_path
(path → decl list)"] + SPR["same_path_refs
(pos → PosSet)"] + CFR["cross_file_refs
(pos → PosSet)"] + ATR["all_type_refs
(pos → PosSet)"] + end + + subgraph ExcDeps["ReactiveExceptionRefs"] + EXCDECL["exception_decls
(path → loc)"] + RESOLVED["resolved_refs
(pos → PosSet)"] + end + + subgraph Output["Combined Output"] + REFS["All refs
→ Ready for solver"] + end + + RFC -->|"process_files
(detect changes)"| FD + FD -->|"flatMap
(extract)"| DECLS + FD -->|"flatMap
(extract)"| ANNOT + FD -->|"flatMap
(extract)"| EXCREF + + DECLS -->|"flatMap"| DBP + DBP -->|"flatMap"| SPR + DBP -->|"join"| CFR + SPR --> ATR + CFR --> ATR + + DECLS -->|"flatMap"| EXCDECL + EXCDECL -->|"join"| RESOLVED + EXCREF -->|"join"| RESOLVED + + ATR --> REFS + RESOLVED --> REFS + + classDef fileLayer fill:#e8f4fd,stroke:#4a90d9,stroke-width:2px + classDef extracted fill:#f0f7e6,stroke:#6b8e23,stroke-width:2px + classDef typeDeps fill:#fff5e6,stroke:#d4a574,stroke-width:2px + classDef excDeps fill:#f5e6ff,stroke:#9966cc,stroke-width:2px + classDef output fill:#e6ffe6,stroke:#2e8b2e,stroke-width:2px + + class RFC,FD fileLayer + class DECLS,ANNOT,EXCREF extracted + class DBP,SPR,CFR,ATR typeDeps + class EXCDECL,RESOLVED excDeps + class REFS output + diff --git a/analysis/reanalyze/diagrams/reactive-pipeline.svg b/analysis/reanalyze/diagrams/reactive-pipeline.svg new file mode 100644 index 0000000000..dfeacb4ba0 --- /dev/null +++ b/analysis/reanalyze/diagrams/reactive-pipeline.svg @@ -0,0 +1 @@ +

Combined Output

ReactiveExceptionRefs

ReactiveTypeDeps

Extracted Collections

Per-File Data

File Layer

process_files
(detect changes)

flatMap
(extract)

flatMap
(extract)

flatMap
(extract)

flatMap

flatMap

join

flatMap

join

join

ReactiveFileCollection
(file change detection)

file_data
(path → file_data option)

decls
(pos → Decl.t)

annotations
(pos → annotation)

exception_refs
(path → loc_from)

decl_by_path
(path → decl list)

same_path_refs
(pos → PosSet)

cross_file_refs
(pos → PosSet)

all_type_refs
(pos → PosSet)

exception_decls
(path → loc)

resolved_refs
(pos → PosSet)

All refs
→ Ready for solver

\ No newline at end of file diff --git a/analysis/reanalyze/src/ReactiveExceptionRefs.ml b/analysis/reanalyze/src/ReactiveExceptionRefs.ml new file mode 100644 index 0000000000..d2bf89da2c --- /dev/null +++ b/analysis/reanalyze/src/ReactiveExceptionRefs.ml @@ -0,0 +1,82 @@ +(** Reactive exception reference resolution. + + Expresses exception ref resolution as a reactive join: + - exception_refs: (path, loc_from) from CrossFileItems + - exception_decls: (path, loc_to) indexed from Declarations + - result: value refs (pos_to, pos_from) + + When declarations or exception_refs change, only affected refs update. *) + +(** {1 Types} *) + +type t = { + exception_decls: (DcePath.t, Location.t) Reactive.t; + resolved_refs: (Lexing.position, PosSet.t) Reactive.t; +} +(** Reactive exception ref collections *) + +(** {1 Creation} *) + +(** Create reactive exception refs from decls and cross-file exception refs. + + [decls] is the reactive declarations collection. + [exception_refs] is the reactive collection of (path, loc_from) from CrossFileItems. *) +let create ~(decls : (Lexing.position, Decl.t) Reactive.t) + ~(exception_refs : (DcePath.t, Location.t) Reactive.t) : t = + (* Step 1: Index exception declarations by path *) + let exception_decls = + Reactive.flatMap decls + ~f:(fun _pos (decl : Decl.t) -> + match decl.Decl.declKind with + | Exception -> + let loc : Location.t = + { + Location.loc_start = decl.pos; + loc_end = decl.posEnd; + loc_ghost = false; + } + in + [(decl.path, loc)] + | _ -> []) + () (* Last-write-wins is fine since paths should be unique *) + in + + (* Step 2: Join exception_refs with exception_decls *) + let resolved_refs = + Reactive.join exception_refs exception_decls + ~key_of:(fun path _loc_from -> path) + ~f:(fun _path loc_from loc_to_opt -> + match loc_to_opt with + | Some loc_to -> + (* Add value reference: pos_to -> pos_from *) + [(loc_to.Location.loc_start, PosSet.singleton loc_from.Location.loc_start)] + | None -> []) + ~merge:PosSet.union () + in + + {exception_decls; resolved_refs} + +(** {1 Freezing} *) + +(** Add all resolved exception refs to a References.builder *) +let add_to_refs_builder (t : t) ~(refs : References.builder) : unit = + Reactive.iter + (fun posTo posFromSet -> + PosSet.iter + (fun posFrom -> References.add_value_ref refs ~posTo ~posFrom) + posFromSet) + t.resolved_refs + +(** Add file dependencies for resolved refs *) +let add_to_file_deps_builder (t : t) ~(file_deps : FileDeps.builder) : unit = + Reactive.iter + (fun posTo posFromSet -> + PosSet.iter + (fun posFrom -> + let from_file = posFrom.Lexing.pos_fname in + let to_file = posTo.Lexing.pos_fname in + if from_file <> to_file then + FileDeps.add_dep file_deps ~from_file ~to_file) + posFromSet) + t.resolved_refs + diff --git a/analysis/reanalyze/src/ReactiveExceptionRefs.mli b/analysis/reanalyze/src/ReactiveExceptionRefs.mli new file mode 100644 index 0000000000..2e7f583497 --- /dev/null +++ b/analysis/reanalyze/src/ReactiveExceptionRefs.mli @@ -0,0 +1,54 @@ +(** Reactive exception reference resolution. + + Expresses exception ref resolution as a reactive join. + When declarations or exception_refs change, only affected refs update. + + {2 Pipeline} + + {[ + decls exception_refs + | | + | flatMap | + ↓ | + exception_decls | + (path → loc) | + ↘ ↙ + join + ↓ + resolved_refs + (pos → PosSet) + ]} + + {2 Example} + + {[ + let exc_refs = ReactiveExceptionRefs.create + ~decls:merged.decls + ~exception_refs:(flatMap cross_file ~f:extract_exception_refs ()) + in + ReactiveExceptionRefs.add_to_refs_builder exc_refs ~refs:my_refs_builder + ]} *) + +(** {1 Types} *) + +type t +(** Reactive exception ref collections *) + +(** {1 Creation} *) + +val create : + decls:(Lexing.position, Decl.t) Reactive.t -> + exception_refs:(DcePath.t, Location.t) Reactive.t -> + t +(** Create reactive exception refs from decls and cross-file exception refs. + + When the source collections change, resolved refs automatically update. *) + +(** {1 Freezing} *) + +val add_to_refs_builder : t -> refs:References.builder -> unit +(** Add all resolved exception refs to a References.builder. *) + +val add_to_file_deps_builder : t -> file_deps:FileDeps.builder -> unit +(** Add file dependencies for resolved refs. *) + diff --git a/analysis/reanalyze/src/ReactiveTypeDeps.ml b/analysis/reanalyze/src/ReactiveTypeDeps.ml new file mode 100644 index 0000000000..bb9aa03931 --- /dev/null +++ b/analysis/reanalyze/src/ReactiveTypeDeps.ml @@ -0,0 +1,255 @@ +(** Reactive type-label dependencies. + + Expresses the type-label dependency computation as a reactive pipeline: + 1. decls -> decl_by_path (index by path) + 2. decl_by_path -> same_path_refs (connect duplicates at same path) + 3. decl_by_path + impl_decls -> cross_file_refs (connect impl<->intf) + + When declarations change, only affected refs are recomputed. *) + +(** {1 Helper types} *) + +type decl_info = { + pos: Lexing.position; + pos_end: Lexing.position; + path: DcePath.t; + is_interface: bool; +} +(** Simplified decl info for type-label processing *) + +let decl_to_info (decl : Decl.t) : decl_info option = + match decl.declKind with + | RecordLabel | VariantCase -> + let is_interface = + match List.rev decl.path with + | [] -> true + | moduleNameTag :: _ -> ( + try (moduleNameTag |> Name.toString).[0] <> '+' with _ -> true) + in + Some {pos = decl.pos; pos_end = decl.posEnd; path = decl.path; is_interface} + | _ -> None + +(** {1 Reactive Collections} *) + +type t = { + decl_by_path: (DcePath.t, decl_info list) Reactive.t; + same_path_refs: (Lexing.position, PosSet.t) Reactive.t; + cross_file_refs: (Lexing.position, PosSet.t) Reactive.t; + all_type_refs: (Lexing.position, PosSet.t) Reactive.t; +} +(** All reactive collections for type-label dependencies *) + +(** Create reactive type-label dependency collections from a decls collection *) +let create ~(decls : (Lexing.position, Decl.t) Reactive.t) + ~(report_types_dead_only_in_interface : bool) : t = + (* Step 1: Index decls by path *) + let decl_by_path = + Reactive.flatMap decls + ~f:(fun _pos decl -> + match decl_to_info decl with + | Some info -> [(info.path, [info])] + | None -> []) + ~merge:List.append () + in + + (* Step 2: Same-path refs - connect all decls at the same path *) + let same_path_refs = + Reactive.flatMap decl_by_path + ~f:(fun _path decls -> + match decls with + | [] | [_] -> [] + | first :: rest -> + (* Connect each decl to the first one (and vice-versa if needed) *) + rest + |> List.concat_map (fun other -> + let refs = + [(first.pos, PosSet.singleton other.pos); + (other.pos, PosSet.singleton first.pos)] + in + if report_types_dead_only_in_interface then + (* Only first -> other *) + [(other.pos, PosSet.singleton first.pos)] + else refs)) + ~merge:PosSet.union () + in + + (* Step 3: Cross-file refs - connect impl decls to intf decls *) + (* First, extract impl decls that need to look up intf *) + let impl_decls = + Reactive.flatMap decls + ~f:(fun _pos decl -> + match decl_to_info decl with + | Some info when not info.is_interface -> ( + match info.path with + | [] -> [] + | typeLabelName :: pathToType -> + (* Try two intf paths *) + let path_1 = pathToType |> DcePath.moduleToInterface in + let path_2 = path_1 |> DcePath.typeToInterface in + let intf_path1 = typeLabelName :: path_1 in + let intf_path2 = typeLabelName :: path_2 in + [(info.pos, (info, intf_path1, intf_path2))]) + | _ -> []) + () + in + + (* Join impl decls with decl_by_path to find intf *) + let impl_to_intf_refs = + Reactive.join impl_decls decl_by_path + ~key_of:(fun _pos (_, intf_path1, _) -> intf_path1) + ~f:(fun _pos (info, _intf_path1, intf_path2) intf_decls_opt -> + match intf_decls_opt with + | Some (intf_info :: _) -> + (* Found at path1, connect impl <-> intf *) + if report_types_dead_only_in_interface then + [(intf_info.pos, PosSet.singleton info.pos)] + else + [(info.pos, PosSet.singleton intf_info.pos); + (intf_info.pos, PosSet.singleton info.pos)] + | _ -> + (* Try path2 - need second join, but for now return placeholder *) + (* We'll handle path2 with a separate join below *) + [(info.pos, (intf_path2, info))] |> List.filter_map (fun _ -> None)) + ~merge:PosSet.union () + in + + (* Second join for path2 fallback *) + let impl_needing_path2 = + Reactive.join impl_decls decl_by_path + ~key_of:(fun _pos (_, intf_path1, _) -> intf_path1) + ~f:(fun pos (info, _intf_path1, intf_path2) intf_decls_opt -> + match intf_decls_opt with + | Some (_ :: _) -> [] (* Found at path1, skip *) + | _ -> [(pos, (info, intf_path2))]) + () + in + + let impl_to_intf_refs_path2 = + Reactive.join impl_needing_path2 decl_by_path + ~key_of:(fun _pos (_, intf_path2) -> intf_path2) + ~f:(fun _pos (info, _) intf_decls_opt -> + match intf_decls_opt with + | Some (intf_info :: _) -> + if report_types_dead_only_in_interface then + [(intf_info.pos, PosSet.singleton info.pos)] + else + [(info.pos, PosSet.singleton intf_info.pos); + (intf_info.pos, PosSet.singleton info.pos)] + | _ -> []) + ~merge:PosSet.union () + in + + (* Also handle intf -> impl direction *) + let intf_decls = + Reactive.flatMap decls + ~f:(fun _pos decl -> + match decl_to_info decl with + | Some info when info.is_interface -> ( + match info.path with + | [] -> [] + | typeLabelName :: pathToType -> + let impl_path = typeLabelName :: DcePath.moduleToImplementation pathToType in + [(info.pos, (info, impl_path))]) + | _ -> []) + () + in + + let intf_to_impl_refs = + Reactive.join intf_decls decl_by_path + ~key_of:(fun _pos (_, impl_path) -> impl_path) + ~f:(fun _pos (info, _) impl_decls_opt -> + match impl_decls_opt with + | Some (impl_info :: _) -> + if report_types_dead_only_in_interface then + [(info.pos, PosSet.singleton impl_info.pos)] + else + [(impl_info.pos, PosSet.singleton info.pos); + (info.pos, PosSet.singleton impl_info.pos)] + | _ -> []) + ~merge:PosSet.union () + in + + (* Combine all cross-file refs *) + let cross_file_refs = + Reactive.flatMap impl_to_intf_refs + ~f:(fun pos refs -> [(pos, refs)]) + ~merge:PosSet.union () + in + (* Merge in path2 refs *) + let cross_file_refs = + Reactive.flatMap impl_to_intf_refs_path2 + ~f:(fun pos refs -> [(pos, refs)]) + ~merge:PosSet.union () + |> fun refs2 -> + Reactive.flatMap cross_file_refs + ~f:(fun pos refs -> + let additional = + match Reactive.get refs2 pos with + | Some r -> r + | None -> PosSet.empty + in + [(pos, PosSet.union refs additional)]) + ~merge:PosSet.union () + in + (* Merge in intf->impl refs *) + let cross_file_refs = + Reactive.flatMap intf_to_impl_refs + ~f:(fun pos refs -> [(pos, refs)]) + ~merge:PosSet.union () + |> fun refs3 -> + Reactive.flatMap cross_file_refs + ~f:(fun pos refs -> + let additional = + match Reactive.get refs3 pos with + | Some r -> r + | None -> PosSet.empty + in + [(pos, PosSet.union refs additional)]) + ~merge:PosSet.union () + in + + (* Step 4: Combine same-path and cross-file refs *) + let all_type_refs = + Reactive.flatMap same_path_refs + ~f:(fun pos refs -> + let cross = + match Reactive.get cross_file_refs pos with + | Some r -> r + | None -> PosSet.empty + in + [(pos, PosSet.union refs cross)]) + ~merge:PosSet.union () + in + (* Also include cross-file refs that don't have same-path refs *) + let all_type_refs = + Reactive.flatMap cross_file_refs + ~f:(fun pos refs -> + match Reactive.get same_path_refs pos with + | Some _ -> [] (* Already included above *) + | None -> [(pos, refs)]) + ~merge:PosSet.union () + |> fun extra_refs -> + Reactive.flatMap all_type_refs + ~f:(fun pos refs -> + let extra = + match Reactive.get extra_refs pos with + | Some r -> r + | None -> PosSet.empty + in + [(pos, PosSet.union refs extra)]) + ~merge:PosSet.union () + in + + {decl_by_path; same_path_refs; cross_file_refs; all_type_refs} + +(** {1 Freezing for solver} *) + +(** Add all type refs to a References.builder *) +let add_to_refs_builder (t : t) ~(refs : References.builder) : unit = + Reactive.iter + (fun posTo posFromSet -> + PosSet.iter + (fun posFrom -> References.add_type_ref refs ~posTo ~posFrom) + posFromSet) + t.all_type_refs + diff --git a/analysis/reanalyze/src/ReactiveTypeDeps.mli b/analysis/reanalyze/src/ReactiveTypeDeps.mli new file mode 100644 index 0000000000..7c9e19c77d --- /dev/null +++ b/analysis/reanalyze/src/ReactiveTypeDeps.mli @@ -0,0 +1,55 @@ +(** Reactive type-label dependencies. + + Expresses the type-label dependency computation as a reactive pipeline. + When declarations change, only affected refs are recomputed. + + {2 Pipeline} + + {[ + decls + |> (flatMap) decl_by_path (* index by path *) + |> (flatMap) same_path_refs (* connect same-path duplicates *) + | + +-> (join) cross_file_refs (* connect impl <-> intf *) + | + +-> all_type_refs (* combined refs *) + ]} + + {2 Example} + + {[ + let reactive_decls = ReactiveMerge.create ... in + let type_deps = ReactiveTypeDeps.create + ~decls:reactive_decls.decls + ~report_types_dead_only_in_interface:true + in + (* Type refs update automatically when decls change *) + ReactiveTypeDeps.add_to_refs_builder type_deps ~refs:my_refs_builder + ]} *) + +(** {1 Types} *) + +type t +(** Reactive type-label dependency collections *) + +(** {1 Creation} *) + +val create : + decls:(Lexing.position, Decl.t) Reactive.t -> + report_types_dead_only_in_interface:bool -> + t +(** Create reactive type-label dependencies from a decls collection. + + When the [decls] collection changes, type refs automatically update. + + [report_types_dead_only_in_interface] controls whether refs are bidirectional + (false) or only intf->impl (true). *) + +(** {1 Freezing} *) + +val add_to_refs_builder : t -> refs:References.builder -> unit +(** Add all computed type refs to a References.builder. + + Call this after processing files to get the current type refs. + The builder will contain all type-label dependency refs. *) + diff --git a/analysis/src/DceCommand.ml b/analysis/src/DceCommand.ml index 6ff03172ae..66ddb6f06f 100644 --- a/analysis/src/DceCommand.ml +++ b/analysis/src/DceCommand.ml @@ -1,6 +1,7 @@ let command () = Reanalyze.RunConfig.dce (); let dce_config = Reanalyze.DceConfig.current () in - Reanalyze.runAnalysis ~dce_config ~cmtRoot:None ~reactive_collection:None; + Reanalyze.runAnalysis ~dce_config ~cmtRoot:None ~reactive_collection:None + ~reactive_merge:None; let issues = !Reanalyze.Log_.Stats.issues in Printf.printf "issues:%d\n" (List.length issues) From d686fd64a55667ae25f9fc3b43e9f2c8e4738b0d Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 08:17:37 +0100 Subject: [PATCH 08/45] Fix reactive mode @genType handling and update architecture docs Bug fix: - ReactiveFileCollection.process now receives (path, raw) instead of just (raw) - ReactiveAnalysis passes cmtFilePath to DceFileProcessing.process_cmt_file - This fixes @genType annotations being incorrectly collected when .cmti exists Previously, reactive mode passed cmtFilePath:"" which made the .cmti existence check always return false, causing @genType annotations to be collected even for files with interface files (where they should be ignored). Architecture updates: - Updated reactive-pipeline.mmd with accurate node names - Added legend table explaining all diagram symbols - Diagram now shows: VR/TR (per-file refs), all ReactiveTypeDeps fields, ReactiveExceptionRefs flow, and combined output Helper functions added for debugging (kept as useful): - Declarations.length, FileAnnotations.length/iter - References.value_refs_length/type_refs_length - FileDeps.files_count/deps_count --- analysis/reactive/src/Reactive.ml | 17 +- .../reactive/src/ReactiveFileCollection.ml | 4 +- .../reactive/src/ReactiveFileCollection.mli | 8 +- analysis/reactive/test/ReactiveTest.ml | 33 +++- analysis/reanalyze/ARCHITECTURE.md | 21 +++ .../reanalyze/diagrams/reactive-pipeline.mmd | 64 ++++--- .../reanalyze/diagrams/reactive-pipeline.svg | 2 +- analysis/reanalyze/src/Declarations.ml | 2 + analysis/reanalyze/src/Declarations.mli | 2 + analysis/reanalyze/src/FileAnnotations.ml | 4 + analysis/reanalyze/src/FileAnnotations.mli | 5 +- analysis/reanalyze/src/FileDeps.ml | 7 +- analysis/reanalyze/src/FileDeps.mli | 6 + analysis/reanalyze/src/ReactiveAnalysis.ml | 7 +- .../reanalyze/src/ReactiveExceptionRefs.ml | 6 +- .../reanalyze/src/ReactiveExceptionRefs.mli | 6 +- analysis/reanalyze/src/ReactiveMerge.ml | 113 +++++++++-- analysis/reanalyze/src/ReactiveMerge.mli | 4 +- analysis/reanalyze/src/ReactiveTypeDeps.ml | 176 +++++++----------- analysis/reanalyze/src/ReactiveTypeDeps.mli | 19 +- analysis/reanalyze/src/Reanalyze.ml | 75 +++++--- analysis/reanalyze/src/References.ml | 4 + analysis/reanalyze/src/References.mli | 4 + 23 files changed, 374 insertions(+), 215 deletions(-) diff --git a/analysis/reactive/src/Reactive.ml b/analysis/reactive/src/Reactive.ml index 05f2b6f29f..11e4836161 100644 --- a/analysis/reactive/src/Reactive.ml +++ b/analysis/reactive/src/Reactive.ml @@ -200,8 +200,9 @@ let lookup (source : ('k, 'v) t) ~key : ('k, 'v) t = When either collection changes, affected entries are recomputed. This is more efficient than nested flatMap for join patterns. *) let join (left : ('k1, 'v1) t) (right : ('k2, 'v2) t) - ~(key_of : 'k1 -> 'v1 -> 'k2) ~(f : 'k1 -> 'v1 -> 'v2 option -> ('k3 * 'v3) list) - ?merge () : ('k3, 'v3) t = + ~(key_of : 'k1 -> 'v1 -> 'k2) + ~(f : 'k1 -> 'v1 -> 'v2 option -> ('k3 * 'v3) list) ?merge () : ('k3, 'v3) t + = let merge_fn = match merge with | Some m -> m @@ -278,9 +279,9 @@ let join (left : ('k1, 'v1) t) (right : ('k2, 'v2) t) let old_affected = remove_left_contributions k1 in (* Update right key tracking *) (match Hashtbl.find_opt left_to_right_key k1 with - | Some old_k2 -> + | Some old_k2 -> ( Hashtbl.remove left_to_right_key k1; - (match Hashtbl.find_opt right_key_to_left_keys old_k2 with + match Hashtbl.find_opt right_key_to_left_keys old_k2 with | Some keys -> Hashtbl.replace right_key_to_left_keys old_k2 (List.filter (fun k -> k <> k1) keys) @@ -313,9 +314,9 @@ let join (left : ('k1, 'v1) t) (right : ('k2, 'v2) t) let affected = remove_left_contributions k1 in (* Clean up tracking *) (match Hashtbl.find_opt left_to_right_key k1 with - | Some k2 -> + | Some k2 -> ( Hashtbl.remove left_to_right_key k1; - (match Hashtbl.find_opt right_key_to_left_keys k2 with + match Hashtbl.find_opt right_key_to_left_keys k2 with | Some keys -> Hashtbl.replace right_key_to_left_keys k2 (List.filter (fun k -> k <> k1) keys) @@ -339,8 +340,8 @@ let join (left : ('k1, 'v1) t) (right : ('k2, 'v2) t) (* When right changes, reprocess all left entries that depend on it *) let downstream = match delta with - | Set (k2, _) | Remove k2 -> - (match Hashtbl.find_opt right_key_to_left_keys k2 with + | Set (k2, _) | Remove k2 -> ( + match Hashtbl.find_opt right_key_to_left_keys k2 with | None -> [] | Some left_keys -> left_keys diff --git a/analysis/reactive/src/ReactiveFileCollection.ml b/analysis/reactive/src/ReactiveFileCollection.ml index 88f9a77265..f634468197 100644 --- a/analysis/reactive/src/ReactiveFileCollection.ml +++ b/analysis/reactive/src/ReactiveFileCollection.ml @@ -16,7 +16,7 @@ let file_changed ~old_id ~new_id = type ('raw, 'v) internal = { cache: (string, file_id * 'v) Hashtbl.t; read_file: string -> 'raw; - process: 'raw -> 'v; + process: string -> 'raw -> 'v; (* path -> raw -> value *) mutable subscribers: ((string, 'v) Reactive.delta -> unit) list; } (** Internal state for file collection *) @@ -61,7 +61,7 @@ let process_if_changed t path = false (* unchanged *) | _ -> let raw = t.internal.read_file path in - let value = t.internal.process raw in + let value = t.internal.process path raw in Hashtbl.replace t.internal.cache path (new_id, value); emit t (Reactive.Set (path, value)); true (* changed *) diff --git a/analysis/reactive/src/ReactiveFileCollection.mli b/analysis/reactive/src/ReactiveFileCollection.mli index 3730c11d70..95a0ca9ef8 100644 --- a/analysis/reactive/src/ReactiveFileCollection.mli +++ b/analysis/reactive/src/ReactiveFileCollection.mli @@ -8,7 +8,7 @@ (* Create file collection *) let files = ReactiveFileCollection.create ~read_file:Cmt_format.read_cmt - ~process:(fun cmt -> extract_data cmt) + ~process:(fun path cmt -> extract_data path cmt) (* Compose with flatMap *) let decls = Reactive.flatMap (ReactiveFileCollection.to_collection files) @@ -27,8 +27,10 @@ type ('raw, 'v) t (** {1 Creation} *) -val create : read_file:(string -> 'raw) -> process:('raw -> 'v) -> ('raw, 'v) t -(** Create a new file collection. *) +val create : + read_file:(string -> 'raw) -> process:(string -> 'raw -> 'v) -> ('raw, 'v) t +(** Create a new file collection. + [process path raw] receives the file path and raw content to produce the value. *) (** {1 Composition} *) diff --git a/analysis/reactive/test/ReactiveTest.ml b/analysis/reactive/test/ReactiveTest.ml index 35ed4cc319..bdd6fc488f 100644 --- a/analysis/reactive/test/ReactiveTest.ml +++ b/analysis/reactive/test/ReactiveTest.ml @@ -244,7 +244,8 @@ let test_file_collection () = (* Create file collection: file -> word count map *) let files = - ReactiveFileCollection.create ~read_file:read_lines ~process:(fun lines -> + ReactiveFileCollection.create ~read_file:read_lines + ~process:(fun _path lines -> (* Count words within this file *) let counts = ref StringMap.empty in lines @@ -357,7 +358,8 @@ let test_lookup () = (* Set foo=42 *) emit (Set ("foo", 42)); - Printf.printf "After Set(foo, 42): lookup has %d entries\n" (length foo_lookup); + Printf.printf "After Set(foo, 42): lookup has %d entries\n" + (length foo_lookup); assert (length foo_lookup = 1); assert (get foo_lookup "foo" = Some 42); @@ -381,7 +383,8 @@ let test_lookup () = emit (Set ("bar", 2)); emit (Remove "foo"); - Printf.printf "Subscription received %d updates (expected 2: Set+Remove for foo)\n" + Printf.printf + "Subscription received %d updates (expected 2: Set+Remove for foo)\n" (List.length !updates); assert (List.length !updates = 2); @@ -441,13 +444,17 @@ let test_join () = (* Add declaration at path "A" with pos 100 *) emit_right (Set ("A", 100)); Printf.printf "After right Set(A, 100): joined=%d\n" (length joined); - assert (length joined = 0); (* No left entries yet *) + assert (length joined = 0); + + (* No left entries yet *) (* Add exception ref at path "A" from loc 1 *) emit_left (Set ("A", 1)); Printf.printf "After left Set(A, 1): joined=%d\n" (length joined); assert (length joined = 1); - assert (get joined 100 = Some 1); (* decl_pos 100 -> loc_from 1 *) + assert (get joined 100 = Some 1); + + (* decl_pos 100 -> loc_from 1 *) (* Add another exception ref at path "B" (no matching decl) *) emit_left (Set ("B", 2)); @@ -465,8 +472,11 @@ let test_join () = emit_right (Set ("B", 201)); Printf.printf "After right Set(B, 201): joined=%d\n" (length joined); assert (length joined = 2); - assert (get joined 200 = None); (* Old key gone *) - assert (get joined 201 = Some 2); (* New key has the value *) + assert (get joined 200 = None); + (* Old key gone *) + assert (get joined 201 = Some 2); + + (* New key has the value *) (* Remove left entry A *) emit_left (Remove "A"); @@ -528,18 +538,21 @@ let test_join_with_merge () = Printf.printf "Two entries looking up X (value 10): sum=%d\n" (get joined 0 |> Option.value ~default:0); - assert (get joined 0 = Some 20); (* 10 + 10 *) + assert (get joined 0 = Some 20); + (* 10 + 10 *) emit_right (Set ("X", 5)); Printf.printf "After right changes to 5: sum=%d\n" (get joined 0 |> Option.value ~default:0); - assert (get joined 0 = Some 10); (* 5 + 5 *) + assert (get joined 0 = Some 10); + (* 5 + 5 *) emit_left (Remove 1); Printf.printf "After removing one left entry: sum=%d\n" (get joined 0 |> Option.value ~default:0); - assert (get joined 0 = Some 5); (* Only one left *) + assert (get joined 0 = Some 5); + (* Only one left *) Printf.printf "PASSED\n\n" let () = diff --git a/analysis/reanalyze/ARCHITECTURE.md b/analysis/reanalyze/ARCHITECTURE.md index 9644b4f1f9..1f7d7a0d72 100644 --- a/analysis/reanalyze/ARCHITECTURE.md +++ b/analysis/reanalyze/ARCHITECTURE.md @@ -159,6 +159,27 @@ The reactive layer (`analysis/reactive/`) provides delta-based incremental updat ![Reactive Pipeline](diagrams/reactive-pipeline.svg) +**Legend:** + +| Symbol | Collection | Type | +|--------|-----------|------| +| **RFC** | `ReactiveFileCollection` | File change detection | +| **FD** | `file_data` | `path → file_data option` | +| **D** | `decls` | `pos → Decl.t` | +| **A** | `annotations` | `pos → annotation` | +| **VR** | `value_refs` | `pos → PosSet` (per-file) | +| **TR** | `type_refs` | `pos → PosSet` (per-file) | +| **CFI** | `cross_file_items` | `path → CrossFileItems.t` | +| **DBP** | `decl_by_path` | `path → decl_info list` | +| **SPR** | `same_path_refs` | Same-path duplicates | +| **I2I** | `impl_to_intf_refs` | Impl → Interface links | +| **I2I₂** | `impl_to_intf_refs_path2` | Impl → Interface (path2) | +| **I→I** | `intf_to_impl_refs` | Interface → Impl links | +| **ER** | `exception_refs` | Exception references | +| **ED** | `exception_decls` | Exception declarations | +| **RR** | `resolved_refs` | Resolved exception refs | +| **REFS** | Output | Combined `References.t` | + ### Delta Propagation > **Source**: [`diagrams/delta-propagation.mmd`](diagrams/delta-propagation.mmd) diff --git a/analysis/reanalyze/diagrams/reactive-pipeline.mmd b/analysis/reanalyze/diagrams/reactive-pipeline.mmd index c5d228cbd0..67cd539389 100644 --- a/analysis/reanalyze/diagrams/reactive-pipeline.mmd +++ b/analysis/reanalyze/diagrams/reactive-pipeline.mmd @@ -1,51 +1,63 @@ %%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#e8f4fd', 'primaryTextColor': '#1a1a1a', 'primaryBorderColor': '#4a90d9', 'lineColor': '#4a90d9', 'secondaryColor': '#f0f7e6', 'tertiaryColor': '#fff5e6'}}}%% flowchart TB subgraph FileLayer["File Layer"] - RFC[("ReactiveFileCollection
(file change detection)")] + RFC[("RFC")] end subgraph FileData["Per-File Data"] - FD["file_data
(path → file_data option)"] + FD["FD"] end - subgraph Extracted["Extracted Collections"] - DECLS["decls
(pos → Decl.t)"] - ANNOT["annotations
(pos → annotation)"] - EXCREF["exception_refs
(path → loc_from)"] + subgraph Extracted["Extracted"] + DECLS["D"] + ANNOT["A"] + VREFS["VR"] + TREFS["TR"] + CFI["CFI"] end subgraph TypeDeps["ReactiveTypeDeps"] - DBP["decl_by_path
(path → decl list)"] - SPR["same_path_refs
(pos → PosSet)"] - CFR["cross_file_refs
(pos → PosSet)"] - ATR["all_type_refs
(pos → PosSet)"] + DBP["DBP"] + SPR["SPR"] + I2I["I2I"] + I2I2["I2I₂"] + INT2IMP["I→I"] end subgraph ExcDeps["ReactiveExceptionRefs"] - EXCDECL["exception_decls
(path → loc)"] - RESOLVED["resolved_refs
(pos → PosSet)"] + EXCREF["ER"] + EXCDECL["ED"] + RESOLVED["RR"] end - subgraph Output["Combined Output"] - REFS["All refs
→ Ready for solver"] + subgraph Output["Output"] + REFS["REFS"] end - RFC -->|"process_files
(detect changes)"| FD - FD -->|"flatMap
(extract)"| DECLS - FD -->|"flatMap
(extract)"| ANNOT - FD -->|"flatMap
(extract)"| EXCREF + RFC -->|"process"| FD + FD -->|"flatMap"| DECLS + FD -->|"flatMap"| ANNOT + FD -->|"flatMap"| VREFS + FD -->|"flatMap"| TREFS + FD -->|"flatMap"| CFI DECLS -->|"flatMap"| DBP DBP -->|"flatMap"| SPR - DBP -->|"join"| CFR - SPR --> ATR - CFR --> ATR + DBP -->|"join"| I2I + DBP -->|"join"| I2I2 + DBP -->|"join"| INT2IMP + CFI -->|"flatMap"| EXCREF DECLS -->|"flatMap"| EXCDECL - EXCDECL -->|"join"| RESOLVED EXCREF -->|"join"| RESOLVED + EXCDECL -->|"join"| RESOLVED - ATR --> REFS + VREFS --> REFS + TREFS --> REFS + SPR --> REFS + I2I --> REFS + I2I2 --> REFS + INT2IMP --> REFS RESOLVED --> REFS classDef fileLayer fill:#e8f4fd,stroke:#4a90d9,stroke-width:2px @@ -55,8 +67,8 @@ flowchart TB classDef output fill:#e6ffe6,stroke:#2e8b2e,stroke-width:2px class RFC,FD fileLayer - class DECLS,ANNOT,EXCREF extracted - class DBP,SPR,CFR,ATR typeDeps - class EXCDECL,RESOLVED excDeps + class DECLS,ANNOT,VREFS,TREFS,CFI extracted + class DBP,SPR,I2I,I2I2,INT2IMP typeDeps + class EXCREF,EXCDECL,RESOLVED excDeps class REFS output diff --git a/analysis/reanalyze/diagrams/reactive-pipeline.svg b/analysis/reanalyze/diagrams/reactive-pipeline.svg index dfeacb4ba0..bc932f903f 100644 --- a/analysis/reanalyze/diagrams/reactive-pipeline.svg +++ b/analysis/reanalyze/diagrams/reactive-pipeline.svg @@ -1 +1 @@ -

Combined Output

ReactiveExceptionRefs

ReactiveTypeDeps

Extracted Collections

Per-File Data

File Layer

process_files
(detect changes)

flatMap
(extract)

flatMap
(extract)

flatMap
(extract)

flatMap

flatMap

join

flatMap

join

join

ReactiveFileCollection
(file change detection)

file_data
(path → file_data option)

decls
(pos → Decl.t)

annotations
(pos → annotation)

exception_refs
(path → loc_from)

decl_by_path
(path → decl list)

same_path_refs
(pos → PosSet)

cross_file_refs
(pos → PosSet)

all_type_refs
(pos → PosSet)

exception_decls
(path → loc)

resolved_refs
(pos → PosSet)

All refs
→ Ready for solver

\ No newline at end of file +

Output

ReactiveExceptionRefs

ReactiveTypeDeps

Extracted

Per-File Data

File Layer

process

flatMap

flatMap

flatMap

flatMap

flatMap

flatMap

flatMap

join

join

join

flatMap

flatMap

join

join

RFC

FD

D

A

VR

TR

CFI

DBP

SPR

I2I

I2I₂

I→I

ER

ED

RR

REFS

\ No newline at end of file diff --git a/analysis/reanalyze/src/Declarations.ml b/analysis/reanalyze/src/Declarations.ml index 0bcaa36b16..6b8dfedc7d 100644 --- a/analysis/reanalyze/src/Declarations.ml +++ b/analysis/reanalyze/src/Declarations.ml @@ -42,3 +42,5 @@ let find_opt (t : t) pos = PosHash.find_opt t pos let fold f (t : t) init = PosHash.fold f t init let iter f (t : t) = PosHash.iter f t + +let length (t : t) = PosHash.length t diff --git a/analysis/reanalyze/src/Declarations.mli b/analysis/reanalyze/src/Declarations.mli index 1d5180dc53..e6362ee2e9 100644 --- a/analysis/reanalyze/src/Declarations.mli +++ b/analysis/reanalyze/src/Declarations.mli @@ -38,3 +38,5 @@ val create_from_hashtbl : Decl.t PosHash.t -> t val find_opt : t -> Lexing.position -> Decl.t option val fold : (Lexing.position -> Decl.t -> 'a -> 'a) -> t -> 'a -> 'a val iter : (Lexing.position -> Decl.t -> unit) -> t -> unit + +val length : t -> int diff --git a/analysis/reanalyze/src/FileAnnotations.ml b/analysis/reanalyze/src/FileAnnotations.ml index 046805b564..60e78a0bb9 100644 --- a/analysis/reanalyze/src/FileAnnotations.ml +++ b/analysis/reanalyze/src/FileAnnotations.ml @@ -53,3 +53,7 @@ let is_annotated_gentype_or_dead (state : t) pos = match PosHash.find_opt state pos with | Some (Dead | GenType) -> true | Some Live | None -> false + +let length (t : t) = PosHash.length t + +let iter f (t : t) = PosHash.iter f t diff --git a/analysis/reanalyze/src/FileAnnotations.mli b/analysis/reanalyze/src/FileAnnotations.mli index 756264813e..292b5b5c12 100644 --- a/analysis/reanalyze/src/FileAnnotations.mli +++ b/analysis/reanalyze/src/FileAnnotations.mli @@ -9,8 +9,7 @@ (** {2 Types} *) -type annotated_as = GenType | Dead | Live -(** Annotation type *) +type annotated_as = GenType | Dead | Live (** Annotation type *) type t (** Immutable annotations - for solver (read-only) *) @@ -41,3 +40,5 @@ val create_from_hashtbl : annotated_as PosHash.t -> t val is_annotated_dead : t -> Lexing.position -> bool val is_annotated_gentype_or_live : t -> Lexing.position -> bool val is_annotated_gentype_or_dead : t -> Lexing.position -> bool +val length : t -> int +val iter : (Lexing.position -> annotated_as -> unit) -> t -> unit diff --git a/analysis/reanalyze/src/FileDeps.ml b/analysis/reanalyze/src/FileDeps.ml index 7c0440b687..ec83cb2896 100644 --- a/analysis/reanalyze/src/FileDeps.ml +++ b/analysis/reanalyze/src/FileDeps.ml @@ -69,7 +69,8 @@ let merge_all (builders : builder list) : t = let builder_files (builder : builder) : FileSet.t = builder.files let builder_deps_to_list (builder : builder) : (string * FileSet.t) list = - FileHash.fold (fun from_file to_files acc -> (from_file, to_files) :: acc) + FileHash.fold + (fun from_file to_files acc -> (from_file, to_files) :: acc) builder.deps [] let create ~files ~deps : t = {files; deps} @@ -87,6 +88,10 @@ let iter_deps (t : t) f = FileHash.iter f t.deps let file_exists (t : t) file = FileHash.mem t.deps file +let files_count (t : t) = FileSet.cardinal t.files + +let deps_count (t : t) = FileHash.length t.deps + (** {2 Topological ordering} *) let iter_files_from_roots_to_leaves (t : t) iterFun = diff --git a/analysis/reanalyze/src/FileDeps.mli b/analysis/reanalyze/src/FileDeps.mli index 2de875017e..1536d66451 100644 --- a/analysis/reanalyze/src/FileDeps.mli +++ b/analysis/reanalyze/src/FileDeps.mli @@ -65,6 +65,12 @@ val iter_deps : t -> (string -> FileSet.t -> unit) -> unit val file_exists : t -> string -> bool (** Check if a file exists in the graph. *) +val files_count : t -> int +(** Count of files in the file set. *) + +val deps_count : t -> int +(** Count of dependencies (number of from_file entries). *) + (** {2 Topological ordering} *) val iter_files_from_roots_to_leaves : t -> (string -> unit) -> unit diff --git a/analysis/reanalyze/src/ReactiveAnalysis.ml b/analysis/reanalyze/src/ReactiveAnalysis.ml index 48fe11b197..962b173771 100644 --- a/analysis/reanalyze/src/ReactiveAnalysis.ml +++ b/analysis/reanalyze/src/ReactiveAnalysis.ml @@ -20,7 +20,7 @@ type t = (Cmt_format.cmt_infos, cmt_file_result option) ReactiveFileCollection.t (** The reactive collection type *) (** Process cmt_infos into a file result *) -let process_cmt_infos ~config cmt_infos : cmt_file_result option = +let process_cmt_infos ~config ~cmtFilePath cmt_infos : cmt_file_result option = let excludePath sourceFile = config.DceConfig.cli.exclude_paths |> List.exists (fun prefix_ -> @@ -54,7 +54,7 @@ let process_cmt_infos ~config cmt_infos : cmt_file_result option = Some (cmt_infos |> DceFileProcessing.process_cmt_file ~config ~file:dce_file_context - ~cmtFilePath:"") + ~cmtFilePath) else None in let exception_data = @@ -70,7 +70,8 @@ let process_cmt_infos ~config cmt_infos : cmt_file_result option = (** Create a new reactive collection *) let create ~config : t = ReactiveFileCollection.create ~read_file:Cmt_format.read_cmt - ~process:(process_cmt_infos ~config) + ~process:(fun path cmt_infos -> + process_cmt_infos ~config ~cmtFilePath:path cmt_infos) (** Process all files incrementally using ReactiveFileCollection. First run processes all files. Subsequent runs only process changed files. *) diff --git a/analysis/reanalyze/src/ReactiveExceptionRefs.ml b/analysis/reanalyze/src/ReactiveExceptionRefs.ml index d2bf89da2c..675d5e0a9d 100644 --- a/analysis/reanalyze/src/ReactiveExceptionRefs.ml +++ b/analysis/reanalyze/src/ReactiveExceptionRefs.ml @@ -49,7 +49,10 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) match loc_to_opt with | Some loc_to -> (* Add value reference: pos_to -> pos_from *) - [(loc_to.Location.loc_start, PosSet.singleton loc_from.Location.loc_start)] + [ + ( loc_to.Location.loc_start, + PosSet.singleton loc_from.Location.loc_start ); + ] | None -> []) ~merge:PosSet.union () in @@ -79,4 +82,3 @@ let add_to_file_deps_builder (t : t) ~(file_deps : FileDeps.builder) : unit = FileDeps.add_dep file_deps ~from_file ~to_file) posFromSet) t.resolved_refs - diff --git a/analysis/reanalyze/src/ReactiveExceptionRefs.mli b/analysis/reanalyze/src/ReactiveExceptionRefs.mli index 2e7f583497..95f24a34c9 100644 --- a/analysis/reanalyze/src/ReactiveExceptionRefs.mli +++ b/analysis/reanalyze/src/ReactiveExceptionRefs.mli @@ -31,7 +31,10 @@ (** {1 Types} *) -type t +type t = { + exception_decls: (DcePath.t, Location.t) Reactive.t; + resolved_refs: (Lexing.position, PosSet.t) Reactive.t; +} (** Reactive exception ref collections *) (** {1 Creation} *) @@ -51,4 +54,3 @@ val add_to_refs_builder : t -> refs:References.builder -> unit val add_to_file_deps_builder : t -> file_deps:FileDeps.builder -> unit (** Add file dependencies for resolved refs. *) - diff --git a/analysis/reanalyze/src/ReactiveMerge.ml b/analysis/reanalyze/src/ReactiveMerge.ml index 5bd111d09a..9f1319de4e 100644 --- a/analysis/reanalyze/src/ReactiveMerge.ml +++ b/analysis/reanalyze/src/ReactiveMerge.ml @@ -13,6 +13,9 @@ type t = { cross_file_items: (string, CrossFileItems.t) Reactive.t; file_deps_map: (string, FileSet.t) Reactive.t; files: (string, unit) Reactive.t; + (* Reactive type/exception dependencies *) + type_deps: ReactiveTypeDeps.t; + exception_refs: ReactiveExceptionRefs.t; } (** All derived reactive collections from per-file data *) @@ -38,7 +41,8 @@ let create (source : (string, DceFileProcessing.file_data option) Reactive.t) : match file_data_opt with | None -> [] | Some file_data -> - FileAnnotations.builder_to_list file_data.DceFileProcessing.annotations) + FileAnnotations.builder_to_list + file_data.DceFileProcessing.annotations) () in @@ -96,21 +100,55 @@ let create (source : (string, DceFileProcessing.file_data option) Reactive.t) : ~merge:FileSet.union () in - (* Files set: (path, ()) - just track which files exist *) + (* Files set: (source_path, ()) - just track which source files exist *) let files = Reactive.flatMap source - ~f:(fun path file_data_opt -> + ~f:(fun _cmt_path file_data_opt -> match file_data_opt with | None -> [] | Some file_data -> - (* Include the file and all files it references *) - let file_set = FileDeps.builder_files file_data.DceFileProcessing.file_deps in - let entries = FileSet.fold (fun f acc -> (f, ()) :: acc) file_set [] in - (path, ()) :: entries) + (* Include all source files from file_deps (NOT the CMT path) *) + let file_set = + FileDeps.builder_files file_data.DceFileProcessing.file_deps + in + FileSet.fold (fun f acc -> (f, ()) :: acc) file_set []) + () + in + + (* Extract exception_refs from cross_file_items for ReactiveExceptionRefs *) + let exception_refs_collection = + Reactive.flatMap cross_file_items + ~f:(fun _path items -> + items.CrossFileItems.exception_refs + |> List.map (fun (r : CrossFileItems.exception_ref) -> + (r.exception_path, r.loc_from))) () in - {decls; annotations; value_refs; type_refs; cross_file_items; file_deps_map; files} + (* Create reactive type-label dependencies *) + let type_deps = + ReactiveTypeDeps.create ~decls + ~report_types_dead_only_in_interface: + DeadCommon.Config.reportTypesDeadOnlyInInterface + in + + (* Create reactive exception refs resolution *) + let exception_refs = + ReactiveExceptionRefs.create ~decls + ~exception_refs:exception_refs_collection + in + + { + decls; + annotations; + value_refs; + type_refs; + cross_file_items; + file_deps_map; + files; + type_deps; + exception_refs; + } (** {1 Conversion to solver-ready format} *) @@ -126,14 +164,41 @@ let freeze_annotations (t : t) : FileAnnotations.t = Reactive.iter (fun pos ann -> PosHash.replace result pos ann) t.annotations; FileAnnotations.create_from_hashtbl result -(** Convert reactive refs to References.t for solver *) +(** Convert reactive refs to References.t for solver. + Includes type-label deps and exception refs from reactive computations. *) let freeze_refs (t : t) : References.t = let value_refs = PosHash.create 256 in let type_refs = PosHash.create 256 in + (* Helper to merge refs into a hashtable *) + let merge_into tbl posTo posFromSet = + let existing = + match PosHash.find_opt tbl posTo with + | Some s -> s + | None -> PosSet.empty + in + PosHash.replace tbl posTo (PosSet.union existing posFromSet) + in + (* Merge per-file value refs *) + Reactive.iter (fun pos refs -> merge_into value_refs pos refs) t.value_refs; + (* Merge per-file type refs *) + Reactive.iter (fun pos refs -> merge_into type_refs pos refs) t.type_refs; + (* Add type-label dependency refs from all sources *) + Reactive.iter + (fun pos refs -> merge_into type_refs pos refs) + t.type_deps.same_path_refs; + Reactive.iter + (fun pos refs -> merge_into type_refs pos refs) + t.type_deps.cross_file_refs; Reactive.iter - (fun pos refs -> PosHash.replace value_refs pos refs) - t.value_refs; - Reactive.iter (fun pos refs -> PosHash.replace type_refs pos refs) t.type_refs; + (fun pos refs -> merge_into type_refs pos refs) + t.type_deps.impl_to_intf_refs_path2; + Reactive.iter + (fun pos refs -> merge_into type_refs pos refs) + t.type_deps.intf_to_impl_refs; + (* Add exception refs (to value refs) *) + Reactive.iter + (fun pos refs -> merge_into value_refs pos refs) + t.exception_refs.resolved_refs; References.create ~value_refs ~type_refs (** Collect all cross-file items *) @@ -154,7 +219,8 @@ let collect_cross_file_items (t : t) : CrossFileItems.t = function_refs = !function_refs; } -(** Convert reactive file deps to FileDeps.t for solver *) +(** Convert reactive file deps to FileDeps.t for solver. + Includes file deps from exception refs. *) let freeze_file_deps (t : t) : FileDeps.t = let files = let result = ref FileSet.empty in @@ -163,7 +229,24 @@ let freeze_file_deps (t : t) : FileDeps.t = in let deps = FileDeps.FileHash.create 256 in Reactive.iter - (fun from_file to_files -> FileDeps.FileHash.replace deps from_file to_files) + (fun from_file to_files -> + FileDeps.FileHash.replace deps from_file to_files) t.file_deps_map; + (* Add file deps from exception refs *) + Reactive.iter + (fun posTo posFromSet -> + PosSet.iter + (fun posFrom -> + let from_file = posFrom.Lexing.pos_fname in + let to_file = posTo.Lexing.pos_fname in + if from_file <> to_file then + let existing = + match FileDeps.FileHash.find_opt deps from_file with + | Some s -> s + | None -> FileSet.empty + in + FileDeps.FileHash.replace deps from_file + (FileSet.add to_file existing)) + posFromSet) + t.exception_refs.resolved_refs; FileDeps.create ~files ~deps - diff --git a/analysis/reanalyze/src/ReactiveMerge.mli b/analysis/reanalyze/src/ReactiveMerge.mli index 03dd06bb44..6f0c3503b8 100644 --- a/analysis/reanalyze/src/ReactiveMerge.mli +++ b/analysis/reanalyze/src/ReactiveMerge.mli @@ -32,6 +32,9 @@ type t = { cross_file_items: (string, CrossFileItems.t) Reactive.t; file_deps_map: (string, FileSet.t) Reactive.t; files: (string, unit) Reactive.t; + (* Reactive type/exception dependencies *) + type_deps: ReactiveTypeDeps.t; + exception_refs: ReactiveExceptionRefs.t; } (** All derived reactive collections from per-file data *) @@ -57,4 +60,3 @@ val collect_cross_file_items : t -> CrossFileItems.t val freeze_file_deps : t -> FileDeps.t (** Convert reactive file deps to FileDeps.t for solver *) - diff --git a/analysis/reanalyze/src/ReactiveTypeDeps.ml b/analysis/reanalyze/src/ReactiveTypeDeps.ml index bb9aa03931..f42102c11d 100644 --- a/analysis/reanalyze/src/ReactiveTypeDeps.ml +++ b/analysis/reanalyze/src/ReactiveTypeDeps.ml @@ -36,6 +36,9 @@ type t = { same_path_refs: (Lexing.position, PosSet.t) Reactive.t; cross_file_refs: (Lexing.position, PosSet.t) Reactive.t; all_type_refs: (Lexing.position, PosSet.t) Reactive.t; + (* Additional cross-file sources for complete coverage *) + impl_to_intf_refs_path2: (Lexing.position, PosSet.t) Reactive.t; + intf_to_impl_refs: (Lexing.position, PosSet.t) Reactive.t; } (** All reactive collections for type-label dependencies *) @@ -59,17 +62,17 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) match decls with | [] | [_] -> [] | first :: rest -> - (* Connect each decl to the first one (and vice-versa if needed) *) + (* Connect each decl to the first one (and vice-versa if needed). + Original: extendTypeDependencies loc loc0 adds posTo=loc, posFrom=loc0 + So: posTo=other, posFrom=first *) rest |> List.concat_map (fun other -> - let refs = - [(first.pos, PosSet.singleton other.pos); - (other.pos, PosSet.singleton first.pos)] - in - if report_types_dead_only_in_interface then - (* Only first -> other *) - [(other.pos, PosSet.singleton first.pos)] - else refs)) + (* Always add: other -> first (posTo=other, posFrom=first) *) + let refs = [(other.pos, PosSet.singleton first.pos)] in + if report_types_dead_only_in_interface then refs + else + (* Also add: first -> other (posTo=first, posFrom=other) *) + (first.pos, PosSet.singleton other.pos) :: refs)) ~merge:PosSet.union () in @@ -93,23 +96,22 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) () in - (* Join impl decls with decl_by_path to find intf *) + (* Join impl decls with decl_by_path to find intf. + Original: extendTypeDependencies loc loc1 where loc=impl, loc1=intf + adds posTo=impl, posFrom=intf *) let impl_to_intf_refs = Reactive.join impl_decls decl_by_path ~key_of:(fun _pos (_, intf_path1, _) -> intf_path1) - ~f:(fun _pos (info, _intf_path1, intf_path2) intf_decls_opt -> + ~f:(fun _pos (info, _intf_path1, _intf_path2) intf_decls_opt -> match intf_decls_opt with | Some (intf_info :: _) -> - (* Found at path1, connect impl <-> intf *) - if report_types_dead_only_in_interface then - [(intf_info.pos, PosSet.singleton info.pos)] + (* Found at path1: posTo=impl, posFrom=intf *) + let refs = [(info.pos, PosSet.singleton intf_info.pos)] in + if report_types_dead_only_in_interface then refs else - [(info.pos, PosSet.singleton intf_info.pos); - (intf_info.pos, PosSet.singleton info.pos)] - | _ -> - (* Try path2 - need second join, but for now return placeholder *) - (* We'll handle path2 with a separate join below *) - [(info.pos, (intf_path2, info))] |> List.filter_map (fun _ -> None)) + (* Also: posTo=intf, posFrom=impl *) + (intf_info.pos, PosSet.singleton info.pos) :: refs + | _ -> []) ~merge:PosSet.union () in @@ -130,16 +132,19 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) ~f:(fun _pos (info, _) intf_decls_opt -> match intf_decls_opt with | Some (intf_info :: _) -> - if report_types_dead_only_in_interface then - [(intf_info.pos, PosSet.singleton info.pos)] - else - [(info.pos, PosSet.singleton intf_info.pos); - (intf_info.pos, PosSet.singleton info.pos)] + (* posTo=impl, posFrom=intf *) + let refs = [(info.pos, PosSet.singleton intf_info.pos)] in + if report_types_dead_only_in_interface then refs + else (intf_info.pos, PosSet.singleton info.pos) :: refs | _ -> []) ~merge:PosSet.union () in - (* Also handle intf -> impl direction *) + (* Also handle intf -> impl direction. + Original: extendTypeDependencies loc1 loc where loc=impl, loc1=intf + adds posTo=impl, posFrom=intf (note: same direction!) + The intf->impl code in original only runs when isInterface=true, + and the lookup is for finding the impl. *) let intf_decls = Reactive.flatMap decls ~f:(fun _pos decl -> @@ -148,7 +153,9 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) match info.path with | [] -> [] | typeLabelName :: pathToType -> - let impl_path = typeLabelName :: DcePath.moduleToImplementation pathToType in + let impl_path = + typeLabelName :: DcePath.moduleToImplementation pathToType + in [(info.pos, (info, impl_path))]) | _ -> []) () @@ -157,90 +164,48 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) let intf_to_impl_refs = Reactive.join intf_decls decl_by_path ~key_of:(fun _pos (_, impl_path) -> impl_path) - ~f:(fun _pos (info, _) impl_decls_opt -> + ~f:(fun _pos (intf_info, _) impl_decls_opt -> match impl_decls_opt with | Some (impl_info :: _) -> - if report_types_dead_only_in_interface then - [(info.pos, PosSet.singleton impl_info.pos)] - else - [(impl_info.pos, PosSet.singleton info.pos); - (info.pos, PosSet.singleton impl_info.pos)] + (* Original: extendTypeDependencies loc1 loc where loc1=intf, loc=impl + But wait, looking at the original code more carefully: + + if isInterface then + match find_one path1 with + | None -> () + | Some loc1 -> + extendTypeDependencies ~config ~refs loc1 loc; + if not Config.reportTypesDeadOnlyInInterface then + extendTypeDependencies ~config ~refs loc loc1 + + Here loc is the current intf decl, loc1 is the found impl. + So extendTypeDependencies loc1 loc means posTo=loc1=impl, posFrom=loc=intf + *) + let refs = [(impl_info.pos, PosSet.singleton intf_info.pos)] in + if report_types_dead_only_in_interface then refs + else (intf_info.pos, PosSet.singleton impl_info.pos) :: refs | _ -> []) ~merge:PosSet.union () in - (* Combine all cross-file refs *) - let cross_file_refs = - Reactive.flatMap impl_to_intf_refs - ~f:(fun pos refs -> [(pos, refs)]) - ~merge:PosSet.union () - in - (* Merge in path2 refs *) - let cross_file_refs = - Reactive.flatMap impl_to_intf_refs_path2 - ~f:(fun pos refs -> [(pos, refs)]) - ~merge:PosSet.union () - |> fun refs2 -> - Reactive.flatMap cross_file_refs - ~f:(fun pos refs -> - let additional = - match Reactive.get refs2 pos with - | Some r -> r - | None -> PosSet.empty - in - [(pos, PosSet.union refs additional)]) - ~merge:PosSet.union () - in - (* Merge in intf->impl refs *) - let cross_file_refs = - Reactive.flatMap intf_to_impl_refs - ~f:(fun pos refs -> [(pos, refs)]) - ~merge:PosSet.union () - |> fun refs3 -> - Reactive.flatMap cross_file_refs - ~f:(fun pos refs -> - let additional = - match Reactive.get refs3 pos with - | Some r -> r - | None -> PosSet.empty - in - [(pos, PosSet.union refs additional)]) - ~merge:PosSet.union () - in - - (* Step 4: Combine same-path and cross-file refs *) - let all_type_refs = - Reactive.flatMap same_path_refs - ~f:(fun pos refs -> - let cross = - match Reactive.get cross_file_refs pos with - | Some r -> r - | None -> PosSet.empty - in - [(pos, PosSet.union refs cross)]) - ~merge:PosSet.union () - in - (* Also include cross-file refs that don't have same-path refs *) - let all_type_refs = - Reactive.flatMap cross_file_refs - ~f:(fun pos refs -> - match Reactive.get same_path_refs pos with - | Some _ -> [] (* Already included above *) - | None -> [(pos, refs)]) - ~merge:PosSet.union () - |> fun extra_refs -> - Reactive.flatMap all_type_refs - ~f:(fun pos refs -> - let extra = - match Reactive.get extra_refs pos with - | Some r -> r - | None -> PosSet.empty - in - [(pos, PosSet.union refs extra)]) - ~merge:PosSet.union () - in - - {decl_by_path; same_path_refs; cross_file_refs; all_type_refs} + (* Cross-file refs are the combination of: + - impl_to_intf_refs (path1 matches) + - impl_to_intf_refs_path2 (path2 fallback) + - intf_to_impl_refs *) + let cross_file_refs = impl_to_intf_refs in + + (* All type refs = same_path_refs + all cross-file sources. + We expose these separately and merge in freeze_refs. *) + let all_type_refs = same_path_refs in + + { + decl_by_path; + same_path_refs; + cross_file_refs; + all_type_refs; + impl_to_intf_refs_path2; + intf_to_impl_refs; + } (** {1 Freezing for solver} *) @@ -252,4 +217,3 @@ let add_to_refs_builder (t : t) ~(refs : References.builder) : unit = (fun posFrom -> References.add_type_ref refs ~posTo ~posFrom) posFromSet) t.all_type_refs - diff --git a/analysis/reanalyze/src/ReactiveTypeDeps.mli b/analysis/reanalyze/src/ReactiveTypeDeps.mli index 7c9e19c77d..5836719baa 100644 --- a/analysis/reanalyze/src/ReactiveTypeDeps.mli +++ b/analysis/reanalyze/src/ReactiveTypeDeps.mli @@ -29,9 +29,25 @@ (** {1 Types} *) -type t +type t = { + decl_by_path: (DcePath.t, decl_info list) Reactive.t; + same_path_refs: (Lexing.position, PosSet.t) Reactive.t; + cross_file_refs: (Lexing.position, PosSet.t) Reactive.t; + all_type_refs: (Lexing.position, PosSet.t) Reactive.t; + (* Additional cross-file sources for complete coverage *) + impl_to_intf_refs_path2: (Lexing.position, PosSet.t) Reactive.t; + intf_to_impl_refs: (Lexing.position, PosSet.t) Reactive.t; +} (** Reactive type-label dependency collections *) +and decl_info = { + pos: Lexing.position; + pos_end: Lexing.position; + path: DcePath.t; + is_interface: bool; +} +(** Simplified decl info for type-label processing *) + (** {1 Creation} *) val create : @@ -52,4 +68,3 @@ val add_to_refs_builder : t -> refs:References.builder -> unit Call this after processing files to get the current type refs. The builder will contain all type-label dependency refs. *) - diff --git a/analysis/reanalyze/src/Reanalyze.ml b/analysis/reanalyze/src/Reanalyze.ml index 0b0359c050..927f264eed 100644 --- a/analysis/reanalyze/src/Reanalyze.ml +++ b/analysis/reanalyze/src/Reanalyze.ml @@ -288,38 +288,51 @@ let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge = (dce_data_list |> List.map (fun fd -> fd.DceFileProcessing.cross_file)) ) in - (* Merge refs and file_deps into builders for cross-file items processing. - This still needs the file_data iteration for post-processing. *) - let refs_builder = References.create_builder () in - let file_deps_builder = FileDeps.create_builder () in - (match reactive_collection with - | Some collection -> - ReactiveAnalysis.iter_file_data collection (fun fd -> - References.merge_into_builder ~from:fd.DceFileProcessing.refs - ~into:refs_builder; - FileDeps.merge_into_builder - ~from:fd.DceFileProcessing.file_deps - ~into:file_deps_builder) - | None -> - dce_data_list - |> List.iter (fun fd -> - References.merge_into_builder - ~from:fd.DceFileProcessing.refs ~into:refs_builder; - FileDeps.merge_into_builder - ~from:fd.DceFileProcessing.file_deps - ~into:file_deps_builder)); - (* Compute type-label dependencies after merge *) - DeadType.process_type_label_dependencies ~config:dce_config ~decls - ~refs:refs_builder; - let find_exception = - DeadException.find_exception_from_decls decls + (* Compute refs and file_deps. + In reactive mode, ReactiveMerge handles type deps and exception refs. + In non-reactive mode, use the imperative processing. *) + let refs, file_deps = + match reactive_merge with + | Some merged -> + (* Reactive mode: freeze_refs includes type deps and exception refs *) + let refs = ReactiveMerge.freeze_refs merged in + let file_deps = ReactiveMerge.freeze_file_deps merged in + (refs, file_deps) + | None -> + (* Non-reactive mode: build refs/file_deps imperatively *) + let refs_builder = References.create_builder () in + let file_deps_builder = FileDeps.create_builder () in + (match reactive_collection with + | Some collection -> + ReactiveAnalysis.iter_file_data collection (fun fd -> + References.merge_into_builder + ~from:fd.DceFileProcessing.refs ~into:refs_builder; + FileDeps.merge_into_builder + ~from:fd.DceFileProcessing.file_deps + ~into:file_deps_builder) + | None -> + dce_data_list + |> List.iter (fun fd -> + References.merge_into_builder + ~from:fd.DceFileProcessing.refs ~into:refs_builder; + FileDeps.merge_into_builder + ~from:fd.DceFileProcessing.file_deps + ~into:file_deps_builder)); + (* Compute type-label dependencies after merge *) + DeadType.process_type_label_dependencies ~config:dce_config + ~decls ~refs:refs_builder; + let find_exception = + DeadException.find_exception_from_decls decls + in + (* Process cross-file exception refs *) + CrossFileItems.process_exception_refs cross_file + ~refs:refs_builder ~file_deps:file_deps_builder + ~find_exception ~config:dce_config; + (* Freeze refs and file_deps for solver *) + let refs = References.freeze_builder refs_builder in + let file_deps = FileDeps.freeze_builder file_deps_builder in + (refs, file_deps) in - (* Process cross-file exception refs *) - CrossFileItems.process_exception_refs cross_file ~refs:refs_builder - ~file_deps:file_deps_builder ~find_exception ~config:dce_config; - (* Freeze refs and file_deps for solver *) - let refs = References.freeze_builder refs_builder in - let file_deps = FileDeps.freeze_builder file_deps_builder in (annotations, decls, cross_file, refs, file_deps)) in (* Solving phase: run the solver and collect issues *) diff --git a/analysis/reanalyze/src/References.ml b/analysis/reanalyze/src/References.ml index 60fd7bfafd..c566aedd9b 100644 --- a/analysis/reanalyze/src/References.ml +++ b/analysis/reanalyze/src/References.ml @@ -67,3 +67,7 @@ let create ~value_refs ~type_refs : t = {value_refs; type_refs} let find_value_refs (t : t) pos = findSet t.value_refs pos let find_type_refs (t : t) pos = findSet t.type_refs pos + +let value_refs_length (t : t) = PosHash.length t.value_refs + +let type_refs_length (t : t) = PosHash.length t.type_refs diff --git a/analysis/reanalyze/src/References.mli b/analysis/reanalyze/src/References.mli index 5776ca615c..89f653657d 100644 --- a/analysis/reanalyze/src/References.mli +++ b/analysis/reanalyze/src/References.mli @@ -47,3 +47,7 @@ val create : value_refs:PosSet.t PosHash.t -> type_refs:PosSet.t PosHash.t -> t val find_value_refs : t -> Lexing.position -> PosSet.t val find_type_refs : t -> Lexing.position -> PosSet.t + +val value_refs_length : t -> int + +val type_refs_length : t -> int From 038d0a0b05103b1e3021f3b47c43bed68ce1c79f Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 08:54:15 +0100 Subject: [PATCH 09/45] Add store abstractions to eliminate merge overhead in reactive mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce five new store modules that wrap reactive collections directly, eliminating O(N) freeze/copy operations in the merge phase: - AnnotationStore: wraps FileAnnotations reactive collection - DeclarationStore: wraps Declarations reactive collection - ReferenceStore: combines 7 reactive sources (value_refs, type_refs, type_deps.*, exception_refs) without copying - FileDepsStore: wraps file deps reactive collections - CrossFileItemsStore: iterates reactive collection directly without intermediate allocation Performance improvement on 4900-file benchmark: - Merge time: 37ms → 0.002ms (eliminated) - Warm run total: 165ms → 129ms (22% faster) The stores provide a unified interface that works with both frozen (non-reactive) and reactive data, dispatching to the appropriate implementation at runtime. Signed-off-by: Cristiano Calcagno --- analysis/reanalyze/src/AnnotationStore.ml | 34 +++++++ analysis/reanalyze/src/AnnotationStore.mli | 19 ++++ analysis/reanalyze/src/CrossFileItems.ml | 39 -------- analysis/reanalyze/src/CrossFileItems.mli | 5 - analysis/reanalyze/src/CrossFileItemsStore.ml | 68 ++++++++++++++ .../reanalyze/src/CrossFileItemsStore.mli | 30 ++++++ analysis/reanalyze/src/DeadCommon.ml | 65 ++++++------- analysis/reanalyze/src/DeadOptionalArgs.ml | 4 +- analysis/reanalyze/src/DeclarationStore.ml | 33 +++++++ analysis/reanalyze/src/DeclarationStore.mli | 27 ++++++ analysis/reanalyze/src/FileDepsStore.ml | 46 ++++++++++ analysis/reanalyze/src/FileDepsStore.mli | 28 ++++++ analysis/reanalyze/src/Reanalyze.ml | 92 +++++++++++++------ analysis/reanalyze/src/ReferenceStore.ml | 68 ++++++++++++++ analysis/reanalyze/src/ReferenceStore.mli | 27 ++++++ 15 files changed, 476 insertions(+), 109 deletions(-) create mode 100644 analysis/reanalyze/src/AnnotationStore.ml create mode 100644 analysis/reanalyze/src/AnnotationStore.mli create mode 100644 analysis/reanalyze/src/CrossFileItemsStore.ml create mode 100644 analysis/reanalyze/src/CrossFileItemsStore.mli create mode 100644 analysis/reanalyze/src/DeclarationStore.ml create mode 100644 analysis/reanalyze/src/DeclarationStore.mli create mode 100644 analysis/reanalyze/src/FileDepsStore.ml create mode 100644 analysis/reanalyze/src/FileDepsStore.mli create mode 100644 analysis/reanalyze/src/ReferenceStore.ml create mode 100644 analysis/reanalyze/src/ReferenceStore.mli diff --git a/analysis/reanalyze/src/AnnotationStore.ml b/analysis/reanalyze/src/AnnotationStore.ml new file mode 100644 index 0000000000..b34dbce8e7 --- /dev/null +++ b/analysis/reanalyze/src/AnnotationStore.ml @@ -0,0 +1,34 @@ +(** Abstraction over annotation storage. + + Allows the solver to work with either: + - [Frozen]: Traditional [FileAnnotations.t] (copied from reactive) + - [Reactive]: Direct [Reactive.t] (no copy, zero-cost on warm runs) *) + +type t = + | Frozen of FileAnnotations.t + | Reactive of (Lexing.position, FileAnnotations.annotated_as) Reactive.t + +let of_frozen ann = Frozen ann + +let of_reactive reactive = Reactive reactive + +let is_annotated_dead t pos = + match t with + | Frozen ann -> FileAnnotations.is_annotated_dead ann pos + | Reactive reactive -> Reactive.get reactive pos = Some FileAnnotations.Dead + +let is_annotated_gentype_or_live t pos = + match t with + | Frozen ann -> FileAnnotations.is_annotated_gentype_or_live ann pos + | Reactive reactive -> ( + match Reactive.get reactive pos with + | Some (FileAnnotations.Live | FileAnnotations.GenType) -> true + | Some FileAnnotations.Dead | None -> false) + +let is_annotated_gentype_or_dead t pos = + match t with + | Frozen ann -> FileAnnotations.is_annotated_gentype_or_dead ann pos + | Reactive reactive -> ( + match Reactive.get reactive pos with + | Some (FileAnnotations.Dead | FileAnnotations.GenType) -> true + | Some FileAnnotations.Live | None -> false) diff --git a/analysis/reanalyze/src/AnnotationStore.mli b/analysis/reanalyze/src/AnnotationStore.mli new file mode 100644 index 0000000000..0c8e099fd8 --- /dev/null +++ b/analysis/reanalyze/src/AnnotationStore.mli @@ -0,0 +1,19 @@ +(** Abstraction over annotation storage. + + Allows the solver to work with either: + - [Frozen]: Traditional [FileAnnotations.t] (copied from reactive) + - [Reactive]: Direct [Reactive.t] (no copy, zero-cost on warm runs) *) + +type t +(** Abstract annotation store *) + +val of_frozen : FileAnnotations.t -> t +(** Wrap a frozen [FileAnnotations.t] *) + +val of_reactive : + (Lexing.position, FileAnnotations.annotated_as) Reactive.t -> t +(** Wrap a reactive collection directly (no copy) *) + +val is_annotated_dead : t -> Lexing.position -> bool +val is_annotated_gentype_or_live : t -> Lexing.position -> bool +val is_annotated_gentype_or_dead : t -> Lexing.position -> bool diff --git a/analysis/reanalyze/src/CrossFileItems.ml b/analysis/reanalyze/src/CrossFileItems.ml index 8b72d84120..f51e55a468 100644 --- a/analysis/reanalyze/src/CrossFileItems.ml +++ b/analysis/reanalyze/src/CrossFileItems.ml @@ -78,42 +78,3 @@ let process_exception_refs (t : t) ~refs ~file_deps ~find_exception ~config = DeadCommon.addValueReference ~config ~refs ~file_deps ~binding:Location.none ~addFileReference:true ~locFrom:loc_from ~locTo:loc_to) - -(** Compute optional args state from calls and function references. - Returns a map from position to final OptionalArgs.t state. - Pure function - does not mutate declarations. *) -let compute_optional_args_state (t : t) ~decls ~is_live : OptionalArgsState.t = - let state = OptionalArgsState.create () in - (* Initialize state from declarations *) - let get_state pos = - match OptionalArgsState.find_opt state pos with - | Some s -> s - | None -> ( - match Declarations.find_opt decls pos with - | Some {declKind = Value {optionalArgs}} -> optionalArgs - | _ -> OptionalArgs.empty) - in - let set_state pos s = OptionalArgsState.set state pos s in - (* Process optional arg calls *) - t.optional_arg_calls - |> List.iter (fun {pos_from; pos_to; arg_names; arg_names_maybe} -> - if is_live pos_from then - let current = get_state pos_to in - let updated = - OptionalArgs.apply_call ~argNames:arg_names - ~argNamesMaybe:arg_names_maybe current - in - set_state pos_to updated); - (* Process function references *) - t.function_refs - |> List.iter (fun {pos_from; pos_to} -> - if is_live pos_from then - let state_from = get_state pos_from in - let state_to = get_state pos_to in - if not (OptionalArgs.isEmpty state_to) then ( - let updated_from, updated_to = - OptionalArgs.combine_pair state_from state_to - in - set_state pos_from updated_from; - set_state pos_to updated_to)); - state diff --git a/analysis/reanalyze/src/CrossFileItems.mli b/analysis/reanalyze/src/CrossFileItems.mli index f7517d9974..93141b1004 100644 --- a/analysis/reanalyze/src/CrossFileItems.mli +++ b/analysis/reanalyze/src/CrossFileItems.mli @@ -74,11 +74,6 @@ val process_exception_refs : (** {2 Optional Args State} *) -val compute_optional_args_state : - t -> - decls:Declarations.t -> - is_live:(Lexing.position -> bool) -> - OptionalArgsState.t (** Compute final optional args state from calls and function references, taking into account caller liveness via the [is_live] predicate. Pure function - does not mutate declarations. *) diff --git a/analysis/reanalyze/src/CrossFileItemsStore.ml b/analysis/reanalyze/src/CrossFileItemsStore.ml new file mode 100644 index 0000000000..33e5a756d6 --- /dev/null +++ b/analysis/reanalyze/src/CrossFileItemsStore.ml @@ -0,0 +1,68 @@ +(** Abstraction over cross-file items storage. + + Allows iteration over optional arg calls and function refs from either: + - [Frozen]: Collected [CrossFileItems.t] + - [Reactive]: Direct iteration over reactive collection (no intermediate allocation) *) + +type t = + | Frozen of CrossFileItems.t + | Reactive of (string, CrossFileItems.t) Reactive.t + +let of_frozen cfi = Frozen cfi + +let of_reactive reactive = Reactive reactive + +let iter_optional_arg_calls t f = + match t with + | Frozen cfi -> List.iter f cfi.CrossFileItems.optional_arg_calls + | Reactive r -> + Reactive.iter + (fun _path items -> List.iter f items.CrossFileItems.optional_arg_calls) + r + +let iter_function_refs t f = + match t with + | Frozen cfi -> List.iter f cfi.CrossFileItems.function_refs + | Reactive r -> + Reactive.iter + (fun _path items -> List.iter f items.CrossFileItems.function_refs) + r + +(** Compute optional args state from calls and function references. + Returns a map from position to final OptionalArgs.t state. + Pure function - does not mutate declarations. *) +let compute_optional_args_state (store : t) ~find_decl ~is_live : + OptionalArgsState.t = + let state = OptionalArgsState.create () in + (* Initialize state from declarations *) + let get_state pos = + match OptionalArgsState.find_opt state pos with + | Some s -> s + | None -> ( + match find_decl pos with + | Some {Decl.declKind = Value {optionalArgs}} -> optionalArgs + | _ -> OptionalArgs.empty) + in + let set_state pos s = OptionalArgsState.set state pos s in + (* Process optional arg calls *) + iter_optional_arg_calls store + (fun {CrossFileItems.pos_from; pos_to; arg_names; arg_names_maybe} -> + if is_live pos_from then + let current = get_state pos_to in + let updated = + OptionalArgs.apply_call ~argNames:arg_names + ~argNamesMaybe:arg_names_maybe current + in + set_state pos_to updated); + (* Process function references *) + iter_function_refs store (fun {CrossFileItems.pos_from; pos_to} -> + if is_live pos_from then + let state_from = get_state pos_from in + let state_to = get_state pos_to in + if not (OptionalArgs.isEmpty state_to) then ( + let updated_from, updated_to = + OptionalArgs.combine_pair state_from state_to + in + set_state pos_from updated_from; + set_state pos_to updated_to)); + state diff --git a/analysis/reanalyze/src/CrossFileItemsStore.mli b/analysis/reanalyze/src/CrossFileItemsStore.mli new file mode 100644 index 0000000000..98eda6d3d7 --- /dev/null +++ b/analysis/reanalyze/src/CrossFileItemsStore.mli @@ -0,0 +1,30 @@ +(** Abstraction over cross-file items storage. + + Allows iteration over optional arg calls and function refs from either: + - [Frozen]: Collected [CrossFileItems.t] + - [Reactive]: Direct iteration over reactive collection (no intermediate allocation) *) + +type t = + | Frozen of CrossFileItems.t + | Reactive of (string, CrossFileItems.t) Reactive.t + (** Cross-file items store with exposed constructors for pattern matching *) + +val of_frozen : CrossFileItems.t -> t +(** Wrap a frozen [CrossFileItems.t] *) + +val of_reactive : (string, CrossFileItems.t) Reactive.t -> t +(** Wrap reactive collection directly (no intermediate collection) *) + +val iter_optional_arg_calls : + t -> (CrossFileItems.optional_arg_call -> unit) -> unit +(** Iterate over all optional arg calls *) + +val iter_function_refs : t -> (CrossFileItems.function_ref -> unit) -> unit +(** Iterate over all function refs *) + +val compute_optional_args_state : + t -> + find_decl:(Lexing.position -> Decl.t option) -> + is_live:(Lexing.position -> bool) -> + OptionalArgsState.t +(** Compute optional args state from calls and function references *) diff --git a/analysis/reanalyze/src/DeadCommon.ml b/analysis/reanalyze/src/DeadCommon.ml index 9f3ad1f21a..a63c212c51 100644 --- a/analysis/reanalyze/src/DeadCommon.ml +++ b/analysis/reanalyze/src/DeadCommon.ml @@ -88,11 +88,6 @@ let addValueReference ~config ~refs ~file_deps ~(binding : Location.t) FileDeps.add_dep file_deps ~from_file:effectiveFrom.loc_start.pos_fname ~to_file:locTo.loc_start.pos_fname) -(* NOTE: iterFilesFromRootsToLeaves moved to FileDeps.iter_files_from_roots_to_leaves *) - -let iterFilesFromRootsToLeaves ~file_deps iterFun = - FileDeps.iter_files_from_roots_to_leaves file_deps iterFun - let addDeclaration_ ~config ~decls ~(file : FileContext.t) ?posEnd ?posStart ~declKind ~path ~(loc : Location.t) ?(posAdjustment = Decl.Nothing) ~moduleLoc (name : Name.t) = @@ -162,7 +157,7 @@ let isInsideReportedValue (ctx : ReportingContext.t) decl = (** Report a dead declaration. Returns list of issues (dead module first, then dead value). Caller is responsible for logging. *) -let reportDeclaration ~config ~refs (ctx : ReportingContext.t) decl : +let reportDeclaration ~config ~ref_store (ctx : ReportingContext.t) decl : Issue.t list = let insideReportedValue = decl |> isInsideReportedValue ctx in if not decl.report then [] @@ -197,7 +192,7 @@ let reportDeclaration ~config ~refs (ctx : ReportingContext.t) decl : (WarningDeadType, "is a variant case which is never constructed") in let hasRefBelow () = - let decl_refs = References.find_value_refs refs decl.pos in + let decl_refs = ReferenceStore.find_value_refs ref_store decl.pos in let refIsBelow (pos : Lexing.position) = decl.pos.pos_fname <> pos.pos_fname || decl.pos.pos_cnum < pos.pos_cnum @@ -227,20 +222,19 @@ let reportDeclaration ~config ~refs (ctx : ReportingContext.t) decl : | None -> [dead_value_issue] else [] -let declIsDead ~annotations ~refs decl = +let declIsDead ~ann_store ~refs decl = let liveRefs = refs |> PosSet.filter (fun p -> - not (FileAnnotations.is_annotated_dead annotations p)) + not (AnnotationStore.is_annotated_dead ann_store p)) in liveRefs |> PosSet.cardinal = 0 - && not - (FileAnnotations.is_annotated_gentype_or_live annotations decl.Decl.pos) + && not (AnnotationStore.is_annotated_gentype_or_live ann_store decl.Decl.pos) -let doReportDead ~annotations pos = - not (FileAnnotations.is_annotated_gentype_or_dead annotations pos) +let doReportDead ~ann_store pos = + not (AnnotationStore.is_annotated_gentype_or_dead ann_store pos) -let rec resolveRecursiveRefs ~all_refs ~annotations ~config ~decls +let rec resolveRecursiveRefs ~ref_store ~ann_store ~config ~decl_store ~checkOptionalArg: (checkOptionalArgFn : config:DceConfig.t -> Decl.t -> Issue.t list) ~deadDeclarations ~issues ~level ~orderedFiles ~refs ~refsBeingResolved decl @@ -275,7 +269,7 @@ let rec resolveRecursiveRefs ~all_refs ~annotations ~config ~decls (decl.path |> DcePath.toString); false) else - match Declarations.find_opt decls pos with + match DeclarationStore.find_opt decl_store pos with | None -> if Config.recursiveDebug then Log_.item "recursiveDebug can't find decl for %s@." @@ -284,20 +278,20 @@ let rec resolveRecursiveRefs ~all_refs ~annotations ~config ~decls | Some xDecl -> let xRefs = match xDecl.declKind |> Decl.Kind.isType with - | true -> References.find_type_refs all_refs pos - | false -> References.find_value_refs all_refs pos + | true -> ReferenceStore.find_type_refs ref_store pos + | false -> ReferenceStore.find_value_refs ref_store pos in let xDeclIsDead = xDecl - |> resolveRecursiveRefs ~all_refs ~annotations ~config ~decls - ~checkOptionalArg:checkOptionalArgFn ~deadDeclarations - ~issues ~level:(level + 1) ~orderedFiles ~refs:xRefs - ~refsBeingResolved + |> resolveRecursiveRefs ~ref_store ~ann_store ~config + ~decl_store ~checkOptionalArg:checkOptionalArgFn + ~deadDeclarations ~issues ~level:(level + 1) + ~orderedFiles ~refs:xRefs ~refsBeingResolved in if xDecl.resolvedDead = None then allDepsResolved := false; not xDeclIsDead) in - let isDead = decl |> declIsDead ~annotations ~refs:newRefs in + let isDead = decl |> declIsDead ~ann_store ~refs:newRefs in let isResolved = (not isDead) || !allDepsResolved || level = 0 in if isResolved then ( decl.resolvedDead <- Some isDead; @@ -306,7 +300,7 @@ let rec resolveRecursiveRefs ~all_refs ~annotations ~config ~decls |> DeadModules.markDead ~config ~isType:(decl.declKind |> Decl.Kind.isType) ~loc:decl.moduleLoc; - if not (doReportDead ~annotations decl.pos) then decl.report <- false; + if not (doReportDead ~ann_store decl.pos) then decl.report <- false; deadDeclarations := decl :: !deadDeclarations) else ( (* Collect optional args issues *) @@ -316,7 +310,7 @@ let rec resolveRecursiveRefs ~all_refs ~annotations ~config ~decls |> DeadModules.markLive ~config ~isType:(decl.declKind |> Decl.Kind.isType) ~loc:decl.moduleLoc; - if FileAnnotations.is_annotated_dead annotations decl.pos then ( + if AnnotationStore.is_annotated_dead ann_store decl.pos then ( (* Collect incorrect @dead annotation issue *) let issue = makeDeadIssue ~decl ~message:" is annotated @dead but is live" @@ -342,22 +336,23 @@ let rec resolveRecursiveRefs ~all_refs ~annotations ~config ~decls refsString level); isDead -let solveDead ~annotations ~config ~decls ~refs ~file_deps ~optional_args_state +let solveDead ~ann_store ~config ~decl_store ~ref_store ~file_deps_store + ~optional_args_state ~checkOptionalArg: (checkOptionalArgFn : optional_args_state:OptionalArgsState.t -> - annotations:FileAnnotations.t -> + ann_store:AnnotationStore.t -> config:DceConfig.t -> Decl.t -> Issue.t list) : AnalysisResult.t = let iterDeclInOrder ~deadDeclarations ~issues ~orderedFiles decl = let decl_refs = match decl |> Decl.isValue with - | true -> References.find_value_refs refs decl.pos - | false -> References.find_type_refs refs decl.pos + | true -> ReferenceStore.find_value_refs ref_store decl.pos + | false -> ReferenceStore.find_type_refs ref_store decl.pos in - resolveRecursiveRefs ~all_refs:refs ~annotations ~config ~decls - ~checkOptionalArg:(checkOptionalArgFn ~optional_args_state ~annotations) + resolveRecursiveRefs ~ref_store ~ann_store ~config ~decl_store + ~checkOptionalArg:(checkOptionalArgFn ~optional_args_state ~ann_store) ~deadDeclarations ~issues ~level:0 ~orderedFiles ~refsBeingResolved:(ref PosSet.empty) ~refs:decl_refs decl |> ignore @@ -365,7 +360,7 @@ let solveDead ~annotations ~config ~decls ~refs ~file_deps ~optional_args_state if config.DceConfig.cli.debug then ( Log_.item "@.File References@.@."; let fileList = ref [] in - FileDeps.iter_deps file_deps (fun file files -> + FileDepsStore.iter_deps file_deps_store (fun file files -> fileList := (file, files) :: !fileList); !fileList |> List.sort (fun (f1, _) (f2, _) -> String.compare f1 f2) @@ -375,12 +370,12 @@ let solveDead ~annotations ~config ~decls ~refs ~file_deps ~optional_args_state (files |> FileSet.elements |> List.map Filename.basename |> String.concat ", "))); let declarations = - Declarations.fold + DeclarationStore.fold (fun _pos decl declarations -> decl :: declarations) - decls [] + decl_store [] in let orderedFiles = Hashtbl.create 256 in - iterFilesFromRootsToLeaves ~file_deps + FileDepsStore.iter_files_from_roots_to_leaves file_deps_store (let current = ref 0 in fun fileName -> incr current; @@ -402,7 +397,7 @@ let solveDead ~annotations ~config ~decls ~refs ~file_deps ~optional_args_state let dead_issues = sortedDeadDeclarations |> List.concat_map (fun decl -> - reportDeclaration ~config ~refs reporting_ctx decl) + reportDeclaration ~config ~ref_store reporting_ctx decl) in (* Combine all issues: inline issues first (they were logged during analysis), then dead declaration issues *) diff --git a/analysis/reanalyze/src/DeadOptionalArgs.ml b/analysis/reanalyze/src/DeadOptionalArgs.ml index c7fcc93b8e..71bef0ac99 100644 --- a/analysis/reanalyze/src/DeadOptionalArgs.ml +++ b/analysis/reanalyze/src/DeadOptionalArgs.ml @@ -59,12 +59,12 @@ let addReferences ~config ~cross_file ~(locFrom : Location.t) (** Check for optional args issues. Returns issues instead of logging. Uses optional_args_state map for final computed state. *) -let check ~optional_args_state ~annotations ~config:_ decl : Issue.t list = +let check ~optional_args_state ~ann_store ~config:_ decl : Issue.t list = match decl with | {Decl.declKind = Value {optionalArgs}} when active () && not - (FileAnnotations.is_annotated_gentype_or_live annotations decl.pos) + (AnnotationStore.is_annotated_gentype_or_live ann_store decl.pos) -> (* Look up computed state from map, fall back to declaration's initial state *) let state = diff --git a/analysis/reanalyze/src/DeclarationStore.ml b/analysis/reanalyze/src/DeclarationStore.ml new file mode 100644 index 0000000000..7b0043c541 --- /dev/null +++ b/analysis/reanalyze/src/DeclarationStore.ml @@ -0,0 +1,33 @@ +(** Abstraction over declaration storage. + + Allows the solver to work with either: + - [Frozen]: Traditional [Declarations.t] (copied from reactive) + - [Reactive]: Direct [Reactive.t] (no copy, zero-cost on warm runs) + + This eliminates the O(N) freeze step when using reactive mode. *) + +type t = + | Frozen of Declarations.t + | Reactive of (Lexing.position, Decl.t) Reactive.t + +let of_frozen decls = Frozen decls + +let of_reactive reactive = Reactive reactive + +let find_opt t pos = + match t with + | Frozen decls -> Declarations.find_opt decls pos + | Reactive reactive -> Reactive.get reactive pos + +let fold f t init = + match t with + | Frozen decls -> Declarations.fold f decls init + | Reactive reactive -> + let acc = ref init in + Reactive.iter (fun pos decl -> acc := f pos decl !acc) reactive; + !acc + +let iter f t = + match t with + | Frozen decls -> Declarations.iter f decls + | Reactive reactive -> Reactive.iter f reactive diff --git a/analysis/reanalyze/src/DeclarationStore.mli b/analysis/reanalyze/src/DeclarationStore.mli new file mode 100644 index 0000000000..c50583aca1 --- /dev/null +++ b/analysis/reanalyze/src/DeclarationStore.mli @@ -0,0 +1,27 @@ +(** Abstraction over declaration storage. + + Allows the solver to work with either: + - [Frozen]: Traditional [Declarations.t] (copied from reactive) + - [Reactive]: Direct [Reactive.t] (no copy, zero-cost on warm runs) + + This eliminates the O(N) freeze step when using reactive mode. *) + +type t = + | Frozen of Declarations.t + | Reactive of (Lexing.position, Decl.t) Reactive.t + (** Declaration store - either frozen or reactive *) + +val of_frozen : Declarations.t -> t +(** Wrap a frozen [Declarations.t] *) + +val of_reactive : (Lexing.position, Decl.t) Reactive.t -> t +(** Wrap a reactive collection directly (no copy) *) + +val find_opt : t -> Lexing.position -> Decl.t option +(** Look up a declaration by position *) + +val fold : (Lexing.position -> Decl.t -> 'a -> 'a) -> t -> 'a -> 'a +(** Fold over all declarations *) + +val iter : (Lexing.position -> Decl.t -> unit) -> t -> unit +(** Iterate over all declarations *) diff --git a/analysis/reanalyze/src/FileDepsStore.ml b/analysis/reanalyze/src/FileDepsStore.ml new file mode 100644 index 0000000000..5c16bbacde --- /dev/null +++ b/analysis/reanalyze/src/FileDepsStore.ml @@ -0,0 +1,46 @@ +(** Abstraction over file dependency storage. + + Allows the solver to work with either: + - [Frozen]: Traditional [FileDeps.t] (copied from reactive) + - [Reactive]: Direct reactive collections (no copy, zero-cost on warm runs) *) + +type t = + | Frozen of FileDeps.t + | Reactive of { + files: (string, unit) Reactive.t; + deps: (string, FileSet.t) Reactive.t; + } + +let of_frozen fd = Frozen fd + +let of_reactive ~files ~deps = Reactive {files; deps} + +let get_deps t file = + match t with + | Frozen fd -> FileDeps.get_deps fd file + | Reactive r -> ( + match Reactive.get r.deps file with + | Some s -> s + | None -> FileSet.empty) + +let iter_deps t f = + match t with + | Frozen fd -> FileDeps.iter_deps fd f + | Reactive r -> Reactive.iter f r.deps + +(** Topological iteration from roots to leaves. + Works for both frozen and reactive - builds temporary structures as needed. *) +let iter_files_from_roots_to_leaves t iterFun = + match t with + | Frozen fd -> FileDeps.iter_files_from_roots_to_leaves fd iterFun + | Reactive r -> + (* Build temporary FileDeps.t from reactive collections for topo sort *) + let files = ref FileSet.empty in + Reactive.iter (fun f () -> files := FileSet.add f !files) r.files; + let deps = FileDeps.FileHash.create 256 in + Reactive.iter + (fun from_file to_files -> + FileDeps.FileHash.replace deps from_file to_files) + r.deps; + let fd = FileDeps.create ~files:!files ~deps in + FileDeps.iter_files_from_roots_to_leaves fd iterFun diff --git a/analysis/reanalyze/src/FileDepsStore.mli b/analysis/reanalyze/src/FileDepsStore.mli new file mode 100644 index 0000000000..93983030a0 --- /dev/null +++ b/analysis/reanalyze/src/FileDepsStore.mli @@ -0,0 +1,28 @@ +(** Abstraction over file dependency storage. + + Allows the solver to work with either: + - [Frozen]: Traditional [FileDeps.t] (copied from reactive) + - [Reactive]: Direct reactive collections (no copy, zero-cost on warm runs) *) + +type t = + | Frozen of FileDeps.t + | Reactive of { + files: (string, unit) Reactive.t; + deps: (string, FileSet.t) Reactive.t; + } (** File deps store with exposed constructors for pattern matching *) + +val of_frozen : FileDeps.t -> t +(** Wrap a frozen [FileDeps.t] *) + +val of_reactive : + files:(string, unit) Reactive.t -> deps:(string, FileSet.t) Reactive.t -> t +(** Wrap reactive collections directly *) + +val get_deps : t -> string -> FileSet.t +(** Get dependencies for a file *) + +val iter_deps : t -> (string -> FileSet.t -> unit) -> unit +(** Iterate over all dependencies *) + +val iter_files_from_roots_to_leaves : t -> (string -> unit) -> unit +(** Iterate files in topological order (roots first) *) diff --git a/analysis/reanalyze/src/Reanalyze.ml b/analysis/reanalyze/src/Reanalyze.ml index 927f264eed..5c6d965c39 100644 --- a/analysis/reanalyze/src/Reanalyze.ml +++ b/analysis/reanalyze/src/Reanalyze.ml @@ -268,38 +268,71 @@ let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge = let analysis_result = if dce_config.DceConfig.run.dce then (* Merging phase: combine all builders -> immutable data *) - let annotations, decls, cross_file, refs, file_deps = + let ann_store, decl_store, cross_file_store, ref_store, file_deps_store = Timing.time_phase `Merging (fun () -> (* Use reactive merge if available, otherwise list-based merge *) - let annotations, decls, cross_file = + let ann_store, decl_store, cross_file_store = match reactive_merge with | Some merged -> - ( ReactiveMerge.freeze_annotations merged, - ReactiveMerge.freeze_decls merged, - ReactiveMerge.collect_cross_file_items merged ) + (* Reactive mode: use stores directly, skip freeze! *) + ( AnnotationStore.of_reactive merged.ReactiveMerge.annotations, + DeclarationStore.of_reactive merged.ReactiveMerge.decls, + CrossFileItemsStore.of_reactive + merged.ReactiveMerge.cross_file_items ) | None -> - ( FileAnnotations.merge_all - (dce_data_list - |> List.map (fun fd -> fd.DceFileProcessing.annotations)), + (* Non-reactive mode: freeze into data, wrap in store *) + let decls = Declarations.merge_all (dce_data_list - |> List.map (fun fd -> fd.DceFileProcessing.decls)), - CrossFileItems.merge_all - (dce_data_list - |> List.map (fun fd -> fd.DceFileProcessing.cross_file)) ) + |> List.map (fun fd -> fd.DceFileProcessing.decls)) + in + ( AnnotationStore.of_frozen + (FileAnnotations.merge_all + (dce_data_list + |> List.map (fun fd -> fd.DceFileProcessing.annotations) + )), + DeclarationStore.of_frozen decls, + CrossFileItemsStore.of_frozen + (CrossFileItems.merge_all + (dce_data_list + |> List.map (fun fd -> fd.DceFileProcessing.cross_file))) + ) in (* Compute refs and file_deps. - In reactive mode, ReactiveMerge handles type deps and exception refs. + In reactive mode, use stores directly (skip freeze!). In non-reactive mode, use the imperative processing. *) - let refs, file_deps = + let ref_store, file_deps_store = match reactive_merge with | Some merged -> - (* Reactive mode: freeze_refs includes type deps and exception refs *) - let refs = ReactiveMerge.freeze_refs merged in - let file_deps = ReactiveMerge.freeze_file_deps merged in - (refs, file_deps) + (* Reactive mode: use stores directly, skip freeze! *) + let ref_store = + ReferenceStore.of_reactive ~value_refs:merged.value_refs + ~type_refs:merged.type_refs ~type_deps:merged.type_deps + ~exception_refs:merged.exception_refs + in + let file_deps_store = + FileDepsStore.of_reactive ~files:merged.files + ~deps:merged.file_deps_map + in + (ref_store, file_deps_store) | None -> (* Non-reactive mode: build refs/file_deps imperatively *) + (* Need Declarations.t for type deps processing *) + let decls = + match decl_store with + | DeclarationStore.Frozen d -> d + | DeclarationStore.Reactive _ -> + failwith + "unreachable: non-reactive path with reactive store" + in + (* Need CrossFileItems.t for exception refs processing *) + let cross_file = + match cross_file_store with + | CrossFileItemsStore.Frozen cfi -> cfi + | CrossFileItemsStore.Reactive _ -> + failwith + "unreachable: non-reactive path with reactive store" + in let refs_builder = References.create_builder () in let file_deps_builder = FileDeps.create_builder () in (match reactive_collection with @@ -331,41 +364,44 @@ let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge = (* Freeze refs and file_deps for solver *) let refs = References.freeze_builder refs_builder in let file_deps = FileDeps.freeze_builder file_deps_builder in - (refs, file_deps) + ( ReferenceStore.of_frozen refs, + FileDepsStore.of_frozen file_deps ) in - (annotations, decls, cross_file, refs, file_deps)) + (ann_store, decl_store, cross_file_store, ref_store, file_deps_store)) in (* Solving phase: run the solver and collect issues *) Timing.time_phase `Solving (fun () -> let empty_optional_args_state = OptionalArgsState.create () in let analysis_result_core = - DeadCommon.solveDead ~annotations ~decls ~refs ~file_deps - ~optional_args_state:empty_optional_args_state ~config:dce_config + DeadCommon.solveDead ~ann_store ~decl_store ~ref_store + ~file_deps_store ~optional_args_state:empty_optional_args_state + ~config:dce_config ~checkOptionalArg:(fun - ~optional_args_state:_ ~annotations:_ ~config:_ _ -> []) + ~optional_args_state:_ ~ann_store:_ ~config:_ _ -> []) in (* Compute liveness-aware optional args state *) let is_live pos = - match Declarations.find_opt decls pos with + match DeclarationStore.find_opt decl_store pos with | Some decl -> Decl.isLive decl | None -> true in let optional_args_state = - CrossFileItems.compute_optional_args_state cross_file ~decls + CrossFileItemsStore.compute_optional_args_state cross_file_store + ~find_decl:(DeclarationStore.find_opt decl_store) ~is_live in (* Collect optional args issues only for live declarations *) let optional_args_issues = - Declarations.fold + DeclarationStore.fold (fun _pos decl acc -> if Decl.isLive decl then let issues = - DeadOptionalArgs.check ~optional_args_state ~annotations + DeadOptionalArgs.check ~optional_args_state ~ann_store ~config:dce_config decl in List.rev_append issues acc else acc) - decls [] + decl_store [] |> List.rev in Some diff --git a/analysis/reanalyze/src/ReferenceStore.ml b/analysis/reanalyze/src/ReferenceStore.ml new file mode 100644 index 0000000000..1cff4a1918 --- /dev/null +++ b/analysis/reanalyze/src/ReferenceStore.ml @@ -0,0 +1,68 @@ +(** Abstraction over reference storage. + + Allows the solver to work with either: + - [Frozen]: Traditional [References.t] (copied from reactive) + - [Reactive]: Direct reactive collections (no copy, zero-cost on warm runs) + + This eliminates the O(N) freeze step when using reactive mode. *) + +type t = + | Frozen of References.t + | Reactive of { + value_refs: (Lexing.position, PosSet.t) Reactive.t; + type_refs: (Lexing.position, PosSet.t) Reactive.t; + (* Type deps sources *) + same_path_refs: (Lexing.position, PosSet.t) Reactive.t; + cross_file_refs: (Lexing.position, PosSet.t) Reactive.t; + impl_to_intf_refs_path2: (Lexing.position, PosSet.t) Reactive.t; + intf_to_impl_refs: (Lexing.position, PosSet.t) Reactive.t; + (* Exception refs source *) + exception_resolved_refs: (Lexing.position, PosSet.t) Reactive.t; + } + +let of_frozen refs = Frozen refs + +let of_reactive ~value_refs ~type_refs ~type_deps ~exception_refs = + Reactive + { + value_refs; + type_refs; + same_path_refs = type_deps.ReactiveTypeDeps.same_path_refs; + cross_file_refs = type_deps.ReactiveTypeDeps.cross_file_refs; + impl_to_intf_refs_path2 = + type_deps.ReactiveTypeDeps.impl_to_intf_refs_path2; + intf_to_impl_refs = type_deps.ReactiveTypeDeps.intf_to_impl_refs; + exception_resolved_refs = + exception_refs.ReactiveExceptionRefs.resolved_refs; + } + +(** Helper to get from reactive and default to empty *) +let get_or_empty reactive pos = + match Reactive.get reactive pos with + | Some s -> s + | None -> PosSet.empty + +let find_value_refs t pos = + match t with + | Frozen refs -> References.find_value_refs refs pos + | Reactive r -> + (* Combine: per-file value_refs + exception resolved_refs *) + let from_file = get_or_empty r.value_refs pos in + let from_exceptions = get_or_empty r.exception_resolved_refs pos in + PosSet.union from_file from_exceptions + +let find_type_refs t pos = + match t with + | Frozen refs -> References.find_type_refs refs pos + | Reactive r -> + (* Combine: per-file type_refs + all type_deps sources *) + let from_file = get_or_empty r.type_refs pos in + let from_same_path = get_or_empty r.same_path_refs pos in + let from_cross_file = get_or_empty r.cross_file_refs pos in + let from_impl_intf2 = get_or_empty r.impl_to_intf_refs_path2 pos in + let from_intf_impl = get_or_empty r.intf_to_impl_refs pos in + from_file + |> PosSet.union from_same_path + |> PosSet.union from_cross_file + |> PosSet.union from_impl_intf2 + |> PosSet.union from_intf_impl diff --git a/analysis/reanalyze/src/ReferenceStore.mli b/analysis/reanalyze/src/ReferenceStore.mli new file mode 100644 index 0000000000..a0e88b9fb8 --- /dev/null +++ b/analysis/reanalyze/src/ReferenceStore.mli @@ -0,0 +1,27 @@ +(** Abstraction over reference storage. + + Allows the solver to work with either: + - [Frozen]: Traditional [References.t] (copied from reactive) + - [Reactive]: Direct reactive collections (no copy, zero-cost on warm runs) + + This eliminates the O(N) freeze step when using reactive mode. *) + +type t +(** Abstract reference store *) + +val of_frozen : References.t -> t +(** Wrap a frozen [References.t] *) + +val of_reactive : + value_refs:(Lexing.position, PosSet.t) Reactive.t -> + type_refs:(Lexing.position, PosSet.t) Reactive.t -> + type_deps:ReactiveTypeDeps.t -> + exception_refs:ReactiveExceptionRefs.t -> + t +(** Wrap reactive collections directly (no copy) *) + +val find_value_refs : t -> Lexing.position -> PosSet.t +(** Find value references to a position *) + +val find_type_refs : t -> Lexing.position -> PosSet.t +(** Find type references to a position *) From 7eb1fba40cb9d9bdc1a8dba16168d67b966141e3 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 12:31:19 +0100 Subject: [PATCH 10/45] Remove backward solver, use forward fixpoint for liveness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is a significant simplification of the dead code analysis solver: **Removed:** - Backward recursive solver (resolveRecursiveRefs, solveDeadBackward) - FileDepsStore module (was only needed for file ordering in backward solver) - Unused find_value_targets/find_type_targets functions **Added:** - Liveness module: forward fixpoint liveness computation - ReactiveDeclRefs: maps declarations to their outgoing references - ReactiveLiveness: reactive liveness via Reactive.fixpoint combinator - Reactive.union: combine two collections with optional merge - Reactive.fixpoint: transitive closure combinator (init + edges → reachable) **Changed:** - References now stores both directions: refs_to (for reporting) and refs_from (for liveness) - ReactiveMerge exposes value_refs_from and type_refs_from - ReactiveTypeDeps produces all_type_refs_from - ReactiveExceptionRefs produces refs_from direction - Updated ARCHITECTURE.md and reactive pipeline diagram The forward algorithm is simpler and more suitable for reactive updates: 1. Identify roots (@live/@genType annotations or externally referenced) 2. Build index mapping declarations to outgoing references 3. Propagate liveness from roots through references 4. Return set of all live positions Both reactive and non-reactive modes correctly report 380 issues on test suite. Signed-Off-By: Cristiano Calcagno --- analysis/reactive/src/Reactive.ml | 157 ++ analysis/reactive/src/Reactive.mli | 44 + analysis/reactive/test/ReactiveTest.ml | 276 +++ analysis/reanalyze/ARCHITECTURE.md | 54 +- .../reanalyze/diagrams/reactive-pipeline.mmd | 68 +- .../reanalyze/diagrams/reactive-pipeline.svg | 2 +- analysis/reanalyze/src/DeadCommon.ml | 324 ++-- analysis/reanalyze/src/FileDepsStore.ml | 46 - analysis/reanalyze/src/FileDepsStore.mli | 28 - analysis/reanalyze/src/Liveness.ml | 229 +++ analysis/reanalyze/src/Liveness.mli | 36 + analysis/reanalyze/src/ReactiveDeclRefs.ml | 82 + analysis/reanalyze/src/ReactiveDeclRefs.mli | 17 + .../reanalyze/src/ReactiveExceptionRefs.ml | 14 +- .../reanalyze/src/ReactiveExceptionRefs.mli | 3 + analysis/reanalyze/src/ReactiveLiveness.ml | 101 + analysis/reanalyze/src/ReactiveLiveness.mli | 9 + analysis/reanalyze/src/ReactiveMerge.ml | 111 +- analysis/reanalyze/src/ReactiveMerge.mli | 6 + analysis/reanalyze/src/ReactiveTypeDeps.ml | 26 +- analysis/reanalyze/src/ReactiveTypeDeps.mli | 4 +- analysis/reanalyze/src/Reanalyze.ml | 51 +- analysis/reanalyze/src/ReferenceStore.ml | 6 + analysis/reanalyze/src/ReferenceStore.mli | 9 +- analysis/reanalyze/src/References.ml | 110 +- analysis/reanalyze/src/References.mli | 46 +- .../deadcode/expected/deadcode.txt | 1721 ++++++++++------- 27 files changed, 2489 insertions(+), 1091 deletions(-) delete mode 100644 analysis/reanalyze/src/FileDepsStore.ml delete mode 100644 analysis/reanalyze/src/FileDepsStore.mli create mode 100644 analysis/reanalyze/src/Liveness.ml create mode 100644 analysis/reanalyze/src/Liveness.mli create mode 100644 analysis/reanalyze/src/ReactiveDeclRefs.ml create mode 100644 analysis/reanalyze/src/ReactiveDeclRefs.mli create mode 100644 analysis/reanalyze/src/ReactiveLiveness.ml create mode 100644 analysis/reanalyze/src/ReactiveLiveness.mli diff --git a/analysis/reactive/src/Reactive.ml b/analysis/reactive/src/Reactive.ml index 11e4836161..b77269496f 100644 --- a/analysis/reactive/src/Reactive.ml +++ b/analysis/reactive/src/Reactive.ml @@ -369,3 +369,160 @@ let join (left : ('k1, 'v1) t) (right : ('k2, 'v2) t) get = (fun k -> Hashtbl.find_opt target k); length = (fun () -> Hashtbl.length target); } + +(** {1 Union} *) + +(** Combine two collections into one. + + Returns a collection containing all entries from both [left] and [right]. + When the same key exists in both, [merge] combines values (defaults to + preferring right). *) +let union (left : ('k, 'v) t) (right : ('k, 'v) t) ?merge () : ('k, 'v) t = + let merge_fn = + match merge with + | Some m -> m + | None -> fun _ v -> v + in + (* Track contributions from each side *) + let left_values : ('k, 'v) Hashtbl.t = Hashtbl.create 64 in + let right_values : ('k, 'v) Hashtbl.t = Hashtbl.create 64 in + let target : ('k, 'v) Hashtbl.t = Hashtbl.create 128 in + let subscribers : (('k, 'v) delta -> unit) list ref = ref [] in + + let emit delta = List.iter (fun h -> h delta) !subscribers in + + let recompute_key k = + match (Hashtbl.find_opt left_values k, Hashtbl.find_opt right_values k) with + | None, None -> + Hashtbl.remove target k; + Some (Remove k) + | Some v, None | None, Some v -> + Hashtbl.replace target k v; + Some (Set (k, v)) + | Some v1, Some v2 -> + let merged = merge_fn v1 v2 in + Hashtbl.replace target k merged; + Some (Set (k, merged)) + in + + let handle_left_delta delta = + let downstream = + match delta with + | Set (k, v) -> + Hashtbl.replace left_values k v; + recompute_key k |> Option.to_list + | Remove k -> + Hashtbl.remove left_values k; + recompute_key k |> Option.to_list + in + List.iter emit downstream + in + + let handle_right_delta delta = + let downstream = + match delta with + | Set (k, v) -> + Hashtbl.replace right_values k v; + recompute_key k |> Option.to_list + | Remove k -> + Hashtbl.remove right_values k; + recompute_key k |> Option.to_list + in + List.iter emit downstream + in + + (* Subscribe to both sources *) + left.subscribe handle_left_delta; + right.subscribe handle_right_delta; + + (* Initialize from existing entries *) + left.iter (fun k v -> + Hashtbl.replace left_values k v; + ignore (recompute_key k)); + right.iter (fun k v -> + Hashtbl.replace right_values k v; + ignore (recompute_key k)); + + { + subscribe = (fun handler -> subscribers := handler :: !subscribers); + iter = (fun f -> Hashtbl.iter f target); + get = (fun k -> Hashtbl.find_opt target k); + length = (fun () -> Hashtbl.length target); + } + +(** {1 Fixpoint} *) + +(** Compute transitive closure via fixpoint. + + Starting from keys in [init], follows edges to discover all reachable keys. + + Current implementation: recomputes full fixpoint on any change. + Future: incremental updates. *) +let fixpoint ~(init : ('k, unit) t) ~(edges : ('k, 'k list) t) () : ('k, unit) t + = + (* Current fixpoint result *) + let result : ('k, unit) Hashtbl.t = Hashtbl.create 256 in + let subscribers : (('k, unit) delta -> unit) list ref = ref [] in + + let emit delta = List.iter (fun h -> h delta) !subscribers in + + (* Recompute the entire fixpoint from scratch *) + let recompute () = + (* Collect old keys to detect removals *) + let old_keys = + Hashtbl.fold (fun k () acc -> k :: acc) result [] + |> List.fold_left + (fun set k -> + Hashtbl.replace set k (); + set) + (Hashtbl.create 256) + in + + (* Clear and recompute *) + Hashtbl.clear result; + + (* Worklist algorithm *) + let worklist = Queue.create () in + + (* Add initial keys *) + init.iter (fun k () -> + if not (Hashtbl.mem result k) then ( + Hashtbl.replace result k (); + Queue.push k worklist)); + + (* Propagate through edges *) + while not (Queue.is_empty worklist) do + let k = Queue.pop worklist in + match edges.get k with + | None -> () + | Some successors -> + List.iter + (fun succ -> + if not (Hashtbl.mem result succ) then ( + Hashtbl.replace result succ (); + Queue.push succ worklist)) + successors + done; + + (* Emit deltas: additions and removals *) + Hashtbl.iter + (fun k () -> if not (Hashtbl.mem old_keys k) then emit (Set (k, ()))) + result; + Hashtbl.iter + (fun k () -> if not (Hashtbl.mem result k) then emit (Remove k)) + old_keys + in + + (* Subscribe to changes in init and edges *) + init.subscribe (fun _ -> recompute ()); + edges.subscribe (fun _ -> recompute ()); + + (* Initial computation *) + recompute (); + + { + subscribe = (fun handler -> subscribers := handler :: !subscribers); + iter = (fun f -> Hashtbl.iter f result); + get = (fun k -> Hashtbl.find_opt result k); + length = (fun () -> Hashtbl.length result); + } diff --git a/analysis/reactive/src/Reactive.mli b/analysis/reactive/src/Reactive.mli index 5894b23bf4..319c1791aa 100644 --- a/analysis/reactive/src/Reactive.mli +++ b/analysis/reactive/src/Reactive.mli @@ -117,3 +117,47 @@ val join : | None -> []) () ]} *) + +(** {1 Union} *) + +val union : + ('k, 'v) t -> ('k, 'v) t -> ?merge:('v -> 'v -> 'v) -> unit -> ('k, 'v) t +(** [union left right ?merge ()] combines two collections. + + Returns a collection containing all entries from both [left] and [right]. + When the same key exists in both collections: + - If [merge] is provided, values are combined with [merge left_val right_val] + - Otherwise, the value from [right] takes precedence + + When either collection changes, the union updates automatically. + + {2 Example: Combining reference sets} + {[ + let value_refs = ... + let type_refs = ... + let all_refs = Reactive.union value_refs type_refs ~merge:PosSet.union () + ]} *) + +(** {1 Fixpoint} *) + +val fixpoint : + init:('k, unit) t -> edges:('k, 'k list) t -> unit -> ('k, unit) t +(** [fixpoint ~init ~edges ()] computes transitive closure. + + Starting from keys in [init], follows edges to discover all reachable keys. + + - [init]: reactive collection of starting keys + - [edges]: reactive collection mapping each key to its successor keys + - Returns: reactive collection of all reachable keys + + When [init] or [edges] changes, the fixpoint recomputes. + + {b Note}: Current implementation recomputes full fixpoint on any change. + Future versions will update incrementally. + + {2 Example: Reachability} + {[ + let roots = ... (* keys that are initially reachable *) + let graph = ... (* key -> successor keys *) + let reachable = Reactive.fixpoint ~init:roots ~edges:graph () + ]} *) diff --git a/analysis/reactive/test/ReactiveTest.ml b/analysis/reactive/test/ReactiveTest.ml index bdd6fc488f..01af42e7c2 100644 --- a/analysis/reactive/test/ReactiveTest.ml +++ b/analysis/reactive/test/ReactiveTest.ml @@ -555,6 +555,278 @@ let test_join_with_merge () = (* Only one left *) Printf.printf "PASSED\n\n" +(* Test union *) +let test_union_basic () = + Printf.printf "=== Test: union basic ===\n"; + + (* Left collection *) + let left_data : (string, int) Hashtbl.t = Hashtbl.create 16 in + let left_subs : ((string, int) delta -> unit) list ref = ref [] in + let left : (string, int) t = + { + subscribe = (fun h -> left_subs := h :: !left_subs); + iter = (fun f -> Hashtbl.iter f left_data); + get = (fun k -> Hashtbl.find_opt left_data k); + length = (fun () -> Hashtbl.length left_data); + } + in + let emit_left delta = + apply_delta left_data delta; + List.iter (fun h -> h delta) !left_subs + in + + (* Right collection *) + let right_data : (string, int) Hashtbl.t = Hashtbl.create 16 in + let right_subs : ((string, int) delta -> unit) list ref = ref [] in + let right : (string, int) t = + { + subscribe = (fun h -> right_subs := h :: !right_subs); + iter = (fun f -> Hashtbl.iter f right_data); + get = (fun k -> Hashtbl.find_opt right_data k); + length = (fun () -> Hashtbl.length right_data); + } + in + let emit_right delta = + apply_delta right_data delta; + List.iter (fun h -> h delta) !right_subs + in + + (* Create union without merge (right takes precedence) *) + let combined = Reactive.union left right () in + + (* Initially empty *) + assert (length combined = 0); + + (* Add to left *) + emit_left (Set ("a", 1)); + Printf.printf "After left Set(a, 1): combined=%d\n" (length combined); + assert (length combined = 1); + assert (get combined "a" = Some 1); + + (* Add different key to right *) + emit_right (Set ("b", 2)); + Printf.printf "After right Set(b, 2): combined=%d\n" (length combined); + assert (length combined = 2); + assert (get combined "a" = Some 1); + assert (get combined "b" = Some 2); + + (* Add same key to right (should override left) *) + emit_right (Set ("a", 10)); + Printf.printf "After right Set(a, 10): combined a=%d\n" + (get combined "a" |> Option.value ~default:(-1)); + assert (length combined = 2); + assert (get combined "a" = Some 10); + + (* Right takes precedence *) + + (* Remove from right (left value should show through) *) + emit_right (Remove "a"); + Printf.printf "After right Remove(a): combined a=%d\n" + (get combined "a" |> Option.value ~default:(-1)); + assert (get combined "a" = Some 1); + + (* Left shows through *) + + (* Remove from left *) + emit_left (Remove "a"); + Printf.printf "After left Remove(a): combined=%d\n" (length combined); + assert (length combined = 1); + assert (get combined "a" = None); + assert (get combined "b" = Some 2); + + Printf.printf "PASSED\n\n" + +let test_union_with_merge () = + Printf.printf "=== Test: union with merge ===\n"; + + (* Left collection *) + let left_data : (string, IntSet.t) Hashtbl.t = Hashtbl.create 16 in + let left_subs : ((string, IntSet.t) delta -> unit) list ref = ref [] in + let left : (string, IntSet.t) t = + { + subscribe = (fun h -> left_subs := h :: !left_subs); + iter = (fun f -> Hashtbl.iter f left_data); + get = (fun k -> Hashtbl.find_opt left_data k); + length = (fun () -> Hashtbl.length left_data); + } + in + let emit_left delta = + apply_delta left_data delta; + List.iter (fun h -> h delta) !left_subs + in + + (* Right collection *) + let right_data : (string, IntSet.t) Hashtbl.t = Hashtbl.create 16 in + let right_subs : ((string, IntSet.t) delta -> unit) list ref = ref [] in + let right : (string, IntSet.t) t = + { + subscribe = (fun h -> right_subs := h :: !right_subs); + iter = (fun f -> Hashtbl.iter f right_data); + get = (fun k -> Hashtbl.find_opt right_data k); + length = (fun () -> Hashtbl.length right_data); + } + in + let emit_right delta = + apply_delta right_data delta; + List.iter (fun h -> h delta) !right_subs + in + + (* Create union with set union as merge *) + let combined = Reactive.union left right ~merge:IntSet.union () in + + (* Add to left: key "x" -> {1, 2} *) + emit_left (Set ("x", IntSet.of_list [1; 2])); + let v = get combined "x" |> Option.get in + Printf.printf "After left Set(x, {1,2}): {%s}\n" + (IntSet.elements v |> List.map string_of_int |> String.concat ", "); + assert (IntSet.equal v (IntSet.of_list [1; 2])); + + (* Add to right: key "x" -> {3, 4} (should merge) *) + emit_right (Set ("x", IntSet.of_list [3; 4])); + let v = get combined "x" |> Option.get in + Printf.printf "After right Set(x, {3,4}): {%s}\n" + (IntSet.elements v |> List.map string_of_int |> String.concat ", "); + assert (IntSet.equal v (IntSet.of_list [1; 2; 3; 4])); + + (* Update left: key "x" -> {1, 5} *) + emit_left (Set ("x", IntSet.of_list [1; 5])); + let v = get combined "x" |> Option.get in + Printf.printf "After left update to {1,5}: {%s}\n" + (IntSet.elements v |> List.map string_of_int |> String.concat ", "); + assert (IntSet.equal v (IntSet.of_list [1; 3; 4; 5])); + + (* Remove right *) + emit_right (Remove "x"); + let v = get combined "x" |> Option.get in + Printf.printf "After right Remove(x): {%s}\n" + (IntSet.elements v |> List.map string_of_int |> String.concat ", "); + assert (IntSet.equal v (IntSet.of_list [1; 5])); + + Printf.printf "PASSED\n\n" + +let test_union_existing_data () = + Printf.printf "=== Test: union on collections with existing data ===\n"; + + (* Create collections with existing data *) + let left_data : (int, string) Hashtbl.t = Hashtbl.create 16 in + Hashtbl.add left_data 1 "a"; + Hashtbl.add left_data 2 "b"; + let left_subs : ((int, string) delta -> unit) list ref = ref [] in + let left : (int, string) t = + { + subscribe = (fun h -> left_subs := h :: !left_subs); + iter = (fun f -> Hashtbl.iter f left_data); + get = (fun k -> Hashtbl.find_opt left_data k); + length = (fun () -> Hashtbl.length left_data); + } + in + + let right_data : (int, string) Hashtbl.t = Hashtbl.create 16 in + Hashtbl.add right_data 2 "B"; + (* Overlaps with left *) + Hashtbl.add right_data 3 "c"; + let right_subs : ((int, string) delta -> unit) list ref = ref [] in + let right : (int, string) t = + { + subscribe = (fun h -> right_subs := h :: !right_subs); + iter = (fun f -> Hashtbl.iter f right_data); + get = (fun k -> Hashtbl.find_opt right_data k); + length = (fun () -> Hashtbl.length right_data); + } + in + + (* Create union after both have data *) + let combined = Reactive.union left right () in + + Printf.printf "Union has %d entries (expected 3)\n" (length combined); + assert (length combined = 3); + assert (get combined 1 = Some "a"); + (* Only in left *) + assert (get combined 2 = Some "B"); + (* Right takes precedence *) + assert (get combined 3 = Some "c"); + + (* Only in right *) + Printf.printf "PASSED\n\n" + +(* Test fixpoint *) +let test_fixpoint () = + Printf.printf "Test: fixpoint\n"; + + (* Create mutable sources *) + let init_tbl = Hashtbl.create 16 in + let init_subscribers = ref [] in + let emit_init delta = + Hashtbl.iter (fun _ h -> h delta) (Hashtbl.create 1); + List.iter (fun h -> h delta) !init_subscribers + in + let init : (int, unit) Reactive.t = + { + subscribe = (fun h -> init_subscribers := h :: !init_subscribers); + iter = (fun f -> Hashtbl.iter f init_tbl); + get = (fun k -> Hashtbl.find_opt init_tbl k); + length = (fun () -> Hashtbl.length init_tbl); + } + in + + let edges_tbl : (int, int list) Hashtbl.t = Hashtbl.create 16 in + let edges_subscribers = ref [] in + let emit_edges delta = List.iter (fun h -> h delta) !edges_subscribers in + let edges : (int, int list) Reactive.t = + { + subscribe = (fun h -> edges_subscribers := h :: !edges_subscribers); + iter = (fun f -> Hashtbl.iter f edges_tbl); + get = (fun k -> Hashtbl.find_opt edges_tbl k); + length = (fun () -> Hashtbl.length edges_tbl); + } + in + + (* Set up graph: 1 -> [2, 3], 2 -> [4], 3 -> [4] *) + Hashtbl.replace edges_tbl 1 [2; 3]; + Hashtbl.replace edges_tbl 2 [4]; + Hashtbl.replace edges_tbl 3 [4]; + + (* Compute fixpoint *) + let reachable = Reactive.fixpoint ~init ~edges () in + + (* Initially empty *) + Printf.printf "Initially: length=%d\n" (Reactive.length reachable); + assert (Reactive.length reachable = 0); + + (* Add root 1 *) + Hashtbl.replace init_tbl 1 (); + emit_init (Set (1, ())); + Printf.printf "After adding root 1: length=%d\n" (Reactive.length reachable); + assert (Reactive.length reachable = 4); + (* 1, 2, 3, 4 *) + assert (Reactive.get reachable 1 = Some ()); + assert (Reactive.get reachable 2 = Some ()); + assert (Reactive.get reachable 3 = Some ()); + assert (Reactive.get reachable 4 = Some ()); + assert (Reactive.get reachable 5 = None); + + (* Add another root 5 with edge 5 -> [6] *) + Hashtbl.replace edges_tbl 5 [6]; + emit_edges (Set (5, [6])); + Hashtbl.replace init_tbl 5 (); + emit_init (Set (5, ())); + Printf.printf "After adding root 5: length=%d\n" (Reactive.length reachable); + assert (Reactive.length reachable = 6); + + (* 1, 2, 3, 4, 5, 6 *) + + (* Remove root 1 *) + Hashtbl.remove init_tbl 1; + emit_init (Remove 1); + Printf.printf "After removing root 1: length=%d\n" (Reactive.length reachable); + assert (Reactive.length reachable = 2); + (* 5, 6 *) + assert (Reactive.get reachable 1 = None); + assert (Reactive.get reachable 5 = Some ()); + assert (Reactive.get reachable 6 = Some ()); + + Printf.printf "PASSED\n\n" + let () = Printf.printf "\n====== Reactive Collection Tests ======\n\n"; test_flatmap_basic (); @@ -565,4 +837,8 @@ let () = test_lookup (); test_join (); test_join_with_merge (); + test_union_basic (); + test_union_with_merge (); + test_union_existing_data (); + test_fixpoint (); Printf.printf "All tests passed!\n" diff --git a/analysis/reanalyze/ARCHITECTURE.md b/analysis/reanalyze/ARCHITECTURE.md index 1f7d7a0d72..b649087b60 100644 --- a/analysis/reanalyze/ARCHITECTURE.md +++ b/analysis/reanalyze/ARCHITECTURE.md @@ -85,14 +85,18 @@ let file_deps = FileDeps.merge_all (file_data_list |> List.map (fun fd -> fd.fil **Output**: `AnalysisResult.t` containing `Issue.t list` -**Algorithm** (two-pass for liveness-aware optional args): +**Algorithm** (forward fixpoint + liveness-aware optional args): -**Pass 1: Core deadness resolution** -1. Build file dependency order (roots to leaves) -2. Sort declarations by dependency order -3. For each declaration, resolve references recursively -4. Determine dead/live status based on reference count -5. Collect issues for dead declarations +**Core liveness computation** (`Liveness.compute_forward`): +1. Identify roots: declarations with `@live`/`@genType` annotations or referenced from outside any declaration +2. Build index mapping each declaration to its outgoing references (refs_from direction) +3. Run forward fixpoint: propagate liveness from roots through references +4. Return set of all live positions + +**Pass 1: Deadness resolution** +1. Compute liveness via forward propagation +2. For each declaration, check if in live set +3. Mark dead declarations, collect issues **Pass 2: Liveness-aware optional args analysis** 1. Use `Decl.isLive` to build an `is_live` predicate from Pass 1 results @@ -149,7 +153,9 @@ The reactive layer (`analysis/reactive/`) provides delta-based incremental updat | `get` | Lookup by key | | `delta` | Change notification: `Set (key, value)` or `Remove key` | | `flatMap` | Transform collection, optionally merge same-key values | -| `join` | Hash join two collections with automatic updates | +| `join` | Hash join two collections (left join behavior) | +| `union` | Combine two collections, optionally merge same-key values | +| `fixpoint` | Transitive closure: `init + edges → reachable` | | `lookup` | Single-key subscription | | `ReactiveFileCollection` | File-backed collection with change detection | @@ -167,18 +173,21 @@ The reactive layer (`analysis/reactive/`) provides delta-based incremental updat | **FD** | `file_data` | `path → file_data option` | | **D** | `decls` | `pos → Decl.t` | | **A** | `annotations` | `pos → annotation` | -| **VR** | `value_refs` | `pos → PosSet` (per-file) | -| **TR** | `type_refs` | `pos → PosSet` (per-file) | +| **VR→** | `value_refs` | `pos → PosSet` (refs_to: target → sources) | +| **TR→** | `type_refs` | `pos → PosSet` (refs_to: target → sources) | +| **VR←** | `value_refs_from` | `pos → PosSet` (refs_from: source → targets) | +| **TR←** | `type_refs_from` | `pos → PosSet` (refs_from: source → targets) | | **CFI** | `cross_file_items` | `path → CrossFileItems.t` | | **DBP** | `decl_by_path` | `path → decl_info list` | -| **SPR** | `same_path_refs` | Same-path duplicates | -| **I2I** | `impl_to_intf_refs` | Impl → Interface links | -| **I2I₂** | `impl_to_intf_refs_path2` | Impl → Interface (path2) | -| **I→I** | `intf_to_impl_refs` | Interface → Impl links | +| **ATR←** | `all_type_refs_from` | Combined type refs (refs_from direction) | | **ER** | `exception_refs` | Exception references | | **ED** | `exception_decls` | Exception declarations | -| **RR** | `resolved_refs` | Resolved exception refs | -| **REFS** | Output | Combined `References.t` | +| **RR←** | `resolved_refs` | Resolved exception refs (refs_from direction) | +| **DR** | `decl_refs` | `pos → (value_targets, type_targets)` | +| **roots** | Root declarations | `@live`/`@genType` or externally referenced | +| **edges** | Reference graph | Declaration → referenced declarations | +| **fixpoint** | `Reactive.fixpoint` | Transitive closure combinator | +| **LIVE** | Output | Set of live positions | ### Delta Propagation @@ -200,12 +209,14 @@ The reactive layer (`analysis/reactive/`) provides delta-based incremental updat | Module | Responsibility | |--------|---------------| -| `Reactive` | Core primitives: `flatMap`, `join`, `lookup`, delta types | +| `Reactive` | Core primitives: `flatMap`, `join`, `union`, `fixpoint`, delta types | | `ReactiveFileCollection` | File-backed collection with change detection | | `ReactiveAnalysis` | CMT processing with file caching | -| `ReactiveMerge` | Derives decls, annotations, refs from file_data | -| `ReactiveTypeDeps` | Type-label dependency resolution via join | +| `ReactiveMerge` | Derives decls, annotations, refs (both directions) from file_data | +| `ReactiveTypeDeps` | Type-label dependency resolution, produces `all_type_refs_from` | | `ReactiveExceptionRefs` | Exception ref resolution via join | +| `ReactiveDeclRefs` | Maps declarations to their outgoing references | +| `ReactiveLiveness` | Computes live positions via reactive fixpoint | --- @@ -227,9 +238,10 @@ The reactive layer (`analysis/reactive/`) provides delta-based incremental updat | `Reanalyze` | Entry point, orchestrates pipeline | | `DceFileProcessing` | Phase 1: Per-file AST processing | | `DceConfig` | Configuration (CLI flags + run config) | -| `DeadCommon` | Phase 3: Solver (`solveDead`) | +| `DeadCommon` | Phase 3: Solver (`solveDead`, `solveDeadReactive`) | +| `Liveness` | Forward fixpoint liveness computation | | `Declarations` | Declaration storage (builder/immutable) | -| `References` | Reference tracking (builder/immutable) | +| `References` | Reference tracking (both refs_to and refs_from directions) | | `FileAnnotations` | Source annotation tracking | | `FileDeps` | Cross-file dependency graph | | `CrossFileItems` | Cross-file optional args and exceptions | diff --git a/analysis/reanalyze/diagrams/reactive-pipeline.mmd b/analysis/reanalyze/diagrams/reactive-pipeline.mmd index 67cd539389..5e9cb1fa5e 100644 --- a/analysis/reanalyze/diagrams/reactive-pipeline.mmd +++ b/analysis/reanalyze/diagrams/reactive-pipeline.mmd @@ -8,67 +8,81 @@ flowchart TB FD["FD"] end - subgraph Extracted["Extracted"] + subgraph Extracted["Extracted (ReactiveMerge)"] DECLS["D"] ANNOT["A"] - VREFS["VR"] - TREFS["TR"] + VREFS_TO["VR→"] + TREFS_TO["TR→"] + VREFS_FROM["VR←"] + TREFS_FROM["TR←"] CFI["CFI"] end subgraph TypeDeps["ReactiveTypeDeps"] DBP["DBP"] - SPR["SPR"] - I2I["I2I"] - I2I2["I2I₂"] - INT2IMP["I→I"] + ATR["ATR←"] end subgraph ExcDeps["ReactiveExceptionRefs"] EXCREF["ER"] EXCDECL["ED"] - RESOLVED["RR"] + RESOLVED["RR←"] end - subgraph Output["Output"] - REFS["REFS"] + subgraph DeclRefs["ReactiveDeclRefs"] + DR["DR"] + end + + subgraph Liveness["ReactiveLiveness"] + ROOTS["roots"] + EDGES["edges"] + FP["fixpoint"] + LIVE["LIVE"] end RFC -->|"process"| FD FD -->|"flatMap"| DECLS FD -->|"flatMap"| ANNOT - FD -->|"flatMap"| VREFS - FD -->|"flatMap"| TREFS + FD -->|"flatMap"| VREFS_TO + FD -->|"flatMap"| TREFS_TO + FD -->|"flatMap"| VREFS_FROM + FD -->|"flatMap"| TREFS_FROM FD -->|"flatMap"| CFI DECLS -->|"flatMap"| DBP - DBP -->|"flatMap"| SPR - DBP -->|"join"| I2I - DBP -->|"join"| I2I2 - DBP -->|"join"| INT2IMP + DBP -->|"union+flatMap"| ATR CFI -->|"flatMap"| EXCREF DECLS -->|"flatMap"| EXCDECL EXCREF -->|"join"| RESOLVED EXCDECL -->|"join"| RESOLVED - VREFS --> REFS - TREFS --> REFS - SPR --> REFS - I2I --> REFS - I2I2 --> REFS - INT2IMP --> REFS - RESOLVED --> REFS + DECLS --> DR + VREFS_FROM --> DR + TREFS_FROM --> DR + ATR --> DR + RESOLVED --> DR + + DECLS --> ROOTS + ANNOT --> ROOTS + + DR -->|"flatMap"| EDGES + ROOTS --> FP + EDGES --> FP + FP -->|"fixpoint"| LIVE classDef fileLayer fill:#e8f4fd,stroke:#4a90d9,stroke-width:2px classDef extracted fill:#f0f7e6,stroke:#6b8e23,stroke-width:2px classDef typeDeps fill:#fff5e6,stroke:#d4a574,stroke-width:2px classDef excDeps fill:#f5e6ff,stroke:#9966cc,stroke-width:2px + classDef declRefs fill:#e6f0ff,stroke:#4a74d9,stroke-width:2px + classDef liveness fill:#ffe6e6,stroke:#cc6666,stroke-width:2px classDef output fill:#e6ffe6,stroke:#2e8b2e,stroke-width:2px class RFC,FD fileLayer - class DECLS,ANNOT,VREFS,TREFS,CFI extracted - class DBP,SPR,I2I,I2I2,INT2IMP typeDeps + class DECLS,ANNOT,VREFS_TO,TREFS_TO,VREFS_FROM,TREFS_FROM,CFI extracted + class DBP,ATR typeDeps class EXCREF,EXCDECL,RESOLVED excDeps - class REFS output - + class DR declRefs + class ROOTS,EDGES,FP liveness + class LIVE output diff --git a/analysis/reanalyze/diagrams/reactive-pipeline.svg b/analysis/reanalyze/diagrams/reactive-pipeline.svg index bc932f903f..9ff00a23b0 100644 --- a/analysis/reanalyze/diagrams/reactive-pipeline.svg +++ b/analysis/reanalyze/diagrams/reactive-pipeline.svg @@ -1 +1 @@ -

Output

ReactiveExceptionRefs

ReactiveTypeDeps

Extracted

Per-File Data

File Layer

process

flatMap

flatMap

flatMap

flatMap

flatMap

flatMap

flatMap

join

join

join

flatMap

flatMap

join

join

RFC

FD

D

A

VR

TR

CFI

DBP

SPR

I2I

I2I₂

I→I

ER

ED

RR

REFS

\ No newline at end of file +

ReactiveLiveness

ReactiveDeclRefs

ReactiveExceptionRefs

ReactiveTypeDeps

Extracted (ReactiveMerge)

Per-File Data

File Layer

process

flatMap

flatMap

flatMap

flatMap

flatMap

flatMap

flatMap

flatMap

union+flatMap

flatMap

flatMap

join

join

flatMap

fixpoint

RFC

FD

D

A

VR→

TR→

VR←

TR←

CFI

DBP

ATR←

ER

ED

RR←

DR

roots

edges

fixpoint

LIVE

\ No newline at end of file diff --git a/analysis/reanalyze/src/DeadCommon.ml b/analysis/reanalyze/src/DeadCommon.ml index a63c212c51..4e98347b85 100644 --- a/analysis/reanalyze/src/DeadCommon.ml +++ b/analysis/reanalyze/src/DeadCommon.ml @@ -14,7 +14,6 @@ module Config = struct let analyzeExternals = ref false let reportUnderscore = false let reportTypesDeadOnlyInInterface = false - let recursiveDebug = false let warnOnCircularDependencies = false end @@ -222,122 +221,12 @@ let reportDeclaration ~config ~ref_store (ctx : ReportingContext.t) decl : | None -> [dead_value_issue] else [] -let declIsDead ~ann_store ~refs decl = - let liveRefs = - refs - |> PosSet.filter (fun p -> - not (AnnotationStore.is_annotated_dead ann_store p)) - in - liveRefs |> PosSet.cardinal = 0 - && not (AnnotationStore.is_annotated_gentype_or_live ann_store decl.Decl.pos) - let doReportDead ~ann_store pos = not (AnnotationStore.is_annotated_gentype_or_dead ann_store pos) -let rec resolveRecursiveRefs ~ref_store ~ann_store ~config ~decl_store - ~checkOptionalArg: - (checkOptionalArgFn : config:DceConfig.t -> Decl.t -> Issue.t list) - ~deadDeclarations ~issues ~level ~orderedFiles ~refs ~refsBeingResolved decl - : bool = - match decl.Decl.pos with - | _ when decl.resolvedDead <> None -> - if Config.recursiveDebug then - Log_.item "recursiveDebug %s [%d] already resolved@." - (decl.path |> DcePath.toString) - level; - (* Use the already-resolved value, not source annotations *) - Option.get decl.resolvedDead - | _ when PosSet.mem decl.pos !refsBeingResolved -> - if Config.recursiveDebug then - Log_.item "recursiveDebug %s [%d] is being resolved: assume dead@." - (decl.path |> DcePath.toString) - level; - true - | _ -> - if Config.recursiveDebug then - Log_.item "recursiveDebug resolving %s [%d]@." - (decl.path |> DcePath.toString) - level; - refsBeingResolved := PosSet.add decl.pos !refsBeingResolved; - let allDepsResolved = ref true in - let newRefs = - refs - |> PosSet.filter (fun pos -> - if pos = decl.pos then ( - if Config.recursiveDebug then - Log_.item "recursiveDebug %s ignoring reference to self@." - (decl.path |> DcePath.toString); - false) - else - match DeclarationStore.find_opt decl_store pos with - | None -> - if Config.recursiveDebug then - Log_.item "recursiveDebug can't find decl for %s@." - (pos |> Pos.toString); - true - | Some xDecl -> - let xRefs = - match xDecl.declKind |> Decl.Kind.isType with - | true -> ReferenceStore.find_type_refs ref_store pos - | false -> ReferenceStore.find_value_refs ref_store pos - in - let xDeclIsDead = - xDecl - |> resolveRecursiveRefs ~ref_store ~ann_store ~config - ~decl_store ~checkOptionalArg:checkOptionalArgFn - ~deadDeclarations ~issues ~level:(level + 1) - ~orderedFiles ~refs:xRefs ~refsBeingResolved - in - if xDecl.resolvedDead = None then allDepsResolved := false; - not xDeclIsDead) - in - let isDead = decl |> declIsDead ~ann_store ~refs:newRefs in - let isResolved = (not isDead) || !allDepsResolved || level = 0 in - if isResolved then ( - decl.resolvedDead <- Some isDead; - if isDead then ( - decl.path - |> DeadModules.markDead ~config - ~isType:(decl.declKind |> Decl.Kind.isType) - ~loc:decl.moduleLoc; - if not (doReportDead ~ann_store decl.pos) then decl.report <- false; - deadDeclarations := decl :: !deadDeclarations) - else ( - (* Collect optional args issues *) - checkOptionalArgFn ~config decl - |> List.iter (fun issue -> issues := issue :: !issues); - decl.path - |> DeadModules.markLive ~config - ~isType:(decl.declKind |> Decl.Kind.isType) - ~loc:decl.moduleLoc; - if AnnotationStore.is_annotated_dead ann_store decl.pos then ( - (* Collect incorrect @dead annotation issue *) - let issue = - makeDeadIssue ~decl ~message:" is annotated @dead but is live" - IncorrectDeadAnnotation - in - decl.path - |> DcePath.toModuleName ~isType:(decl.declKind |> Decl.Kind.isType) - |> DeadModules.checkModuleDead ~config ~fileName:decl.pos.pos_fname - |> Option.iter (fun mod_issue -> issues := mod_issue :: !issues); - issues := issue :: !issues)); - if config.DceConfig.cli.debug then - let refsString = - newRefs |> PosSet.elements |> List.map Pos.toString - |> String.concat ", " - in - Log_.item "%s %s %s: %d references (%s) [%d]@." - (match isDead with - | true -> "Dead" - | false -> "Live") - (decl.declKind |> Decl.Kind.toString) - (decl.path |> DcePath.toString) - (newRefs |> PosSet.cardinal) - refsString level); - isDead - -let solveDead ~ann_store ~config ~decl_store ~ref_store ~file_deps_store - ~optional_args_state +(** Forward-based solver using refs_from direction. + Computes liveness via forward propagation, then processes declarations. *) +let solveDeadForward ~ann_store ~config ~decl_store ~refs ~optional_args_state ~checkOptionalArg: (checkOptionalArgFn : optional_args_state:OptionalArgsState.t -> @@ -345,53 +234,167 @@ let solveDead ~ann_store ~config ~decl_store ~ref_store ~file_deps_store config:DceConfig.t -> Decl.t -> Issue.t list) : AnalysisResult.t = - let iterDeclInOrder ~deadDeclarations ~issues ~orderedFiles decl = - let decl_refs = - match decl |> Decl.isValue with - | true -> ReferenceStore.find_value_refs ref_store decl.pos - | false -> ReferenceStore.find_type_refs ref_store decl.pos - in - resolveRecursiveRefs ~ref_store ~ann_store ~config ~decl_store - ~checkOptionalArg:(checkOptionalArgFn ~optional_args_state ~ann_store) - ~deadDeclarations ~issues ~level:0 ~orderedFiles - ~refsBeingResolved:(ref PosSet.empty) ~refs:decl_refs decl - |> ignore + (* Compute liveness using forward propagation *) + let debug = config.DceConfig.cli.debug in + let live = Liveness.compute_forward ~debug ~decl_store ~refs ~ann_store in + + (* Process each declaration based on computed liveness *) + let deadDeclarations = ref [] in + let inline_issues = ref [] in + + (* For consistent debug output, collect and sort declarations *) + let all_decls = + DeclarationStore.fold (fun _pos decl acc -> decl :: acc) decl_store [] + |> List.fast_sort Decl.compareForReporting in - if config.DceConfig.cli.debug then ( - Log_.item "@.File References@.@."; - let fileList = ref [] in - FileDepsStore.iter_deps file_deps_store (fun file files -> - fileList := (file, files) :: !fileList); - !fileList - |> List.sort (fun (f1, _) (f2, _) -> String.compare f1 f2) - |> List.iter (fun (file, files) -> - Log_.item "%s -->> %s@." - (file |> Filename.basename) - (files |> FileSet.elements |> List.map Filename.basename - |> String.concat ", "))); - let declarations = - DeclarationStore.fold - (fun _pos decl declarations -> decl :: declarations) - decl_store [] + + all_decls + |> List.iter (fun (decl : Decl.t) -> + let pos = decl.pos in + let live_reason = Liveness.get_live_reason ~live pos in + let is_live = Option.is_some live_reason in + let is_dead = not is_live in + + (* Debug output with reason *) + (if debug then + let refs_set = + match decl |> Decl.isValue with + | true -> References.find_value_refs refs pos + | false -> References.find_type_refs refs pos + in + let status = + match live_reason with + | None -> "Dead" + | Some reason -> + Printf.sprintf "Live (%s)" (Liveness.reason_to_string reason) + in + Log_.item "%s %s %s: %d references (%s)@." status + (decl.declKind |> Decl.Kind.toString) + (decl.path |> DcePath.toString) + (refs_set |> PosSet.cardinal) + (refs_set |> PosSet.elements |> List.map Pos.toString + |> String.concat ", ")); + + decl.resolvedDead <- Some is_dead; + + if is_dead then ( + decl.path + |> DeadModules.markDead ~config + ~isType:(decl.declKind |> Decl.Kind.isType) + ~loc:decl.moduleLoc; + if not (doReportDead ~ann_store decl.pos) then decl.report <- false; + deadDeclarations := decl :: !deadDeclarations) + else ( + (* Collect optional args issues for live declarations *) + checkOptionalArgFn ~optional_args_state ~ann_store ~config decl + |> List.iter (fun issue -> inline_issues := issue :: !inline_issues); + decl.path + |> DeadModules.markLive ~config + ~isType:(decl.declKind |> Decl.Kind.isType) + ~loc:decl.moduleLoc; + if AnnotationStore.is_annotated_dead ann_store decl.pos then ( + (* Collect incorrect @dead annotation issue *) + let issue = + makeDeadIssue ~decl ~message:" is annotated @dead but is live" + IncorrectDeadAnnotation + in + decl.path + |> DcePath.toModuleName ~isType:(decl.declKind |> Decl.Kind.isType) + |> DeadModules.checkModuleDead ~config ~fileName:decl.pos.pos_fname + |> Option.iter (fun mod_issue -> + inline_issues := mod_issue :: !inline_issues); + inline_issues := issue :: !inline_issues))); + + let sortedDeadDeclarations = + !deadDeclarations |> List.fast_sort Decl.compareForReporting in - let orderedFiles = Hashtbl.create 256 in - FileDepsStore.iter_files_from_roots_to_leaves file_deps_store - (let current = ref 0 in - fun fileName -> - incr current; - Hashtbl.add orderedFiles fileName !current); - let orderedDeclarations = - (* analyze in reverse order *) - declarations |> List.fast_sort (Decl.compareUsingDependencies ~orderedFiles) + + (* Collect issues from dead declarations *) + let reporting_ctx = ReportingContext.create () in + let dead_issues = + sortedDeadDeclarations + |> List.concat_map (fun decl -> + reportDeclaration ~config + ~ref_store:(ReferenceStore.of_frozen refs) + reporting_ctx decl) in + let all_issues = List.rev !inline_issues @ dead_issues in + AnalysisResult.add_issues AnalysisResult.empty all_issues + +(** Reactive solver using reactive liveness collection. *) +let solveDeadReactive ~ann_store ~config ~decl_store ~ref_store + ~(live : (Lexing.position, unit) Reactive.t) ~optional_args_state + ~checkOptionalArg: + (checkOptionalArgFn : + optional_args_state:OptionalArgsState.t -> + ann_store:AnnotationStore.t -> + config:DceConfig.t -> + Decl.t -> + Issue.t list) : AnalysisResult.t = + let debug = config.DceConfig.cli.debug in + let is_live pos = Reactive.get live pos <> None in + + (* Process each declaration based on computed liveness *) let deadDeclarations = ref [] in let inline_issues = ref [] in - orderedDeclarations - |> List.iter - (iterDeclInOrder ~orderedFiles ~deadDeclarations ~issues:inline_issues); + + (* For consistent debug output, collect and sort declarations *) + let all_decls = + DeclarationStore.fold (fun _pos decl acc -> decl :: acc) decl_store [] + |> List.fast_sort Decl.compareForReporting + in + + all_decls + |> List.iter (fun (decl : Decl.t) -> + let pos = decl.pos in + let is_live = is_live pos in + let is_dead = not is_live in + + (* Debug output *) + (if debug then + let refs_set = ReferenceStore.find_value_refs ref_store pos in + let status = if is_live then "Live" else "Dead" in + Log_.item "%s %s %s: %d references (%s)@." status + (decl.declKind |> Decl.Kind.toString) + (decl.path |> DcePath.toString) + (refs_set |> PosSet.cardinal) + (refs_set |> PosSet.elements |> List.map Pos.toString + |> String.concat ", ")); + + decl.resolvedDead <- Some is_dead; + + if is_dead then ( + decl.path + |> DeadModules.markDead ~config + ~isType:(decl.declKind |> Decl.Kind.isType) + ~loc:decl.moduleLoc; + if not (doReportDead ~ann_store decl.pos) then decl.report <- false; + deadDeclarations := decl :: !deadDeclarations) + else ( + (* Collect optional args issues for live declarations *) + checkOptionalArgFn ~optional_args_state ~ann_store ~config decl + |> List.iter (fun issue -> inline_issues := issue :: !inline_issues); + decl.path + |> DeadModules.markLive ~config + ~isType:(decl.declKind |> Decl.Kind.isType) + ~loc:decl.moduleLoc; + if AnnotationStore.is_annotated_dead ann_store decl.pos then ( + (* Collect incorrect @dead annotation issue *) + let issue = + makeDeadIssue ~decl ~message:" is annotated @dead but is live" + IncorrectDeadAnnotation + in + decl.path + |> DcePath.toModuleName ~isType:(decl.declKind |> Decl.Kind.isType) + |> DeadModules.checkModuleDead ~config ~fileName:decl.pos.pos_fname + |> Option.iter (fun mod_issue -> + inline_issues := mod_issue :: !inline_issues); + inline_issues := issue :: !inline_issues))); + let sortedDeadDeclarations = !deadDeclarations |> List.fast_sort Decl.compareForReporting in + (* Collect issues from dead declarations *) let reporting_ctx = ReportingContext.create () in let dead_issues = @@ -399,8 +402,17 @@ let solveDead ~ann_store ~config ~decl_store ~ref_store ~file_deps_store |> List.concat_map (fun decl -> reportDeclaration ~config ~ref_store reporting_ctx decl) in - (* Combine all issues: inline issues first (they were logged during analysis), - then dead declaration issues *) let all_issues = List.rev !inline_issues @ dead_issues in - (* Return result - caller is responsible for logging *) AnalysisResult.add_issues AnalysisResult.empty all_issues + +(** Main entry point - uses forward solver. *) +let solveDead ~ann_store ~config ~decl_store ~ref_store ~optional_args_state + ~checkOptionalArg : AnalysisResult.t = + match ReferenceStore.get_refs_opt ref_store with + | Some refs -> + solveDeadForward ~ann_store ~config ~decl_store ~refs ~optional_args_state + ~checkOptionalArg + | None -> + failwith + "solveDead: ReferenceStore must be Frozen (use solveDeadReactive for \ + reactive mode)" diff --git a/analysis/reanalyze/src/FileDepsStore.ml b/analysis/reanalyze/src/FileDepsStore.ml deleted file mode 100644 index 5c16bbacde..0000000000 --- a/analysis/reanalyze/src/FileDepsStore.ml +++ /dev/null @@ -1,46 +0,0 @@ -(** Abstraction over file dependency storage. - - Allows the solver to work with either: - - [Frozen]: Traditional [FileDeps.t] (copied from reactive) - - [Reactive]: Direct reactive collections (no copy, zero-cost on warm runs) *) - -type t = - | Frozen of FileDeps.t - | Reactive of { - files: (string, unit) Reactive.t; - deps: (string, FileSet.t) Reactive.t; - } - -let of_frozen fd = Frozen fd - -let of_reactive ~files ~deps = Reactive {files; deps} - -let get_deps t file = - match t with - | Frozen fd -> FileDeps.get_deps fd file - | Reactive r -> ( - match Reactive.get r.deps file with - | Some s -> s - | None -> FileSet.empty) - -let iter_deps t f = - match t with - | Frozen fd -> FileDeps.iter_deps fd f - | Reactive r -> Reactive.iter f r.deps - -(** Topological iteration from roots to leaves. - Works for both frozen and reactive - builds temporary structures as needed. *) -let iter_files_from_roots_to_leaves t iterFun = - match t with - | Frozen fd -> FileDeps.iter_files_from_roots_to_leaves fd iterFun - | Reactive r -> - (* Build temporary FileDeps.t from reactive collections for topo sort *) - let files = ref FileSet.empty in - Reactive.iter (fun f () -> files := FileSet.add f !files) r.files; - let deps = FileDeps.FileHash.create 256 in - Reactive.iter - (fun from_file to_files -> - FileDeps.FileHash.replace deps from_file to_files) - r.deps; - let fd = FileDeps.create ~files:!files ~deps in - FileDeps.iter_files_from_roots_to_leaves fd iterFun diff --git a/analysis/reanalyze/src/FileDepsStore.mli b/analysis/reanalyze/src/FileDepsStore.mli deleted file mode 100644 index 93983030a0..0000000000 --- a/analysis/reanalyze/src/FileDepsStore.mli +++ /dev/null @@ -1,28 +0,0 @@ -(** Abstraction over file dependency storage. - - Allows the solver to work with either: - - [Frozen]: Traditional [FileDeps.t] (copied from reactive) - - [Reactive]: Direct reactive collections (no copy, zero-cost on warm runs) *) - -type t = - | Frozen of FileDeps.t - | Reactive of { - files: (string, unit) Reactive.t; - deps: (string, FileSet.t) Reactive.t; - } (** File deps store with exposed constructors for pattern matching *) - -val of_frozen : FileDeps.t -> t -(** Wrap a frozen [FileDeps.t] *) - -val of_reactive : - files:(string, unit) Reactive.t -> deps:(string, FileSet.t) Reactive.t -> t -(** Wrap reactive collections directly *) - -val get_deps : t -> string -> FileSet.t -(** Get dependencies for a file *) - -val iter_deps : t -> (string -> FileSet.t -> unit) -> unit -(** Iterate over all dependencies *) - -val iter_files_from_roots_to_leaves : t -> (string -> unit) -> unit -(** Iterate files in topological order (roots first) *) diff --git a/analysis/reanalyze/src/Liveness.ml b/analysis/reanalyze/src/Liveness.ml new file mode 100644 index 0000000000..5c6059d72d --- /dev/null +++ b/analysis/reanalyze/src/Liveness.ml @@ -0,0 +1,229 @@ +(** Forward liveness fixpoint computation. + + Computes the set of live declarations by forward propagation: + 1. Start with roots (inherently live declarations) + 2. For each live declaration, mark what it references as live + 3. Repeat until fixpoint + + Roots include: + - Declarations annotated @live or @genType + - Declarations referenced from non-declaration positions (external uses) + + Note: refs_from is keyed by expression positions, not declaration positions. + We need to find all refs where posFrom is within the declaration's range. *) + +(** Reason why a declaration is live *) +type live_reason = + | Annotated (** Has @live or @genType annotation *) + | ExternalRef (** Referenced from outside any declaration *) + | Propagated (** Referenced by another live declaration *) + +let reason_to_string = function + | Annotated -> "annotated" + | ExternalRef -> "external ref" + | Propagated -> "propagated" + +(** Check if a position is within a declaration's range *) +let pos_in_decl (pos : Lexing.position) (decl : Decl.t) : bool = + pos.pos_fname = decl.pos.pos_fname + && pos.pos_cnum >= decl.posStart.pos_cnum + && pos.pos_cnum <= decl.posEnd.pos_cnum + +(** Build a hashtable mapping posTo -> bool indicating if it has external refs. + External refs are refs where posFrom is NOT a declaration position. + (Matching backward algorithm: it checks find_opt, not range containment) *) +let find_externally_referenced ~(decl_store : DeclarationStore.t) + ~(refs : References.t) : bool PosHash.t = + let externally_referenced = PosHash.create 256 in + + (* Helper: check if posFrom is a declaration position *) + let is_decl_pos posFrom = + DeclarationStore.find_opt decl_store posFrom <> None + in + + (* Check value refs *) + References.iter_value_refs_from refs (fun posFrom posToSet -> + if not (is_decl_pos posFrom) then + PosSet.iter + (fun posTo -> PosHash.replace externally_referenced posTo true) + posToSet); + + (* Check type refs *) + References.iter_type_refs_from refs (fun posFrom posToSet -> + if not (is_decl_pos posFrom) then + PosSet.iter + (fun posTo -> PosHash.replace externally_referenced posTo true) + posToSet); + + externally_referenced + +(** Check if a declaration is inherently live (a root) *) +let is_root ~ann_store ~externally_referenced (decl : Decl.t) = + AnnotationStore.is_annotated_gentype_or_live ann_store decl.pos + || PosHash.mem externally_referenced decl.pos + +(** Build index mapping declaration positions to their outgoing refs. + Done once upfront to avoid O(worklist × refs) in the main loop. + + Optimized by grouping declarations by file first, so we only check + declarations in the same file as each ref source. *) +let build_decl_refs_index ~(decl_store : DeclarationStore.t) + ~(refs : References.t) : (PosSet.t * PosSet.t) PosHash.t = + let index = PosHash.create 256 in + + (* Group declarations by file for efficient lookup *) + let decls_by_file : (string, (Lexing.position * Decl.t) list) Hashtbl.t = + Hashtbl.create 256 + in + DeclarationStore.iter + (fun pos decl -> + let fname = pos.Lexing.pos_fname in + let existing = + try Hashtbl.find decls_by_file fname with Not_found -> [] + in + Hashtbl.replace decls_by_file fname ((pos, decl) :: existing)) + decl_store; + + (* Helper to add targets to a declaration's index entry *) + let add_targets decl_pos targets ~is_type = + let value_targets, type_targets = + match PosHash.find_opt index decl_pos with + | Some pair -> pair + | None -> (PosSet.empty, PosSet.empty) + in + let new_pair = + if is_type then (value_targets, PosSet.union type_targets targets) + else (PosSet.union value_targets targets, type_targets) + in + PosHash.replace index decl_pos new_pair + in + + (* For each ref, find which declaration (in same file) contains its source *) + let process_ref posFrom posToSet ~is_type = + let fname = posFrom.Lexing.pos_fname in + match Hashtbl.find_opt decls_by_file fname with + | None -> () (* No declarations in this file *) + | Some decls_in_file -> + List.iter + (fun (decl_pos, decl) -> + if pos_in_decl posFrom decl then + add_targets decl_pos posToSet ~is_type) + decls_in_file + in + + References.iter_value_refs_from refs (fun posFrom posToSet -> + process_ref posFrom posToSet ~is_type:false); + References.iter_type_refs_from refs (fun posFrom posToSet -> + process_ref posFrom posToSet ~is_type:true); + + index + +(** Compute liveness using forward propagation from roots. + Returns a hashtable mapping positions to their live reason. *) +let compute_forward ~debug ~(decl_store : DeclarationStore.t) + ~(refs : References.t) ~(ann_store : AnnotationStore.t) : + live_reason PosHash.t = + let live = PosHash.create 256 in + let worklist = Queue.create () in + let root_count = ref 0 in + let propagated_count = ref 0 in + + (* Find declarations with external references *) + let externally_referenced = find_externally_referenced ~decl_store ~refs in + + (* Pre-compute index: decl_pos -> (value_targets, type_targets) *) + let decl_refs_index = build_decl_refs_index ~decl_store ~refs in + + if debug then Log_.item "@.Forward Liveness Analysis@.@."; + + (* Initialize with roots *) + DeclarationStore.iter + (fun pos decl -> + if is_root ~ann_store ~externally_referenced decl then ( + incr root_count; + let reason = + if AnnotationStore.is_annotated_gentype_or_live ann_store pos then + Annotated + else ExternalRef + in + PosHash.replace live pos reason; + Queue.push (pos, decl) worklist; + if debug then + Log_.item " Root (%s): %s %s@." (reason_to_string reason) + (decl.declKind |> Decl.Kind.toString) + (decl.path |> DcePath.toString))) + decl_store; + + if debug then Log_.item "@. %d roots found@.@." !root_count; + + (* Forward propagation fixpoint. + For each live declaration, look up its outgoing refs from the index. *) + while not (Queue.is_empty worklist) do + let pos, decl = Queue.pop worklist in + + (* Skip if this position is annotated @dead - don't propagate from it *) + if not (AnnotationStore.is_annotated_dead ann_store pos) then + (* Look up pre-computed targets for this declaration *) + match PosHash.find_opt decl_refs_index pos with + | None -> () (* No outgoing refs from this declaration *) + | Some (value_targets, type_targets) -> + (* Propagate to value targets that are value declarations *) + PosSet.iter + (fun target -> + if not (PosHash.mem live target) then + match DeclarationStore.find_opt decl_store target with + | Some target_decl + when not (target_decl.declKind |> Decl.Kind.isType) -> + incr propagated_count; + PosHash.replace live target Propagated; + Queue.push (target, target_decl) worklist; + if debug then + Log_.item " Propagate: %s -> %s@." + (decl.path |> DcePath.toString) + (target_decl.path |> DcePath.toString) + | Some _ -> + (* Type target from value ref - see below *) + () + | None -> + (* External or non-declaration target *) + PosHash.replace live target Propagated) + value_targets; + + (* Propagate to type targets that are type declarations *) + PosSet.iter + (fun target -> + if not (PosHash.mem live target) then + match DeclarationStore.find_opt decl_store target with + | Some target_decl when target_decl.declKind |> Decl.Kind.isType + -> + incr propagated_count; + PosHash.replace live target Propagated; + Queue.push (target, target_decl) worklist; + if debug then + Log_.item " Propagate: %s -> %s@." + (decl.path |> DcePath.toString) + (target_decl.path |> DcePath.toString) + | Some _ -> + (* Value target from type ref - skip *) + () + | None -> + (* External or non-declaration target *) + PosHash.replace live target Propagated) + type_targets + done; + + if debug then + Log_.item "@. %d declarations marked live via propagation@.@." + !propagated_count; + + live + +(** Check if a position is live according to forward-computed liveness *) +let is_live_forward ~(live : live_reason PosHash.t) (pos : Lexing.position) : + bool = + PosHash.mem live pos + +(** Get the reason why a position is live, if it is *) +let get_live_reason ~(live : live_reason PosHash.t) (pos : Lexing.position) : + live_reason option = + PosHash.find_opt live pos diff --git a/analysis/reanalyze/src/Liveness.mli b/analysis/reanalyze/src/Liveness.mli new file mode 100644 index 0000000000..5d34cb9082 --- /dev/null +++ b/analysis/reanalyze/src/Liveness.mli @@ -0,0 +1,36 @@ +(** Forward liveness fixpoint computation. + + Computes the set of live declarations by forward propagation: + 1. Start with roots (inherently live declarations) + 2. For each live declaration, mark what it references as live + 3. Repeat until fixpoint + + Roots include: + - Declarations annotated @live or @genType + - Declarations referenced from non-declaration positions (external uses) *) + +(** Reason why a declaration is live *) +type live_reason = + | Annotated (** Has @live or @genType annotation *) + | ExternalRef (** Referenced from outside any declaration *) + | Propagated (** Referenced by another live declaration *) + +val reason_to_string : live_reason -> string +(** Convert a live reason to a human-readable string *) + +val compute_forward : + debug:bool -> + decl_store:DeclarationStore.t -> + refs:References.t -> + ann_store:AnnotationStore.t -> + live_reason PosHash.t +(** Compute liveness using forward propagation. + Returns a hashtable mapping live positions to their [live_reason]. + Pass [~debug:true] for verbose output. *) + +val is_live_forward : live:live_reason PosHash.t -> Lexing.position -> bool +(** Check if a position is live according to forward-computed liveness *) + +val get_live_reason : + live:live_reason PosHash.t -> Lexing.position -> live_reason option +(** Get the reason why a position is live, if it is *) diff --git a/analysis/reanalyze/src/ReactiveDeclRefs.ml b/analysis/reanalyze/src/ReactiveDeclRefs.ml new file mode 100644 index 0000000000..21031638fd --- /dev/null +++ b/analysis/reanalyze/src/ReactiveDeclRefs.ml @@ -0,0 +1,82 @@ +(** Reactive mapping from declarations to their outgoing references. + + This is the reactive version of [Liveness.build_decl_refs_index]. + + For each declaration, computes the set of positions it references. + Updates incrementally when refs or declarations change. *) + +(** Build reactive index: decl_pos -> (value_targets, type_targets) + + Uses pure reactive combinators - no internal hashtables. *) +let create ~(decls : (Lexing.position, Decl.t) Reactive.t) + ~(value_refs_from : (Lexing.position, PosSet.t) Reactive.t) + ~(type_refs_from : (Lexing.position, PosSet.t) Reactive.t) : + (Lexing.position, PosSet.t * PosSet.t) Reactive.t = + (* Group declarations by file *) + let decls_by_file : (string, (Lexing.position * Decl.t) list) Reactive.t = + Reactive.flatMap decls + ~f:(fun pos decl -> [(pos.Lexing.pos_fname, [(pos, decl)])]) + ~merge:( @ ) () + in + + (* Check if posFrom is contained in decl's range *) + let pos_in_decl (posFrom : Lexing.position) (decl : Decl.t) : bool = + posFrom.pos_fname = decl.pos.pos_fname + && posFrom.pos_cnum >= decl.posStart.pos_cnum + && posFrom.pos_cnum <= decl.posEnd.pos_cnum + in + + (* For each ref, find which decl(s) contain it and output (decl_pos, targets) *) + let value_decl_refs : (Lexing.position, PosSet.t) Reactive.t = + Reactive.join value_refs_from decls_by_file + ~key_of:(fun posFrom _targets -> posFrom.Lexing.pos_fname) + ~f:(fun posFrom targets decls_opt -> + match decls_opt with + | None -> [] + | Some decls_in_file -> + decls_in_file + |> List.filter_map (fun (decl_pos, decl) -> + if pos_in_decl posFrom decl then Some (decl_pos, targets) + else None)) + ~merge:PosSet.union () + in + + let type_decl_refs : (Lexing.position, PosSet.t) Reactive.t = + Reactive.join type_refs_from decls_by_file + ~key_of:(fun posFrom _targets -> posFrom.Lexing.pos_fname) + ~f:(fun posFrom targets decls_opt -> + match decls_opt with + | None -> [] + | Some decls_in_file -> + decls_in_file + |> List.filter_map (fun (decl_pos, decl) -> + if pos_in_decl posFrom decl then Some (decl_pos, targets) + else None)) + ~merge:PosSet.union () + in + + (* Combine value and type refs into (value_targets, type_targets) pairs. + Use join to combine, with decls as the base to ensure all decls are present. *) + let with_value_refs : (Lexing.position, PosSet.t) Reactive.t = + Reactive.join decls value_decl_refs + ~key_of:(fun pos _decl -> pos) + ~f:(fun pos _decl refs_opt -> + [(pos, Option.value refs_opt ~default:PosSet.empty)]) + () + in + + let with_type_refs : (Lexing.position, PosSet.t) Reactive.t = + Reactive.join decls type_decl_refs + ~key_of:(fun pos _decl -> pos) + ~f:(fun pos _decl refs_opt -> + [(pos, Option.value refs_opt ~default:PosSet.empty)]) + () + in + + (* Combine into final (value_targets, type_targets) pairs *) + Reactive.join with_value_refs with_type_refs + ~key_of:(fun pos _value_targets -> pos) + ~f:(fun pos value_targets type_targets_opt -> + let type_targets = Option.value type_targets_opt ~default:PosSet.empty in + [(pos, (value_targets, type_targets))]) + () diff --git a/analysis/reanalyze/src/ReactiveDeclRefs.mli b/analysis/reanalyze/src/ReactiveDeclRefs.mli new file mode 100644 index 0000000000..e11f6510b6 --- /dev/null +++ b/analysis/reanalyze/src/ReactiveDeclRefs.mli @@ -0,0 +1,17 @@ +(** Reactive mapping from declarations to their outgoing references. + + This is the reactive version of [Liveness.build_decl_refs_index]. + Updates incrementally when refs or declarations change. + + Next step: combine with a reactive fixpoint combinator for fully + incremental liveness computation. *) + +val create : + decls:(Lexing.position, Decl.t) Reactive.t -> + value_refs_from:(Lexing.position, PosSet.t) Reactive.t -> + type_refs_from:(Lexing.position, PosSet.t) Reactive.t -> + (Lexing.position, PosSet.t * PosSet.t) Reactive.t +(** [create ~decls ~value_refs_from ~type_refs_from] creates a reactive index + mapping each declaration position to its outgoing references. + + Returns [(value_targets, type_targets)] for each declaration. *) diff --git a/analysis/reanalyze/src/ReactiveExceptionRefs.ml b/analysis/reanalyze/src/ReactiveExceptionRefs.ml index 675d5e0a9d..2775ad88b9 100644 --- a/analysis/reanalyze/src/ReactiveExceptionRefs.ml +++ b/analysis/reanalyze/src/ReactiveExceptionRefs.ml @@ -12,6 +12,7 @@ type t = { exception_decls: (DcePath.t, Location.t) Reactive.t; resolved_refs: (Lexing.position, PosSet.t) Reactive.t; + resolved_refs_from: (Lexing.position, PosSet.t) Reactive.t; } (** Reactive exception ref collections *) @@ -48,7 +49,7 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) ~f:(fun _path loc_from loc_to_opt -> match loc_to_opt with | Some loc_to -> - (* Add value reference: pos_to -> pos_from *) + (* Add value reference: pos_to -> pos_from (refs_to direction) *) [ ( loc_to.Location.loc_start, PosSet.singleton loc_from.Location.loc_start ); @@ -57,7 +58,16 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) ~merge:PosSet.union () in - {exception_decls; resolved_refs} + (* Step 3: Create refs_from direction by inverting *) + let resolved_refs_from = + Reactive.flatMap resolved_refs + ~f:(fun posTo posFromSet -> + PosSet.elements posFromSet + |> List.map (fun posFrom -> (posFrom, PosSet.singleton posTo))) + ~merge:PosSet.union () + in + + {exception_decls; resolved_refs; resolved_refs_from} (** {1 Freezing} *) diff --git a/analysis/reanalyze/src/ReactiveExceptionRefs.mli b/analysis/reanalyze/src/ReactiveExceptionRefs.mli index 95f24a34c9..8f918d7cfe 100644 --- a/analysis/reanalyze/src/ReactiveExceptionRefs.mli +++ b/analysis/reanalyze/src/ReactiveExceptionRefs.mli @@ -34,6 +34,9 @@ type t = { exception_decls: (DcePath.t, Location.t) Reactive.t; resolved_refs: (Lexing.position, PosSet.t) Reactive.t; + (** refs_to direction: target -> sources *) + resolved_refs_from: (Lexing.position, PosSet.t) Reactive.t; + (** refs_from direction: source -> targets (for forward solver) *) } (** Reactive exception ref collections *) diff --git a/analysis/reanalyze/src/ReactiveLiveness.ml b/analysis/reanalyze/src/ReactiveLiveness.ml new file mode 100644 index 0000000000..150511bf9c --- /dev/null +++ b/analysis/reanalyze/src/ReactiveLiveness.ml @@ -0,0 +1,101 @@ +(** Reactive liveness computation using fixpoint. + + Computes the set of live declarations by: + 1. Starting from roots (annotated + externally referenced) + 2. Propagating through references via fixpoint + + Uses pure reactive combinators - no internal hashtables. *) + +(** Compute reactive liveness from ReactiveMerge.t *) +let create ~(merged : ReactiveMerge.t) : (Lexing.position, unit) Reactive.t = + let decls = merged.decls in + let annotations = merged.annotations in + + (* Combine value refs using union: per-file refs + exception refs *) + let value_refs_from : (Lexing.position, PosSet.t) Reactive.t = + Reactive.union merged.value_refs_from + merged.exception_refs.resolved_refs_from ~merge:PosSet.union () + in + + (* Combine type refs using union: per-file refs + type deps from ReactiveTypeDeps *) + let type_refs_from : (Lexing.position, PosSet.t) Reactive.t = + Reactive.union merged.type_refs_from merged.type_deps.all_type_refs_from + ~merge:PosSet.union () + in + + (* Step 1: Build decl_refs_index - maps decl -> (value_targets, type_targets) *) + let decl_refs_index = + ReactiveDeclRefs.create ~decls ~value_refs_from ~type_refs_from + in + + (* Step 2: Convert to edges format for fixpoint: decl -> successor list *) + let edges : (Lexing.position, Lexing.position list) Reactive.t = + Reactive.flatMap decl_refs_index + ~f:(fun pos (value_targets, type_targets) -> + let all_targets = PosSet.union value_targets type_targets in + [(pos, PosSet.elements all_targets)]) + () + in + + (* Step 3: Compute roots - positions that are inherently live *) + (* Root if: annotated @live/@genType OR referenced from outside any decl *) + + (* Compute externally referenced positions reactively. + A position is externally referenced if any reference to it comes from + a position that is NOT a declaration position. + + We use join to check if posFrom is a decl position. *) + let external_value_refs : (Lexing.position, unit) Reactive.t = + Reactive.join value_refs_from decls + ~key_of:(fun posFrom _targets -> posFrom) + ~f:(fun _posFrom targets decl_opt -> + match decl_opt with + | Some _ -> [] (* posFrom is a decl, not external *) + | None -> + (* posFrom is not a decl, so all targets are externally referenced *) + PosSet.elements targets |> List.map (fun posTo -> (posTo, ()))) + ~merge:(fun () () -> ()) + () + in + + let external_type_refs : (Lexing.position, unit) Reactive.t = + Reactive.join type_refs_from decls + ~key_of:(fun posFrom _targets -> posFrom) + ~f:(fun _posFrom targets decl_opt -> + match decl_opt with + | Some _ -> [] (* posFrom is a decl, not external *) + | None -> + (* posFrom is not a decl, so all targets are externally referenced *) + PosSet.elements targets |> List.map (fun posTo -> (posTo, ()))) + ~merge:(fun () () -> ()) + () + in + + let externally_referenced : (Lexing.position, unit) Reactive.t = + Reactive.union external_value_refs external_type_refs + ~merge:(fun () () -> ()) + () + in + + (* Compute annotated roots: decls with @live or @genType *) + let annotated_roots : (Lexing.position, unit) Reactive.t = + Reactive.join decls annotations + ~key_of:(fun pos _decl -> pos) + ~f:(fun pos _decl ann_opt -> + match ann_opt with + | Some FileAnnotations.Live | Some FileAnnotations.GenType -> + [(pos, ())] + | _ -> []) + ~merge:(fun () () -> ()) + () + in + + (* Combine all roots *) + let all_roots : (Lexing.position, unit) Reactive.t = + Reactive.union annotated_roots externally_referenced + ~merge:(fun () () -> ()) + () + in + + (* Step 4: Compute fixpoint - all reachable positions from roots *) + Reactive.fixpoint ~init:all_roots ~edges () diff --git a/analysis/reanalyze/src/ReactiveLiveness.mli b/analysis/reanalyze/src/ReactiveLiveness.mli new file mode 100644 index 0000000000..a523e6500b --- /dev/null +++ b/analysis/reanalyze/src/ReactiveLiveness.mli @@ -0,0 +1,9 @@ +(** Reactive liveness computation using fixpoint. + + Computes the set of live declarations incrementally. *) + +val create : merged:ReactiveMerge.t -> (Lexing.position, unit) Reactive.t +(** [create ~merged] computes reactive liveness from merged DCE data. + + Returns a reactive collection where presence indicates the position is live. + Updates automatically when any input changes. *) diff --git a/analysis/reanalyze/src/ReactiveMerge.ml b/analysis/reanalyze/src/ReactiveMerge.ml index 9f1319de4e..7b11e3933e 100644 --- a/analysis/reanalyze/src/ReactiveMerge.ml +++ b/analysis/reanalyze/src/ReactiveMerge.ml @@ -10,6 +10,8 @@ type t = { annotations: (Lexing.position, FileAnnotations.annotated_as) Reactive.t; value_refs: (Lexing.position, PosSet.t) Reactive.t; type_refs: (Lexing.position, PosSet.t) Reactive.t; + value_refs_from: (Lexing.position, PosSet.t) Reactive.t; + type_refs_from: (Lexing.position, PosSet.t) Reactive.t; cross_file_items: (string, CrossFileItems.t) Reactive.t; file_deps_map: (string, FileSet.t) Reactive.t; files: (string, unit) Reactive.t; @@ -68,6 +70,30 @@ let create (source : (string, DceFileProcessing.file_data option) Reactive.t) : ~merge:PosSet.union () in + (* Value refs_from: (posFrom, PosSet of targets) with PosSet.union merge *) + let value_refs_from = + Reactive.flatMap source + ~f:(fun _path file_data_opt -> + match file_data_opt with + | None -> [] + | Some file_data -> + References.builder_value_refs_from_list + file_data.DceFileProcessing.refs) + ~merge:PosSet.union () + in + + (* Type refs_from: (posFrom, PosSet of targets) with PosSet.union merge *) + let type_refs_from = + Reactive.flatMap source + ~f:(fun _path file_data_opt -> + match file_data_opt with + | None -> [] + | Some file_data -> + References.builder_type_refs_from_list + file_data.DceFileProcessing.refs) + ~merge:PosSet.union () + in + (* Cross-file items: (path, CrossFileItems.t) with merge by concatenation *) let cross_file_items = Reactive.flatMap source @@ -143,6 +169,8 @@ let create (source : (string, DceFileProcessing.file_data option) Reactive.t) : annotations; value_refs; type_refs; + value_refs_from; + type_refs_from; cross_file_items; file_deps_map; files; @@ -165,12 +193,16 @@ let freeze_annotations (t : t) : FileAnnotations.t = FileAnnotations.create_from_hashtbl result (** Convert reactive refs to References.t for solver. - Includes type-label deps and exception refs from reactive computations. *) + Includes type-label deps and exception refs from reactive computations. + Builds both refs_to and refs_from directions. *) let freeze_refs (t : t) : References.t = - let value_refs = PosHash.create 256 in - let type_refs = PosHash.create 256 in - (* Helper to merge refs into a hashtable *) - let merge_into tbl posTo posFromSet = + let value_refs_to = PosHash.create 256 in + let type_refs_to = PosHash.create 256 in + let value_refs_from = PosHash.create 256 in + let type_refs_from = PosHash.create 256 in + + (* Helper to merge refs into refs_to hashtable *) + let merge_into_to tbl posTo posFromSet = let existing = match PosHash.find_opt tbl posTo with | Some s -> s @@ -178,28 +210,61 @@ let freeze_refs (t : t) : References.t = in PosHash.replace tbl posTo (PosSet.union existing posFromSet) in - (* Merge per-file value refs *) - Reactive.iter (fun pos refs -> merge_into value_refs pos refs) t.value_refs; - (* Merge per-file type refs *) - Reactive.iter (fun pos refs -> merge_into type_refs pos refs) t.type_refs; - (* Add type-label dependency refs from all sources *) - Reactive.iter - (fun pos refs -> merge_into type_refs pos refs) - t.type_deps.same_path_refs; - Reactive.iter - (fun pos refs -> merge_into type_refs pos refs) - t.type_deps.cross_file_refs; + + (* Helper to add to refs_from hashtable (inverse direction) *) + let add_to_from tbl posFrom posTo = + let existing = + match PosHash.find_opt tbl posFrom with + | Some s -> s + | None -> PosSet.empty + in + PosHash.replace tbl posFrom (PosSet.add posTo existing) + in + + (* Merge and invert per-file value refs *) Reactive.iter - (fun pos refs -> merge_into type_refs pos refs) - t.type_deps.impl_to_intf_refs_path2; + (fun posTo posFromSet -> + merge_into_to value_refs_to posTo posFromSet; + PosSet.iter + (fun posFrom -> add_to_from value_refs_from posFrom posTo) + posFromSet) + t.value_refs; + + (* Merge and invert per-file type refs *) Reactive.iter - (fun pos refs -> merge_into type_refs pos refs) - t.type_deps.intf_to_impl_refs; - (* Add exception refs (to value refs) *) + (fun posTo posFromSet -> + merge_into_to type_refs_to posTo posFromSet; + PosSet.iter + (fun posFrom -> add_to_from type_refs_from posFrom posTo) + posFromSet) + t.type_refs; + + (* Add and invert type-label dependency refs from all sources *) + let add_type_refs reactive = + Reactive.iter + (fun posTo posFromSet -> + merge_into_to type_refs_to posTo posFromSet; + PosSet.iter + (fun posFrom -> add_to_from type_refs_from posFrom posTo) + posFromSet) + reactive + in + add_type_refs t.type_deps.same_path_refs; + add_type_refs t.type_deps.cross_file_refs; + add_type_refs t.type_deps.impl_to_intf_refs_path2; + add_type_refs t.type_deps.intf_to_impl_refs; + + (* Add and invert exception refs (to value refs) *) Reactive.iter - (fun pos refs -> merge_into value_refs pos refs) + (fun posTo posFromSet -> + merge_into_to value_refs_to posTo posFromSet; + PosSet.iter + (fun posFrom -> add_to_from value_refs_from posFrom posTo) + posFromSet) t.exception_refs.resolved_refs; - References.create ~value_refs ~type_refs + + References.create ~value_refs_to ~type_refs_to ~value_refs_from + ~type_refs_from (** Collect all cross-file items *) let collect_cross_file_items (t : t) : CrossFileItems.t = diff --git a/analysis/reanalyze/src/ReactiveMerge.mli b/analysis/reanalyze/src/ReactiveMerge.mli index 6f0c3503b8..7fa54c81ef 100644 --- a/analysis/reanalyze/src/ReactiveMerge.mli +++ b/analysis/reanalyze/src/ReactiveMerge.mli @@ -28,7 +28,13 @@ type t = { decls: (Lexing.position, Decl.t) Reactive.t; annotations: (Lexing.position, FileAnnotations.annotated_as) Reactive.t; value_refs: (Lexing.position, PosSet.t) Reactive.t; + (** Value refs in refs_to direction: target -> sources *) type_refs: (Lexing.position, PosSet.t) Reactive.t; + (** Type refs in refs_to direction: target -> sources *) + value_refs_from: (Lexing.position, PosSet.t) Reactive.t; + (** Value refs in refs_from direction: source -> targets *) + type_refs_from: (Lexing.position, PosSet.t) Reactive.t; + (** Type refs in refs_from direction: source -> targets *) cross_file_items: (string, CrossFileItems.t) Reactive.t; file_deps_map: (string, FileSet.t) Reactive.t; files: (string, unit) Reactive.t; diff --git a/analysis/reanalyze/src/ReactiveTypeDeps.ml b/analysis/reanalyze/src/ReactiveTypeDeps.ml index f42102c11d..1988fb86f9 100644 --- a/analysis/reanalyze/src/ReactiveTypeDeps.ml +++ b/analysis/reanalyze/src/ReactiveTypeDeps.ml @@ -33,12 +33,14 @@ let decl_to_info (decl : Decl.t) : decl_info option = type t = { decl_by_path: (DcePath.t, decl_info list) Reactive.t; + (* refs_to direction: target -> sources *) same_path_refs: (Lexing.position, PosSet.t) Reactive.t; cross_file_refs: (Lexing.position, PosSet.t) Reactive.t; all_type_refs: (Lexing.position, PosSet.t) Reactive.t; - (* Additional cross-file sources for complete coverage *) impl_to_intf_refs_path2: (Lexing.position, PosSet.t) Reactive.t; intf_to_impl_refs: (Lexing.position, PosSet.t) Reactive.t; + (* refs_from direction: source -> targets (for forward solver) *) + all_type_refs_from: (Lexing.position, PosSet.t) Reactive.t; } (** All reactive collections for type-label dependencies *) @@ -198,6 +200,27 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) We expose these separately and merge in freeze_refs. *) let all_type_refs = same_path_refs in + (* Create refs_from by combining and inverting all refs_to sources. + We use a single flatMap that iterates all sources once. *) + let all_type_refs_from = + (* Combine all refs_to sources using union *) + let combined_refs_to = + let u1 = + Reactive.union same_path_refs cross_file_refs ~merge:PosSet.union () + in + let u2 = + Reactive.union u1 impl_to_intf_refs_path2 ~merge:PosSet.union () + in + Reactive.union u2 intf_to_impl_refs ~merge:PosSet.union () + in + (* Invert the combined refs_to to refs_from *) + Reactive.flatMap combined_refs_to + ~f:(fun posTo posFromSet -> + PosSet.elements posFromSet + |> List.map (fun posFrom -> (posFrom, PosSet.singleton posTo))) + ~merge:PosSet.union () + in + { decl_by_path; same_path_refs; @@ -205,6 +228,7 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) all_type_refs; impl_to_intf_refs_path2; intf_to_impl_refs; + all_type_refs_from; } (** {1 Freezing for solver} *) diff --git a/analysis/reanalyze/src/ReactiveTypeDeps.mli b/analysis/reanalyze/src/ReactiveTypeDeps.mli index 5836719baa..ac6c9ff2aa 100644 --- a/analysis/reanalyze/src/ReactiveTypeDeps.mli +++ b/analysis/reanalyze/src/ReactiveTypeDeps.mli @@ -31,12 +31,14 @@ type t = { decl_by_path: (DcePath.t, decl_info list) Reactive.t; + (* refs_to direction: target -> sources *) same_path_refs: (Lexing.position, PosSet.t) Reactive.t; cross_file_refs: (Lexing.position, PosSet.t) Reactive.t; all_type_refs: (Lexing.position, PosSet.t) Reactive.t; - (* Additional cross-file sources for complete coverage *) impl_to_intf_refs_path2: (Lexing.position, PosSet.t) Reactive.t; intf_to_impl_refs: (Lexing.position, PosSet.t) Reactive.t; + (* refs_from direction: source -> targets (for forward solver) *) + all_type_refs_from: (Lexing.position, PosSet.t) Reactive.t; } (** Reactive type-label dependency collections *) diff --git a/analysis/reanalyze/src/Reanalyze.ml b/analysis/reanalyze/src/Reanalyze.ml index 5c6d965c39..6898d55151 100644 --- a/analysis/reanalyze/src/Reanalyze.ml +++ b/analysis/reanalyze/src/Reanalyze.ml @@ -268,7 +268,7 @@ let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge = let analysis_result = if dce_config.DceConfig.run.dce then (* Merging phase: combine all builders -> immutable data *) - let ann_store, decl_store, cross_file_store, ref_store, file_deps_store = + let ann_store, decl_store, cross_file_store, ref_store = Timing.time_phase `Merging (fun () -> (* Use reactive merge if available, otherwise list-based merge *) let ann_store, decl_store, cross_file_store = @@ -298,25 +298,18 @@ let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge = |> List.map (fun fd -> fd.DceFileProcessing.cross_file))) ) in - (* Compute refs and file_deps. + (* Compute refs. In reactive mode, use stores directly (skip freeze!). In non-reactive mode, use the imperative processing. *) - let ref_store, file_deps_store = + let ref_store = match reactive_merge with | Some merged -> - (* Reactive mode: use stores directly, skip freeze! *) - let ref_store = - ReferenceStore.of_reactive ~value_refs:merged.value_refs - ~type_refs:merged.type_refs ~type_deps:merged.type_deps - ~exception_refs:merged.exception_refs - in - let file_deps_store = - FileDepsStore.of_reactive ~files:merged.files - ~deps:merged.file_deps_map - in - (ref_store, file_deps_store) + (* Reactive mode: use stores directly *) + ReferenceStore.of_reactive ~value_refs:merged.value_refs + ~type_refs:merged.type_refs ~type_deps:merged.type_deps + ~exception_refs:merged.exception_refs | None -> - (* Non-reactive mode: build refs/file_deps imperatively *) + (* Non-reactive mode: build refs imperatively *) (* Need Declarations.t for type deps processing *) let decls = match decl_store with @@ -361,23 +354,31 @@ let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge = CrossFileItems.process_exception_refs cross_file ~refs:refs_builder ~file_deps:file_deps_builder ~find_exception ~config:dce_config; - (* Freeze refs and file_deps for solver *) + (* Freeze refs for solver *) let refs = References.freeze_builder refs_builder in - let file_deps = FileDeps.freeze_builder file_deps_builder in - ( ReferenceStore.of_frozen refs, - FileDepsStore.of_frozen file_deps ) + ReferenceStore.of_frozen refs in - (ann_store, decl_store, cross_file_store, ref_store, file_deps_store)) + (ann_store, decl_store, cross_file_store, ref_store)) in (* Solving phase: run the solver and collect issues *) Timing.time_phase `Solving (fun () -> let empty_optional_args_state = OptionalArgsState.create () in let analysis_result_core = - DeadCommon.solveDead ~ann_store ~decl_store ~ref_store - ~file_deps_store ~optional_args_state:empty_optional_args_state - ~config:dce_config - ~checkOptionalArg:(fun - ~optional_args_state:_ ~ann_store:_ ~config:_ _ -> []) + match reactive_merge with + | Some merged -> + (* Reactive mode: use reactive liveness *) + let live = ReactiveLiveness.create ~merged in + DeadCommon.solveDeadReactive ~ann_store ~decl_store ~ref_store + ~live ~optional_args_state:empty_optional_args_state + ~config:dce_config + ~checkOptionalArg:(fun + ~optional_args_state:_ ~ann_store:_ ~config:_ _ -> []) + | None -> + DeadCommon.solveDead ~ann_store ~decl_store ~ref_store + ~optional_args_state:empty_optional_args_state + ~config:dce_config + ~checkOptionalArg:(fun + ~optional_args_state:_ ~ann_store:_ ~config:_ _ -> []) in (* Compute liveness-aware optional args state *) let is_live pos = diff --git a/analysis/reanalyze/src/ReferenceStore.ml b/analysis/reanalyze/src/ReferenceStore.ml index 1cff4a1918..b69365885f 100644 --- a/analysis/reanalyze/src/ReferenceStore.ml +++ b/analysis/reanalyze/src/ReferenceStore.ml @@ -66,3 +66,9 @@ let find_type_refs t pos = |> PosSet.union from_cross_file |> PosSet.union from_impl_intf2 |> PosSet.union from_intf_impl + +(** Get underlying References.t for Frozen stores. Used for forward liveness. *) +let get_refs_opt t = + match t with + | Frozen refs -> Some refs + | Reactive _ -> None diff --git a/analysis/reanalyze/src/ReferenceStore.mli b/analysis/reanalyze/src/ReferenceStore.mli index a0e88b9fb8..d0f93f6026 100644 --- a/analysis/reanalyze/src/ReferenceStore.mli +++ b/analysis/reanalyze/src/ReferenceStore.mli @@ -20,8 +20,13 @@ val of_reactive : t (** Wrap reactive collections directly (no copy) *) +(** {2 refs_to direction (for reporting)} *) + val find_value_refs : t -> Lexing.position -> PosSet.t -(** Find value references to a position *) +(** Find who value-references this position *) val find_type_refs : t -> Lexing.position -> PosSet.t -(** Find type references to a position *) +(** Find who type-references this position *) + +val get_refs_opt : t -> References.t option +(** Get underlying References.t for Frozen stores. Returns None for Reactive. *) diff --git a/analysis/reanalyze/src/References.ml b/analysis/reanalyze/src/References.ml index c566aedd9b..920a0930a2 100644 --- a/analysis/reanalyze/src/References.ml +++ b/analysis/reanalyze/src/References.ml @@ -2,7 +2,13 @@ Two types are provided: - [builder] - mutable, for AST processing - - [t] - immutable, for solver (read-only access) *) + - [t] - immutable, for solver (read-only access) + + References are stored in BOTH directions: + - refs_to: posTo -> {posFrom1, posFrom2, ...} = who references posTo + - refs_from: posFrom -> {posTo1, posTo2, ...} = what posFrom references + + This allows gradual migration from backward to forward algorithms. *) (* Helper to add to a set in a hashtable *) let addSet h k v = @@ -12,62 +18,120 @@ let addSet h k v = (* Helper to find a set in a hashtable *) let findSet h k = try PosHash.find h k with Not_found -> PosSet.empty -(* Internal representation: two hashtables *) +(* Internal representation: four hashtables (two directions x two ref types) *) type refs_table = PosSet.t PosHash.t -type builder = {value_refs: refs_table; type_refs: refs_table} - -type t = {value_refs: refs_table; type_refs: refs_table} +type builder = { + (* refs_to direction: posTo -> {sources that reference it} *) + value_refs_to: refs_table; + type_refs_to: refs_table; + (* refs_from direction: posFrom -> {targets it references} *) + value_refs_from: refs_table; + type_refs_from: refs_table; +} + +type t = { + value_refs_to: refs_table; + type_refs_to: refs_table; + value_refs_from: refs_table; + type_refs_from: refs_table; +} (* ===== Builder API ===== *) let create_builder () : builder = - {value_refs = PosHash.create 256; type_refs = PosHash.create 256} - + { + value_refs_to = PosHash.create 256; + type_refs_to = PosHash.create 256; + value_refs_from = PosHash.create 256; + type_refs_from = PosHash.create 256; + } + +(* Store in both directions *) let add_value_ref (builder : builder) ~posTo ~posFrom = - addSet builder.value_refs posTo posFrom + addSet builder.value_refs_to posTo posFrom; + addSet builder.value_refs_from posFrom posTo let add_type_ref (builder : builder) ~posTo ~posFrom = - addSet builder.type_refs posTo posFrom + addSet builder.type_refs_to posTo posFrom; + addSet builder.type_refs_from posFrom posTo let merge_into_builder ~(from : builder) ~(into : builder) = + (* Merge refs_to direction *) PosHash.iter (fun pos refs -> - refs |> PosSet.iter (fun fromPos -> addSet into.value_refs pos fromPos)) - from.value_refs; + refs |> PosSet.iter (fun fromPos -> addSet into.value_refs_to pos fromPos)) + from.value_refs_to; PosHash.iter (fun pos refs -> - refs |> PosSet.iter (fun fromPos -> addSet into.type_refs pos fromPos)) - from.type_refs + refs |> PosSet.iter (fun fromPos -> addSet into.type_refs_to pos fromPos)) + from.type_refs_to; + (* Merge refs_from direction *) + PosHash.iter + (fun pos refs -> + refs |> PosSet.iter (fun toPos -> addSet into.value_refs_from pos toPos)) + from.value_refs_from; + PosHash.iter + (fun pos refs -> + refs |> PosSet.iter (fun toPos -> addSet into.type_refs_from pos toPos)) + from.type_refs_from let merge_all (builders : builder list) : t = let result = create_builder () in builders |> List.iter (fun builder -> merge_into_builder ~from:builder ~into:result); - {value_refs = result.value_refs; type_refs = result.type_refs} + { + value_refs_to = result.value_refs_to; + type_refs_to = result.type_refs_to; + value_refs_from = result.value_refs_from; + type_refs_from = result.type_refs_from; + } let freeze_builder (builder : builder) : t = (* Zero-copy freeze - builder should not be used after this *) - {value_refs = builder.value_refs; type_refs = builder.type_refs} + { + value_refs_to = builder.value_refs_to; + type_refs_to = builder.type_refs_to; + value_refs_from = builder.value_refs_from; + type_refs_from = builder.type_refs_from; + } (* ===== Builder extraction for reactive merge ===== *) +(* Extract refs_to direction (posTo -> {sources}) *) let builder_value_refs_to_list (builder : builder) : (Lexing.position * PosSet.t) list = - PosHash.fold (fun pos refs acc -> (pos, refs) :: acc) builder.value_refs [] + PosHash.fold (fun pos refs acc -> (pos, refs) :: acc) builder.value_refs_to [] let builder_type_refs_to_list (builder : builder) : (Lexing.position * PosSet.t) list = - PosHash.fold (fun pos refs acc -> (pos, refs) :: acc) builder.type_refs [] + PosHash.fold (fun pos refs acc -> (pos, refs) :: acc) builder.type_refs_to [] -let create ~value_refs ~type_refs : t = {value_refs; type_refs} +(* Extract refs_from direction (posFrom -> {targets}) *) +let builder_value_refs_from_list (builder : builder) : + (Lexing.position * PosSet.t) list = + PosHash.fold + (fun pos refs acc -> (pos, refs) :: acc) + builder.value_refs_from [] -(* ===== Read-only API ===== *) +let builder_type_refs_from_list (builder : builder) : + (Lexing.position * PosSet.t) list = + PosHash.fold + (fun pos refs acc -> (pos, refs) :: acc) + builder.type_refs_from [] + +let create ~value_refs_to ~type_refs_to ~value_refs_from ~type_refs_from : t = + {value_refs_to; type_refs_to; value_refs_from; type_refs_from} -let find_value_refs (t : t) pos = findSet t.value_refs pos +(* ===== Read-only API ===== *) -let find_type_refs (t : t) pos = findSet t.type_refs pos +(* refs_to direction: find who references this position (for reporting) *) +let find_value_refs (t : t) pos = findSet t.value_refs_to pos +let find_type_refs (t : t) pos = findSet t.type_refs_to pos -let value_refs_length (t : t) = PosHash.length t.value_refs +let value_refs_length (t : t) = PosHash.length t.value_refs_to +let type_refs_length (t : t) = PosHash.length t.type_refs_to -let type_refs_length (t : t) = PosHash.length t.type_refs +(* refs_from direction: iterate over what positions reference (for liveness) *) +let iter_value_refs_from (t : t) f = PosHash.iter f t.value_refs_from +let iter_type_refs_from (t : t) f = PosHash.iter f t.type_refs_from diff --git a/analysis/reanalyze/src/References.mli b/analysis/reanalyze/src/References.mli index 89f653657d..2ff2af1bad 100644 --- a/analysis/reanalyze/src/References.mli +++ b/analysis/reanalyze/src/References.mli @@ -4,8 +4,11 @@ - [builder] - mutable, for AST processing - [t] - immutable, for solver (read-only access) - References track which positions reference which declarations. - Both value references and type references are tracked. *) + References are stored in BOTH directions: + - refs_to: posTo -> {sources that reference it} + - refs_from: posFrom -> {targets it references} + + This enables gradual migration from backward to forward algorithms. *) (** {2 Types} *) @@ -18,10 +21,14 @@ type builder (** {2 Builder API - for AST processing} *) val create_builder : unit -> builder + val add_value_ref : builder -> posTo:Lexing.position -> posFrom:Lexing.position -> unit +(** Add a value reference. Stores in both directions. *) + val add_type_ref : builder -> posTo:Lexing.position -> posFrom:Lexing.position -> unit +(** Add a type reference. Stores in both directions. *) val merge_into_builder : from:builder -> into:builder -> unit (** Merge one builder into another. *) @@ -35,19 +42,42 @@ val freeze_builder : builder -> t (** {2 Builder extraction for reactive merge} *) val builder_value_refs_to_list : builder -> (Lexing.position * PosSet.t) list -(** Extract all value refs as a list for reactive merge *) +(** Extract value refs in refs_to direction (posTo -> sources) *) val builder_type_refs_to_list : builder -> (Lexing.position * PosSet.t) list -(** Extract all type refs as a list for reactive merge *) +(** Extract type refs in refs_to direction (posTo -> sources) *) -val create : value_refs:PosSet.t PosHash.t -> type_refs:PosSet.t PosHash.t -> t -(** Create a References.t from hashtables *) +val builder_value_refs_from_list : builder -> (Lexing.position * PosSet.t) list +(** Extract value refs in refs_from direction (posFrom -> targets) *) -(** {2 Read-only API for t - for solver} *) +val builder_type_refs_from_list : builder -> (Lexing.position * PosSet.t) list +(** Extract type refs in refs_from direction (posFrom -> targets) *) + +val create : + value_refs_to:PosSet.t PosHash.t -> + type_refs_to:PosSet.t PosHash.t -> + value_refs_from:PosSet.t PosHash.t -> + type_refs_from:PosSet.t PosHash.t -> + t +(** Create a References.t from hashtables (all four directions) *) + +(** {2 Read-only API - refs_to direction (for reporting)} *) val find_value_refs : t -> Lexing.position -> PosSet.t +(** Find who value-references this position *) + val find_type_refs : t -> Lexing.position -> PosSet.t +(** Find who type-references this position *) -val value_refs_length : t -> int +(** {2 Read-only API - refs_from direction (for liveness)} *) + +val iter_value_refs_from : t -> (Lexing.position -> PosSet.t -> unit) -> unit +(** Iterate all value refs in refs_from direction *) + +val iter_type_refs_from : t -> (Lexing.position -> PosSet.t -> unit) -> unit +(** Iterate all type refs in refs_from direction *) +(** {2 Length} *) + +val value_refs_length : t -> int val type_refs_length : t -> int diff --git a/tests/analysis_tests/tests-reanalyze/deadcode/expected/deadcode.txt b/tests/analysis_tests/tests-reanalyze/deadcode/expected/deadcode.txt index f2dacf6bf8..9a4c1c2d2e 100644 --- a/tests/analysis_tests/tests-reanalyze/deadcode/expected/deadcode.txt +++ b/tests/analysis_tests/tests-reanalyze/deadcode/expected/deadcode.txt @@ -1805,733 +1805,1000 @@ addTypeReference DeadTypeTest.res:9:2 --> DeadTypeTest.resi:9:2 addValueReference TestDeadExn.res:1:7 --> DeadExn.res:1:0 -File References - - AutoAnnotate.res -->> - BootloaderResource.res -->> - BucklescriptAnnotations.res -->> - ComponentAsProp.res -->> React.res - CreateErrorHandler1.res -->> ErrorHandler.resi - CreateErrorHandler2.res -->> - DeadCodeImplementation.res -->> - DeadCodeInterface.res -->> - DeadExn.res -->> - DeadExn.resi -->> - DeadRT.res -->> - DeadRT.resi -->> - DeadTest.res -->> DeadValueTest.resi, DynamicallyLoadedComponent.res, ImmutableArray.resi, React.res - DeadTestBlacklist.res -->> - DeadTestWithInterface.res -->> - DeadTypeTest.res -->> - DeadTypeTest.resi -->> DeadTypeTest.res - DeadValueTest.res -->> - DeadValueTest.resi -->> DeadValueTest.res - Docstrings.res -->> - DynamicallyLoadedComponent.res -->> React.res - EmptyArray.res -->> - ErrorHandler.res -->> - ErrorHandler.resi -->> ErrorHandler.res - EverythingLiveHere.res -->> - FirstClassModules.res -->> - FirstClassModulesInterface.res -->> - FirstClassModulesInterface.resi -->> FirstClassModulesInterface.res - Hooks.res -->> ImportHookDefault.res, ImportHooks.res, React.res - IgnoreInterface.res -->> - IgnoreInterface.resi -->> - ImmutableArray.res -->> - ImmutableArray.resi -->> ImmutableArray.res - ImportHookDefault.res -->> - ImportHooks.res -->> - ImportIndex.res -->> - ImportJsValue.res -->> - ImportMyBanner.res -->> - InnerModuleTypes.res -->> - InnerModuleTypes.resi -->> - JSResource.res -->> - JsxV4.res -->> React.res - LetPrivate.res -->> - ModuleAliases.res -->> - ModuleAliases2.res -->> - ModuleExceptionBug.res -->> - NestedModules.res -->> - NestedModulesInSignature.res -->> - NestedModulesInSignature.resi -->> NestedModulesInSignature.res - Newsyntax.res -->> - Newton.res -->> - Opaque.res -->> - OptArg.res -->> - OptArg.resi -->> OptArg.res - OptionalArgsLiveDead.res -->> - Records.res -->> - References.res -->> - RepeatedLabel.res -->> - RequireCond.res -->> - Shadow.res -->> - TestDeadExn.res -->> DeadExn.res - TestEmitInnerModules.res -->> - TestFirstClassModules.res -->> - TestImmutableArray.res -->> ImmutableArray.resi - TestImport.res -->> - TestInnedModuleTypes.res -->> - TestModuleAliases.res -->> - TestOptArg.res -->> OptArg.resi - TestPromise.res -->> - ToSuppress.res -->> - TransitiveType1.res -->> - TransitiveType2.res -->> - TransitiveType3.res -->> - Tuples.res -->> - TypeParams1.res -->> - TypeParams2.res -->> - TypeParams3.res -->> - Types.res -->> - Unboxed.res -->> - Uncurried.res -->> - Unison.res -->> - UseImportJsValue.res -->> ImportJsValue.res - Variants.res -->> - VariantsWithPayload.res -->> - Dead VariantCase +AutoAnnotate.annotatedVariant.R4: 0 references () [0] - Dead VariantCase +AutoAnnotate.annotatedVariant.R2: 0 references () [0] - Dead RecordLabel +AutoAnnotate.r4.r4: 0 references () [0] - Dead RecordLabel +AutoAnnotate.r3.r3: 0 references () [0] - Dead RecordLabel +AutoAnnotate.r2.r2: 0 references () [0] - Dead RecordLabel +AutoAnnotate.record.variant: 0 references () [0] - Dead VariantCase +AutoAnnotate.variant.R: 0 references () [0] - Dead Value +BucklescriptAnnotations.+bar: 0 references () [1] - Dead Value +BucklescriptAnnotations.+f: 0 references () [0] - Live RecordLabel +ComponentAsProp.props.button: 1 references (_none_:1:-1) [0] - Live RecordLabel +ComponentAsProp.props.description: 1 references (_none_:1:-1) [0] - Live RecordLabel +ComponentAsProp.props.title: 1 references (_none_:1:-1) [0] - Live Value +ComponentAsProp.+make: 0 references () [0] - Live Value +CreateErrorHandler1.Error1.+notification: 1 references (ErrorHandler.resi:3:2) [0] - Live Value +CreateErrorHandler2.Error2.+notification: 1 references (ErrorHandler.resi:3:2) [0] - Live Value +DeadCodeImplementation.M.+x: 1 references (DeadCodeInterface.res:2:2) [0] - Dead Value +DeadRT.+emitModuleAccessPath: 0 references () [0] - Live VariantCase +DeadRT.moduleAccessPath.Kaboom: 1 references (DeadRT.res:11:16) [0] - Live VariantCase DeadRT.moduleAccessPath.Root: 1 references (DeadTest.res:98:16) [1] - Live VariantCase +DeadRT.moduleAccessPath.Root: 1 references (DeadRT.resi:2:2) [0] - Live VariantCase DeadRT.moduleAccessPath.Kaboom: 1 references (DeadRT.res:3:2) [0] - Dead RecordLabel +DeadTest.inlineRecord3.IR3.b: 0 references () [0] - Dead RecordLabel +DeadTest.inlineRecord3.IR3.a: 0 references () [0] - Dead VariantCase +DeadTest.inlineRecord3.IR3: 0 references () [0] - Dead RecordLabel +DeadTest.inlineRecord2.IR2.b: 0 references () [0] - Dead RecordLabel +DeadTest.inlineRecord2.IR2.a: 0 references () [0] - Dead VariantCase +DeadTest.inlineRecord2.IR2: 0 references () [0] - Dead Value +DeadTest.+_: 0 references () [0] - Live Value +DeadTest.+ira: 1 references (DeadTest.res:163:27) [0] - Live RecordLabel +DeadTest.inlineRecord.IR.e: 0 references () [0] - Dead RecordLabel +DeadTest.inlineRecord.IR.d: 0 references () [0] - Live RecordLabel +DeadTest.inlineRecord.IR.c: 1 references (DeadTest.res:163:7) [0] - Live RecordLabel +DeadTest.inlineRecord.IR.b: 1 references (DeadTest.res:163:35) [0] - Dead RecordLabel +DeadTest.inlineRecord.IR.a: 0 references () [0] - Live VariantCase +DeadTest.inlineRecord.IR: 1 references (DeadTest.res:163:20) [0] - Dead Value +DeadTest.+_: 0 references () [0] - Live Value +DeadTest.+deadIncorrect: 1 references (DeadTest.res:156:8) [0] - Dead RecordLabel +DeadTest.rc.a: 0 references () [0] - Dead Value +DeadTest.+funWithInnerVars: 0 references () [1] - Dead Value +DeadTest.+y: 0 references () [0] - Dead Value +DeadTest.+x: 0 references () [0] - Live VariantCase +DeadTest.WithInclude.t.A: 1 references (DeadTest.res:142:7) [1] - Live VariantCase +DeadTest.WithInclude.t.A: 1 references (DeadTest.res:134:11) [0] - Live Value +DeadTest.GloobLive.+globallyLive3: 0 references () [0] - Live Value +DeadTest.GloobLive.+globallyLive2: 0 references () [0] - Live Value +DeadTest.GloobLive.+globallyLive1: 0 references () [0] - Dead Value +DeadTest.+stringLengthNoSideEffects: 0 references () [0] - Dead Value +DeadTest.+theSideEffectIsLogging: 0 references () [0] - Live RecordLabel +DeadTest.props.s: 1 references (_none_:1:-1) [0] - Live Value +DeadTest.+make: 1 references (DeadTest.res:119:16) [0] - Dead Value +DeadTest.+deadRef: 0 references () [0] - Dead Value +DeadTest.+second: 0 references () [0] - Dead Value +DeadTest.+a3: 0 references () [0] - Dead Value +DeadTest.+a2: 0 references () [0] - Dead Value +DeadTest.+a1: 0 references () [0] - Dead Value +DeadTest.+zzz: 0 references () [0] - Dead Value +DeadTest.+withDefaultValue: 0 references () [0] - Dead Value +DeadTest.+bar: 0 references () [0] - Dead Value +DeadTest.+foo: 0 references () [1] - Dead Value +DeadTest.+cb: 0 references () [0] - Dead Value +DeadTest.+cb: 0 references () [0] - Dead Value +DeadTest.+recWithCallback: 0 references () [0] - Dead Value +DeadTest.+rec2: 0 references () [0] - Dead Value +DeadTest.+rec1: 0 references () [0] - Dead Value +DeadTest.+split_map: 0 references () [0] - Dead Value +DeadTest.+unusedRec: 0 references () [0] - Dead Value +DeadTest.MM.+valueOnlyInImplementation: 0 references () [0] - Live Value +DeadTest.MM.+x: 1 references (DeadTest.res:69:9) [1] - Live Value +DeadTest.MM.+x: 1 references (DeadTest.res:60:2) [0] - Dead Value +DeadTest.MM.+y: 0 references () [1] - Live Value +DeadTest.MM.+y: 1 references (DeadTest.res:64:6) [0] - Dead Value +DeadTest.UnderscoreInside.+_: 0 references () [0] - Dead Value +DeadTest.+_: 0 references () [0] - Dead Value +DeadTest.+_: 0 references () [0] - Live RecordLabel +DeadTest.record.yyy: 1 references (DeadTest.res:53:9) [0] - Live RecordLabel +DeadTest.record.xxx: 1 references (DeadTest.res:52:13) [0] - Dead Value +DeadTest.+_: 0 references () [0] - Dead Value +DeadTest.+_: 0 references () [0] - Live Value +DeadTest.VariantUsedOnlyInImplementation.+a: 1 references (DeadTest.res:42:17) [1] - Live Value +DeadTest.VariantUsedOnlyInImplementation.+a: 1 references (DeadTest.res:36:2) [0] - Live VariantCase +DeadTest.VariantUsedOnlyInImplementation.t.A: 1 references (DeadTest.res:39:10) [0] - Live VariantCase +DeadTest.VariantUsedOnlyInImplementation.t.A: 1 references (DeadTest.res:38:11) [0] - Dead Value +DeadTest.M.+thisSignatureItemIsDead: 0 references () [1] - Dead Value +DeadTest.M.+thisSignatureItemIsDead: 0 references () [0] - Dead Value +DeadTest.Inner.+thisIsAlsoMarkedDead: 0 references () [0] - Live Value +DeadTest.+thisIsMarkedLive: 0 references () [0] - Live Value +DeadTest.+thisIsKeptAlive: 1 references (DeadTest.res:20:4) [0] - Dead Value +DeadTest.+thisIsMarkedDead: 0 references () [0] - Live Value +DeadTest.+thisIsUsedTwice: 2 references (DeadTest.res:11:7, DeadTest.res:12:7) [0] - Live Value +DeadTest.+thisIsUsedOnce: 1 references (DeadTest.res:8:7) [0] - Live Value +DeadTest.+fortyTwoButExported: 0 references () [0] - Dead Value +DeadTest.+fortytwo: 0 references () [0] - Dead Value +DeadTestBlacklist.+x: 0 references () [0] - Dead Value +DeadTestWithInterface.Ext_buffer.+x: 0 references () [1] - Dead Value +DeadTestWithInterface.Ext_buffer.+x: 0 references () [0] - Dead VariantCase DeadTypeTest.deadType.InNeither: 0 references () [0] - Live VariantCase +DeadTypeTest.deadType.InBoth: 1 references (DeadTypeTest.res:13:8) [1] - Live VariantCase DeadTypeTest.deadType.InBoth: 2 references (DeadTest.res:45:8, DeadTypeTest.res:9:2) [0] - Live VariantCase DeadTypeTest.deadType.OnlyInInterface: 1 references (DeadTest.res:44:8) [0] - Live VariantCase +DeadTypeTest.deadType.OnlyInImplementation: 1 references (DeadTypeTest.res:12:8) [1] - Live VariantCase DeadTypeTest.deadType.OnlyInImplementation: 1 references (DeadTypeTest.res:7:2) [0] - Dead Value DeadTypeTest.+a: 0 references () [0] - Dead VariantCase DeadTypeTest.t.B: 0 references () [0] - Live VariantCase +DeadTypeTest.t.A: 1 references (DeadTypeTest.res:4:8) [1] - Live VariantCase DeadTypeTest.t.A: 1 references (DeadTypeTest.res:2:2) [0] - Live Value +Docstrings.+unitArgWithConversionU: 0 references () [0] - Live Value +Docstrings.+unitArgWithConversion: 0 references () [0] - Dead VariantCase +Docstrings.t.B: 0 references () [0] - Live VariantCase +Docstrings.t.A: 2 references (Docstrings.res:64:34, Docstrings.res:67:39) [0] - Live Value +Docstrings.+unitArgWithoutConversionU: 0 references () [0] - Live Value +Docstrings.+unitArgWithoutConversion: 0 references () [0] - Live Value +Docstrings.+grouped: 0 references () [0] - Live Value +Docstrings.+unnamed2U: 0 references () [0] - Live Value +Docstrings.+unnamed2: 0 references () [0] - Live Value +Docstrings.+unnamed1U: 0 references () [0] - Live Value +Docstrings.+unnamed1: 0 references () [0] - Live Value +Docstrings.+useParamU: 0 references () [0] - Live Value +Docstrings.+useParam: 0 references () [0] - Live Value +Docstrings.+treeU: 0 references () [0] - Live Value +Docstrings.+twoU: 0 references () [0] - Live Value +Docstrings.+oneU: 0 references () [0] - Live Value +Docstrings.+tree: 0 references () [0] - Live Value +Docstrings.+two: 0 references () [0] - Live Value +Docstrings.+one: 0 references () [0] - Live Value +Docstrings.+signMessage: 0 references () [0] - Live Value +Docstrings.+flat: 0 references () [0] - Live Value +EmptyArray.Z.+make: 1 references (EmptyArray.res:10:9) [0] - Dead Value +EverythingLiveHere.+z: 0 references () [0] - Dead Value +EverythingLiveHere.+y: 0 references () [0] - Dead Value +EverythingLiveHere.+x: 0 references () [0] - Live Value +FirstClassModules.+someFunctorAsFunction: 0 references () [0] - Live Value +FirstClassModules.SomeFunctor.+ww: 1 references (FirstClassModules.res:57:2) [0] - Live Value +FirstClassModules.+testConvert: 0 references () [0] - Live Value +FirstClassModules.+firstClassModule: 0 references () [0] - Live Value +FirstClassModules.M.+x: 1 references (FirstClassModules.res:2:2) [0] - Live Value +FirstClassModules.M.Z.+u: 1 references (FirstClassModules.res:37:4) [0] - Live Value +FirstClassModules.M.InnerModule3.+k3: 1 references (FirstClassModules.res:14:4) [0] - Live Value +FirstClassModules.M.InnerModule2.+k: 1 references (FirstClassModules.res:10:4) [0] - Live Value +FirstClassModules.M.+y: 1 references (FirstClassModules.res:20:2) [0] - Dead Value FirstClassModulesInterface.+r: 0 references () [0] - Dead RecordLabel FirstClassModulesInterface.record.y: 0 references () [0] - Dead RecordLabel FirstClassModulesInterface.record.x: 0 references () [0] - Live Value +Hooks.RenderPropRequiresConversion.+car: 1 references (Hooks.res:65:30) [0] - Live RecordLabel +Hooks.RenderPropRequiresConversion.props.renderVehicle: 1 references (_none_:1:-1) [0] - Live Value +Hooks.RenderPropRequiresConversion.+make: 0 references () [0] - Dead RecordLabel +Hooks.r.x: 0 references () [0] - Live Value +Hooks.+functionWithRenamedArgs: 0 references () [0] - Live Value +Hooks.NoProps.+make: 0 references () [0] - Live RecordLabel +Hooks.Inner.Inner2.props.vehicle: 1 references (_none_:1:-1) [0] - Live Value +Hooks.Inner.Inner2.+make: 0 references () [0] - Live RecordLabel +Hooks.Inner.props.vehicle: 1 references (_none_:1:-1) [0] - Live Value +Hooks.Inner.+make: 0 references () [0] - Live Value +Hooks.+default: 0 references () [0] - Live RecordLabel +Hooks.props.vehicle: 1 references (_none_:1:-1) [0] - Live Value +Hooks.+make: 1 references (Hooks.res:25:4) [0] - Live RecordLabel +Hooks.vehicle.name: 5 references (Hooks.res:10:29, Hooks.res:29:66, Hooks.res:33:68, Hooks.res:47:2, Hooks.res:47:14) [0] - Live RecordLabel +ImportIndex.props.method: 0 references () [0] - Live Value +ImportIndex.+make: 0 references () [0] - Dead Value +ImportMyBanner.+make: 0 references () [0] - Live Value +ImportMyBanner.+make: 0 references () [0] - Dead RecordLabel +ImportMyBanner.message.text: 0 references () [0] - Live VariantCase InnerModuleTypes.I.t.Foo: 1 references (TestInnedModuleTypes.res:1:8) [1] - Live VariantCase +InnerModuleTypes.I.t.Foo: 1 references (InnerModuleTypes.resi:2:11) [0] - Live Value +JsxV4.C.+make: 1 references (JsxV4.res:7:9) [0] - Live Value +LetPrivate.+y: 0 references () [0] - Live Value +LetPrivate.local_1.+x: 1 references (LetPrivate.res:7:4) [0] - Live Value +ModuleAliases.+testInner2: 0 references () [0] - Live Value +ModuleAliases.+testInner: 0 references () [0] - Live Value +ModuleAliases.+testNested: 0 references () [0] - Dead RecordLabel +ModuleAliases.Outer2.Inner2.InnerNested.t.nested: 0 references () [0] - Dead RecordLabel +ModuleAliases.Outer.Inner.innerT.inner: 0 references () [0] - Dead Value +ModuleAliases2.+q: 0 references () [0] - Dead RecordLabel +ModuleAliases2.Outer.Inner.inner.inner: 0 references () [0] - Dead RecordLabel +ModuleAliases2.Outer.outer.outer: 0 references () [0] - Dead RecordLabel +ModuleAliases2.record.y: 0 references () [0] - Dead RecordLabel +ModuleAliases2.record.x: 0 references () [0] - Live Value +ModuleExceptionBug.+ddjdj: 1 references (ModuleExceptionBug.res:8:7) [0] - Dead Exception +ModuleExceptionBug.MyOtherException: 0 references () [0] - Dead Value +ModuleExceptionBug.Dep.+customDouble: 0 references () [0] - Live Value +NestedModules.Universe.+someString: 0 references () [0] - Dead VariantCase +NestedModules.Universe.variant.B: 0 references () [0] - Dead VariantCase +NestedModules.Universe.variant.A: 0 references () [0] - Live Value +NestedModules.Universe.Nested2.+nested2Function: 0 references () [0] - Live Value +NestedModules.Universe.Nested2.Nested3.+nested3Function: 0 references () [0] - Live Value +NestedModules.Universe.Nested2.Nested3.+nested3Value: 0 references () [0] - Dead Value +NestedModules.Universe.Nested2.Nested3.+w: 0 references () [0] - Dead Value +NestedModules.Universe.Nested2.Nested3.+z: 0 references () [0] - Dead Value +NestedModules.Universe.Nested2.Nested3.+y: 0 references () [0] - Dead Value +NestedModules.Universe.Nested2.Nested3.+x: 0 references () [0] - Dead Value +NestedModules.Universe.Nested2.+y: 0 references () [0] - Live Value +NestedModules.Universe.Nested2.+nested2Value: 0 references () [0] - Dead Value +NestedModules.Universe.Nested2.+x: 0 references () [0] - Dead Value +NestedModules.Universe.+notExported: 0 references () [0] - Live Value +NestedModules.Universe.+theAnswer: 0 references () [0] - Live Value +NestedModules.+notNested: 0 references () [0] - Live Value NestedModulesInSignature.Universe.+theAnswer: 0 references () [0] - Dead RecordLabel +Newsyntax.record2.yy: 0 references () [0] - Dead RecordLabel +Newsyntax.record2.xx: 0 references () [0] - Dead VariantCase +Newsyntax.variant.C: 0 references () [0] - Dead VariantCase +Newsyntax.variant.B: 0 references () [0] - Dead VariantCase +Newsyntax.variant.A: 0 references () [0] - Dead RecordLabel +Newsyntax.record.yyy: 0 references () [0] - Dead RecordLabel +Newsyntax.record.xxx: 0 references () [0] - Dead Value +Newsyntax.+y: 0 references () [0] - Dead Value +Newsyntax.+x: 0 references () [0] - Live Value +Newton.+result: 2 references (Newton.res:31:8, Newton.res:31:18) [0] - Live Value +Newton.+fPrimed: 1 references (Newton.res:29:4) [0] - Live Value +Newton.+f: 2 references (Newton.res:29:4, Newton.res:31:16) [0] - Live Value +Newton.+newton: 1 references (Newton.res:29:4) [2] - Live Value +Newton.+loop: 1 references (Newton.res:6:4) [1] - Live Value +Newton.+next: 1 references (Newton.res:14:10) [0] - Live Value +Newton.+previous: 2 references (Newton.res:14:10, Newton.res:16:8) [0] - Live Value +Newton.+iterateMore: 1 references (Newton.res:14:10) [1] - Live Value +Newton.+delta: 1 references (Newton.res:8:6) [0] - Live Value +Newton.+current: 3 references (Newton.res:8:6, Newton.res:14:10, Newton.res:15:8) [0] - Live Value +Newton.+/: 1 references (Newton.res:16:8) [0] - Live Value +Newton.+*: 2 references (Newton.res:25:4, Newton.res:27:4) [0] - Live Value +Newton.++: 1 references (Newton.res:25:4) [0] - Live Value +Newton.+-: 4 references (Newton.res:9:8, Newton.res:16:8, Newton.res:25:4, Newton.res:27:4) [0] - Live Value +Opaque.+testConvertNestedRecordFromOtherFile: 0 references () [0] - Live Value +Opaque.+noConversion: 0 references () [0] - Dead VariantCase +Opaque.opaqueFromRecords.A: 0 references () [0] - Live Value +OptionalArgsLiveDead.+liveCaller: 1 references (OptionalArgsLiveDead.res:7:8) [0] - Dead Value +OptionalArgsLiveDead.+deadCaller: 0 references () [0] - Live Value +OptionalArgsLiveDead.+formatDate: 1 references (OptionalArgsLiveDead.res:5:4) [0] - Live Value +Records.+testMyRecBsAs2: 0 references () [0] - Live Value +Records.+testMyRecBsAs: 0 references () [0] - Live RecordLabel +Records.myRecBsAs.type_: 1 references (Records.res:145:38) [0] - Live Value +Records.+testMyObj2: 0 references () [0] - Live Value +Records.+testMyObj: 0 references () [0] - Live Value +Records.+testMyRec2: 0 references () [0] - Live Value +Records.+testMyRec: 0 references () [0] - Live RecordLabel +Records.myRec.type_: 1 references (Records.res:127:30) [0] - Live Value +Records.+computeArea4: 0 references () [0] - Live Value +Records.+computeArea3: 0 references () [0] - Live Value +Records.+someBusiness2: 0 references () [0] - Live Value +Records.+findAddress2: 0 references () [0] - Live RecordLabel +Records.business2.address2: 1 references (Records.res:97:2) [0] - Dead RecordLabel +Records.business2.owner: 0 references () [0] - Dead RecordLabel +Records.business2.name: 0 references () [0] - Live Value +Records.+getPayloadRecordPlusOne: 0 references () [0] - Live Value +Records.+payloadValue: 0 references () [0] - Live Value +Records.+recordValue: 1 references (Records.res:80:4) [0] - Live Value +Records.+getPayloadRecord: 0 references () [0] - Dead RecordLabel +Records.record.w: 0 references () [0] - Live RecordLabel +Records.record.v: 1 references (Records.res:85:5) [0] - Live Value +Records.+getPayload: 0 references () [0] - Live RecordLabel +Records.payload.payload: 3 references (Records.res:65:18, Records.res:74:24, Records.res:83:31) [0] - Dead RecordLabel +Records.payload.num: 0 references () [0] - Live Value +Records.+findAllAddresses: 0 references () [0] - Live Value +Records.+someBusiness: 0 references () [0] - Live Value +Records.+findAddress: 0 references () [0] - Live Value +Records.+getOpt: 3 references (Records.res:39:4, Records.res:46:4, Records.res:96:4) [0] - Live RecordLabel +Records.business.address: 2 references (Records.res:40:2, Records.res:50:6) [0] - Live RecordLabel +Records.business.owner: 1 references (Records.res:51:6) [0] - Dead RecordLabel +Records.business.name: 0 references () [0] - Live RecordLabel +Records.person.address: 1 references (Records.res:51:42) [0] - Dead RecordLabel +Records.person.age: 0 references () [0] - Dead RecordLabel +Records.person.name: 0 references () [0] - Live Value +Records.+coord2d: 0 references () [0] - Live Value +Records.+computeArea: 0 references () [0] - Live Value +Records.+origin: 0 references () [0] - Live RecordLabel +Records.coord.z: 1 references (Records.res:14:19) [0] - Live RecordLabel +Records.coord.y: 1 references (Records.res:14:19) [0] - Live RecordLabel +Records.coord.x: 1 references (Records.res:14:19) [0] - Live Value +References.+preserveRefIdentity: 0 references () [0] - Live Value +References.+destroysRefIdentity: 0 references () [0] - Dead RecordLabel +References.requiresConversion.x: 0 references () [0] - Live Value +References.+set: 0 references () [0] - Live Value +References.+make: 0 references () [0] - Live Value +References.+get: 0 references () [0] - Live Value +References.R.+set: 1 references (References.res:37:4) [1] - Live Value +References.R.+set: 1 references (References.res:19:2) [0] - Live Value +References.R.+make: 1 references (References.res:34:4) [1] - Live Value +References.R.+make: 1 references (References.res:18:2) [0] - Live Value +References.R.+get: 1 references (References.res:31:4) [1] - Live Value +References.R.+get: 1 references (References.res:17:2) [0] - Live Value +References.+update: 0 references () [0] - Live Value +References.+access: 0 references () [0] - Live Value +References.+create: 0 references () [0] - Live Value +RepeatedLabel.+userData: 1 references (RepeatedLabel.res:14:7) [0] - Dead RecordLabel +RepeatedLabel.tabState.f: 0 references () [0] - Live RecordLabel +RepeatedLabel.tabState.b: 1 references (RepeatedLabel.res:12:16) [0] - Live RecordLabel +RepeatedLabel.tabState.a: 1 references (RepeatedLabel.res:12:16) [0] - Dead RecordLabel +RepeatedLabel.userData.b: 0 references () [0] - Dead RecordLabel +RepeatedLabel.userData.a: 0 references () [0] - Dead Value +Shadow.M.+test: 0 references () [0] - Live Value +Shadow.M.+test: 0 references () [0] - Live Value +Shadow.+test: 0 references () [0] - Live Value +Shadow.+test: 0 references () [0] - Live Value +TestEmitInnerModules.Outer.Medium.Inner.+y: 0 references () [0] - Live Value +TestEmitInnerModules.Inner.+y: 0 references () [0] - Live Value +TestEmitInnerModules.Inner.+x: 0 references () [0] - Live Value +TestFirstClassModules.+convertFirstClassModuleWithTypeEquations: 0 references () [0] - Live Value +TestFirstClassModules.+convertRecord: 0 references () [0] - Live Value +TestFirstClassModules.+convertInterface: 0 references () [0] - Live Value +TestFirstClassModules.+convert: 0 references () [0] - Dead Value +TestImmutableArray.+testBeltArraySet: 0 references () [0] - Dead Value +TestImmutableArray.+testBeltArrayGet: 0 references () [0] - Live Value +TestImmutableArray.+testImmutableArrayGet: 0 references () [0] - Live Value +TestImport.+defaultValue2: 0 references () [0] - Dead Value +TestImport.+make: 0 references () [0] - Live Value +TestImport.+make: 0 references () [0] - Dead RecordLabel +TestImport.message.text: 0 references () [0] - Live Value +TestImport.+defaultValue: 0 references () [0] - Live Value +TestImport.+valueStartingWithUpperCaseLetter: 0 references () [0] - Dead Value +TestImport.+innerStuffContents: 0 references () [0] - Live Value +TestImport.+innerStuffContentsAsEmptyObject: 0 references () [0] - Live Value +TestImport.+innerStuffContents: 0 references () [0] - Dead Value +TestInnedModuleTypes.+_: 0 references () [0] - Live Value +TestModuleAliases.+testInner2Expanded: 0 references () [0] - Live Value +TestModuleAliases.+testInner2: 0 references () [0] - Live Value +TestModuleAliases.+testInner1Expanded: 0 references () [0] - Live Value +TestModuleAliases.+testInner1: 0 references () [0] - Live Value +TestOptArg.+liveSuppressesOptArgs: 1 references (TestOptArg.res:16:8) [0] - Live Value +TestOptArg.+notSuppressesOptArgs: 1 references (TestOptArg.res:11:8) [0] - Live Value +TestOptArg.+bar: 1 references (TestOptArg.res:7:7) [0] - Live Value +TestOptArg.+foo: 1 references (TestOptArg.res:5:4) [0] - Live Value +TestPromise.+convert: 0 references () [0] - Dead RecordLabel +TestPromise.toPayload.result: 0 references () [0] - Live RecordLabel +TestPromise.fromPayload.s: 1 references (TestPromise.res:14:32) [0] - Dead RecordLabel +TestPromise.fromPayload.x: 0 references () [0] - Dead Value +ToSuppress.+toSuppress: 0 references () [0] - Live Value +TransitiveType1.+convertAlias: 0 references () [0] - Live Value +TransitiveType1.+convert: 0 references () [0] - Dead Value +TransitiveType2.+convertT2: 0 references () [0] - Live Value +TransitiveType3.+convertT3: 0 references () [0] - Dead RecordLabel +TransitiveType3.t3.s: 0 references () [0] - Dead RecordLabel +TransitiveType3.t3.i: 0 references () [0] - Live Value +Tuples.+changeSecondAge: 0 references () [0] - Live Value +Tuples.+marry: 0 references () [0] - Live Value +Tuples.+getFirstName: 0 references () [0] - Live RecordLabel +Tuples.person.age: 1 references (Tuples.res:49:84) [0] - Live RecordLabel +Tuples.person.name: 1 references (Tuples.res:43:49) [0] - Live Value +Tuples.+coord2d: 0 references () [0] - Live Value +Tuples.+computeAreaNoConverters: 0 references () [0] - Live Value +Tuples.+computeAreaWithIdent: 0 references () [0] - Live Value +Tuples.+computeArea: 0 references () [0] - Live Value +Tuples.+origin: 0 references () [0] - Live Value +Tuples.+testTuple: 0 references () [0] - Dead Value +TypeParams1.+exportSomething: 0 references () [0] - Dead Value +TypeParams2.+exportSomething: 0 references () [0] - Dead RecordLabel +TypeParams2.item.id: 0 references () [0] - Live Value +TypeParams3.+test2: 0 references () [0] - Live Value +TypeParams3.+test: 0 references () [0] - Dead Value +Types.ObjectId.+x: 0 references () [0] - Live Value +Types.+optFunction: 0 references () [0] - Live Value +Types.+i64Const: 0 references () [0] - Live Value +Types.+currentTime: 0 references () [0] - Live Value +Types.+testInstantiateTypeParameter: 0 references () [0] - Dead RecordLabel +Types.someRecord.id: 0 references () [0] - Live Value +Types.+setMatch: 0 references () [0] - Live Value +Types.+testMarshalFields: 0 references () [0] - Live Value +Types.+testConvertNull: 0 references () [0] - Dead RecordLabel +Types.record.s: 0 references () [0] - Dead RecordLabel +Types.record.i: 0 references () [0] - Live Value +Types.+jsonStringify: 0 references () [0] - Live Value +Types.+jsString2T: 0 references () [0] - Live Value +Types.+jsStringT: 0 references () [0] - Dead VariantCase +Types.opaqueVariant.B: 0 references () [0] - Dead VariantCase +Types.opaqueVariant.A: 0 references () [0] - Live Value +Types.+testFunctionOnOptionsAsArgument: 0 references () [0] - Live Value +Types.+mutuallyRecursiveConverter: 0 references () [0] - Live Value +Types.+selfRecursiveConverter: 0 references () [0] - Dead RecordLabel +Types.mutuallyRecursiveB.a: 0 references () [0] - Live RecordLabel +Types.mutuallyRecursiveA.b: 1 references (Types.res:49:34) [0] - Live RecordLabel +Types.selfRecursive.self: 1 references (Types.res:42:30) [0] - Live Value +Types.+swap: 0 references () [0] - Dead VariantCase +Types.typeWithVars.B: 0 references () [0] - Dead VariantCase +Types.typeWithVars.A: 0 references () [0] - Live Value +Types.+map: 0 references () [0] - Live Value +Types.+someIntList: 0 references () [0] - Live Value +Unboxed.+r2Test: 0 references () [0] - Dead RecordLabel +Unboxed.r2.B.g: 0 references () [0] - Dead VariantCase +Unboxed.r2.B: 0 references () [0] - Dead RecordLabel +Unboxed.r1.x: 0 references () [0] - Live Value +Unboxed.+testV1: 0 references () [0] - Dead VariantCase +Unboxed.v2.A: 0 references () [0] - Dead VariantCase +Unboxed.v1.A: 0 references () [0] - Live Value +Uncurried.+sumLblCurried: 0 references () [0] - Live Value +Uncurried.+sumCurried: 0 references () [0] - Live Value +Uncurried.+sumU2: 0 references () [0] - Live Value +Uncurried.+sumU: 0 references () [0] - Live Value +Uncurried.+callback2U: 0 references () [0] - Live Value +Uncurried.+callback2: 0 references () [0] - Live RecordLabel +Uncurried.authU.loginU: 1 references (Uncurried.res:38:25) [0] - Live RecordLabel +Uncurried.auth.login: 1 references (Uncurried.res:35:24) [0] - Live Value +Uncurried.+callback: 0 references () [0] - Live Value +Uncurried.+curried3: 0 references () [0] - Live Value +Uncurried.+uncurried3: 0 references () [0] - Live Value +Uncurried.+uncurried2: 0 references () [0] - Live Value +Uncurried.+uncurried1: 0 references () [0] - Live Value +Uncurried.+uncurried0: 0 references () [0] - Live Value +Unison.+toString: 3 references (Unison.res:37:0, Unison.res:38:0, Unison.res:39:0) [0] - Live Value +Unison.+fits: 1 references (Unison.res:26:8) [0] - Live Value +Unison.+group: 2 references (Unison.res:38:25, Unison.res:39:25) [0] - Live VariantCase +Unison.stack.Cons: 2 references (Unison.res:38:20, Unison.res:39:20) [0] - Live VariantCase +Unison.stack.Empty: 3 references (Unison.res:37:20, Unison.res:38:53, Unison.res:39:52) [0] - Live RecordLabel +Unison.t.doc: 2 references (Unison.res:23:9, Unison.res:28:9) [0] - Live RecordLabel +Unison.t.break: 1 references (Unison.res:28:9) [0] - Live VariantCase +Unison.break.Always: 1 references (Unison.res:39:38) [0] - Live VariantCase +Unison.break.Never: 1 references (Unison.res:38:38) [0] - Live VariantCase +Unison.break.IfNeed: 1 references (Unison.res:17:20) [0] - Live Value +UseImportJsValue.+useTypeImportedInOtherModule: 0 references () [0] - Live Value +UseImportJsValue.+useGetProp: 0 references () [0] - Live Value +Variants.+restResult3: 0 references () [0] - Live Value +Variants.+restResult2: 0 references () [0] - Live Value +Variants.+restResult1: 0 references () [0] - Dead VariantCase +Variants.result1.Error: 0 references () [0] - Dead VariantCase +Variants.result1.Ok: 0 references () [0] - Live Value +Variants.+polyWithOpt: 0 references () [0] - Dead VariantCase +Variants.type_.Type: 0 references () [0] - Live Value +Variants.+id2: 0 references () [0] - Live Value +Variants.+id1: 0 references () [0] - Live Value +Variants.+testConvert2to3: 0 references () [0] - Live Value +Variants.+testConvert3: 0 references () [0] - Live Value +Variants.+testConvert2: 0 references () [0] - Live Value +Variants.+fortytwoBAD: 0 references () [0] - Live Value +Variants.+fortytwoOK: 0 references () [0] - Live Value +Variants.+testConvert: 0 references () [0] - Live Value +Variants.+swap: 0 references () [0] - Live Value +Variants.+onlySunday: 0 references () [0] - Live Value +Variants.+sunday: 0 references () [0] - Live Value +Variants.+saturday: 0 references () [0] - Live Value +Variants.+monday: 0 references () [0] - Live Value +Variants.+isWeekend: 0 references () [0] - Live Value +VariantsWithPayload.+testVariant1Object: 0 references () [0] - Dead VariantCase +VariantsWithPayload.variant1Object.R: 0 references () [0] - Live Value +VariantsWithPayload.+testVariant1Int: 0 references () [0] - Dead VariantCase +VariantsWithPayload.variant1Int.R: 0 references () [0] - Live Value +VariantsWithPayload.+printVariantWithPayloads: 0 references () [0] - Live Value +VariantsWithPayload.+testVariantWithPayloads: 0 references () [0] - Dead VariantCase +VariantsWithPayload.variantWithPayloads.E: 0 references () [0] - Dead VariantCase +VariantsWithPayload.variantWithPayloads.D: 0 references () [0] - Dead VariantCase +VariantsWithPayload.variantWithPayloads.C: 0 references () [0] - Dead VariantCase +VariantsWithPayload.variantWithPayloads.B: 0 references () [0] - Dead VariantCase +VariantsWithPayload.variantWithPayloads.A: 0 references () [0] - Live Value +VariantsWithPayload.+testSimpleVariant: 0 references () [0] - Dead VariantCase +VariantsWithPayload.simpleVariant.C: 0 references () [0] - Dead VariantCase +VariantsWithPayload.simpleVariant.B: 0 references () [0] - Dead VariantCase +VariantsWithPayload.simpleVariant.A: 0 references () [0] - Live Value +VariantsWithPayload.+printManyPayloads: 0 references () [0] - Live Value +VariantsWithPayload.+testManyPayloads: 0 references () [0] - Live Value +VariantsWithPayload.+printVariantWithPayload: 0 references () [0] - Live Value +VariantsWithPayload.+testWithPayload: 0 references () [0] - Live RecordLabel +VariantsWithPayload.payload.y: 2 references (VariantsWithPayload.res:26:74, VariantsWithPayload.res:44:72) [0] - Live RecordLabel +VariantsWithPayload.payload.x: 2 references (VariantsWithPayload.res:26:57, VariantsWithPayload.res:44:55) [0] - Live Value +DeadExn.+eInside: 1 references (DeadExn.res:12:7) [0] - Dead Value +DeadExn.+eToplevel: 0 references () [0] - Dead Exception +DeadExn.DeadE: 0 references () [0] - Live Exception +DeadExn.Inside.Einside: 1 references (DeadExn.res:10:14) [0] - Live Exception +DeadExn.Etoplevel: 1 references (DeadExn.res:8:16) [0] - Live RecordLabel +DeadTypeTest.record.z: 0 references () [0] - Live RecordLabel +DeadTypeTest.record.y: 0 references () [0] - Live RecordLabel +DeadTypeTest.record.x: 0 references () [0] - Dead Value +DeadTypeTest.+_: 0 references () [0] - Dead Value +DeadTypeTest.+_: 0 references () [0] - Dead VariantCase +DeadTypeTest.deadType.InNeither: 0 references () [0] - Live VariantCase +DeadTypeTest.deadType.OnlyInInterface: 1 references (DeadTypeTest.resi:8:2) [0] - Dead Value +DeadTypeTest.+a: 0 references () [0] - Dead VariantCase +DeadTypeTest.t.B: 0 references () [0] - Dead Value DeadValueTest.+valueDead: 0 references () [0] - Live Value DeadValueTest.+valueAlive: 1 references (DeadTest.res:73:16) [0] - Live RecordLabel +DynamicallyLoadedComponent.props.s: 1 references (_none_:1:-1) [0] - Live Value +DynamicallyLoadedComponent.+make: 1 references (DeadTest.res:110:17) [0] - Dead Value ErrorHandler.+x: 0 references () [0] - Live Value ErrorHandler.Make.+notify: 1 references (CreateErrorHandler1.res:8:0) [0] - Dead Value +FirstClassModulesInterface.+r: 0 references () [0] - Dead RecordLabel +FirstClassModulesInterface.record.y: 0 references () [0] - Dead RecordLabel +FirstClassModulesInterface.record.x: 0 references () [0] - Dead Value ImmutableArray.+eq: 0 references () [0] - Dead Value ImmutableArray.+eqU: 0 references () [0] - Dead Value ImmutableArray.+cmp: 0 references () [0] - Dead Value ImmutableArray.+cmpU: 0 references () [0] - Dead Value ImmutableArray.+some2: 0 references () [0] - Dead Value ImmutableArray.+some2U: 0 references () [0] - Dead Value ImmutableArray.+every2: 0 references () [0] - Dead Value ImmutableArray.+every2U: 0 references () [0] - Dead Value ImmutableArray.+every: 0 references () [0] - Dead Value ImmutableArray.+everyU: 0 references () [0] - Dead Value ImmutableArray.+some: 0 references () [0] - Dead Value ImmutableArray.+someU: 0 references () [0] - Dead Value ImmutableArray.+reduceReverse2: 0 references () [0] - Dead Value ImmutableArray.+reduceReverse2U: 0 references () [0] - Dead Value ImmutableArray.+reduceReverse: 0 references () [0] - Dead Value ImmutableArray.+reduceReverseU: 0 references () [0] - Dead Value ImmutableArray.+reduce: 0 references () [0] - Dead Value ImmutableArray.+reduceU: 0 references () [0] - Dead Value ImmutableArray.+partition: 0 references () [0] - Dead Value ImmutableArray.+partitionU: 0 references () [0] - Dead Value ImmutableArray.+mapWithIndex: 0 references () [0] - Dead Value ImmutableArray.+mapWithIndexU: 0 references () [0] - Dead Value ImmutableArray.+forEachWithIndex: 0 references () [0] - Dead Value ImmutableArray.+forEachWithIndexU: 0 references () [0] - Dead Value ImmutableArray.+keepMap: 0 references () [0] - Dead Value ImmutableArray.+keepMapU: 0 references () [0] - Dead Value ImmutableArray.+keepWithIndex: 0 references () [0] - Dead Value ImmutableArray.+keepWithIndexU: 0 references () [0] - Dead Value ImmutableArray.+map: 0 references () [0] - Dead Value ImmutableArray.+mapU: 0 references () [0] - Dead Value ImmutableArray.+forEach: 0 references () [0] - Dead Value ImmutableArray.+forEachU: 0 references () [0] - Dead Value ImmutableArray.+copy: 0 references () [0] - Dead Value ImmutableArray.+sliceToEnd: 0 references () [0] - Dead Value ImmutableArray.+slice: 0 references () [0] - Dead Value ImmutableArray.+concatMany: 0 references () [0] - Dead Value ImmutableArray.+concat: 0 references () [0] - Dead Value ImmutableArray.+unzip: 0 references () [0] - Dead Value ImmutableArray.+zipBy: 0 references () [0] - Dead Value ImmutableArray.+zipByU: 0 references () [0] - Dead Value ImmutableArray.+zip: 0 references () [0] - Dead Value ImmutableArray.+makeByAndShuffle: 0 references () [0] - Dead Value ImmutableArray.+makeByAndShuffleU: 0 references () [0] - Dead Value ImmutableArray.+makeBy: 0 references () [0] - Dead Value ImmutableArray.+makeByU: 0 references () [0] - Dead Value ImmutableArray.+rangeBy: 0 references () [0] - Dead Value ImmutableArray.+range: 0 references () [0] - Dead Value ImmutableArray.+make: 0 references () [0] - Dead Value ImmutableArray.+makeUninitializedUnsafe: 0 references () [0] - Dead Value ImmutableArray.+makeUninitialized: 0 references () [0] - Dead Value ImmutableArray.+reverse: 0 references () [0] - Dead Value ImmutableArray.+shuffle: 0 references () [0] - Dead Value ImmutableArray.+getUndefined: 0 references () [0] - Dead Value ImmutableArray.+getUnsafe: 0 references () [0] - Dead Value ImmutableArray.+getExn: 0 references () [0] - Dead Value ImmutableArray.+get: 0 references () [0] - Dead Value ImmutableArray.+size: 0 references () [0] - Dead Value ImmutableArray.+length: 0 references () [0] - Dead Value ImmutableArray.+toArray: 0 references () [0] - Live Value ImmutableArray.+fromArray: 1 references (DeadTest.res:1:15) [0] - Live Value ImmutableArray.Array.+get: 1 references (TestImmutableArray.res:2:4) [0] - Live RecordLabel +ImportHookDefault.props.renderMe: 0 references () [0] - Live RecordLabel +ImportHookDefault.props.children: 0 references () [0] - Live RecordLabel +ImportHookDefault.props.person: 0 references () [0] - Live Value +ImportHookDefault.+make: 1 references (Hooks.res:17:5) [0] - Dead RecordLabel +ImportHookDefault.person.age: 0 references () [0] - Dead RecordLabel +ImportHookDefault.person.name: 0 references () [0] - Live Value +ImportHooks.+foo: 0 references () [0] - Live RecordLabel +ImportHooks.props.renderMe: 0 references () [0] - Live RecordLabel +ImportHooks.props.children: 0 references () [0] - Live RecordLabel +ImportHooks.props.person: 0 references () [0] - Live Value +ImportHooks.+make: 1 references (Hooks.res:14:5) [0] - Dead RecordLabel +ImportHooks.person.age: 0 references () [0] - Dead RecordLabel +ImportHooks.person.name: 0 references () [0] - Live Value +ImportJsValue.+default: 0 references () [0] - Live Value +ImportJsValue.+polymorphic: 0 references () [0] - Live Value +ImportJsValue.+convertVariant: 0 references () [0] - Dead VariantCase +ImportJsValue.variant.S: 0 references () [0] - Dead VariantCase +ImportJsValue.variant.I: 0 references () [0] - Live Value +ImportJsValue.+returnedFromHigherOrder: 0 references () [0] - Live Value +ImportJsValue.+higherOrder: 1 references (ImportJsValue.res:64:4) [0] - Live Value +ImportJsValue.+useColor: 0 references () [0] - Live Value +ImportJsValue.+useGetAbs: 0 references () [0] - Live Value +ImportJsValue.+useGetProp: 0 references () [0] - Live Value +ImportJsValue.AbsoluteValue.+getAbs: 1 references (ImportJsValue.res:50:4) [1] - Live Value +ImportJsValue.AbsoluteValue.+getAbs: 1 references (ImportJsValue.res:40:6) [0] - Live Value +ImportJsValue.+areaValue: 0 references () [0] - Live Value +ImportJsValue.+roundedNumber: 0 references () [0] - Live Value +ImportJsValue.+returnMixedArray: 0 references () [0] - Live Value +ImportJsValue.+area: 1 references (ImportJsValue.res:30:4) [0] - Dead RecordLabel +ImportJsValue.point.y: 0 references () [0] - Dead RecordLabel +ImportJsValue.point.x: 0 references () [0] - Live Value +ImportJsValue.+round: 1 references (ImportJsValue.res:27:4) [0] - Live Value +NestedModulesInSignature.Universe.+theAnswer: 1 references (NestedModulesInSignature.resi:2:2) [0] - Live Value OptArg.+bar: 1 references (TestOptArg.res:1:7) [0] - Dead Value OptArg.+foo: 0 references () [0] - Dead Value +DeadValueTest.+tail: 0 references () [0] - Dead Value +DeadValueTest.+subList: 0 references () [0] - Dead Value +DeadValueTest.+valueOnlyInImplementation: 0 references () [0] - Dead Value +DeadValueTest.+valueDead: 0 references () [0] - Live Value +DeadValueTest.+valueAlive: 1 references (DeadValueTest.resi:1:0) [0] - Dead Value +ErrorHandler.+x: 0 references () [0] - Live Value +ErrorHandler.Make.+notify: 1 references (ErrorHandler.resi:7:2) [0] - Dead Value +ImmutableArray.+eq: 0 references () [0] - Dead Value +ImmutableArray.+eqU: 0 references () [0] - Dead Value +ImmutableArray.+cmp: 0 references () [0] - Dead Value +ImmutableArray.+cmpU: 0 references () [0] - Dead Value +ImmutableArray.+some2: 0 references () [0] - Dead Value +ImmutableArray.+some2U: 0 references () [0] - Dead Value +ImmutableArray.+every2: 0 references () [0] - Dead Value +ImmutableArray.+every2U: 0 references () [0] - Dead Value +ImmutableArray.+every: 0 references () [0] - Dead Value +ImmutableArray.+everyU: 0 references () [0] - Dead Value +ImmutableArray.+some: 0 references () [0] - Dead Value +ImmutableArray.+someU: 0 references () [0] - Dead Value +ImmutableArray.+reduceReverse2: 0 references () [0] - Dead Value +ImmutableArray.+reduceReverse2U: 0 references () [0] - Dead Value +ImmutableArray.+reduceReverse: 0 references () [0] - Dead Value +ImmutableArray.+reduceReverseU: 0 references () [0] - Dead Value +ImmutableArray.+reduce: 0 references () [0] - Dead Value +ImmutableArray.+reduceU: 0 references () [0] - Dead Value +ImmutableArray.+partition: 0 references () [0] - Dead Value +ImmutableArray.+partitionU: 0 references () [0] - Dead Value +ImmutableArray.+mapWithIndex: 0 references () [0] - Dead Value +ImmutableArray.+mapWithIndexU: 0 references () [0] - Dead Value +ImmutableArray.+forEachWithIndex: 0 references () [0] - Dead Value +ImmutableArray.+forEachWithIndexU: 0 references () [0] - Dead Value +ImmutableArray.+keepMap: 0 references () [0] - Dead Value +ImmutableArray.+keepMapU: 0 references () [0] - Dead Value +ImmutableArray.+keepWithIndex: 0 references () [0] - Dead Value +ImmutableArray.+keepWithIndexU: 0 references () [0] - Dead Value +ImmutableArray.+map: 0 references () [0] - Dead Value +ImmutableArray.+mapU: 0 references () [0] - Dead Value +ImmutableArray.+forEach: 0 references () [0] - Dead Value +ImmutableArray.+forEachU: 0 references () [0] - Dead Value +ImmutableArray.+copy: 0 references () [0] - Dead Value +ImmutableArray.+sliceToEnd: 0 references () [0] - Dead Value +ImmutableArray.+slice: 0 references () [0] - Dead Value +ImmutableArray.+concatMany: 0 references () [0] - Dead Value +ImmutableArray.+concat: 0 references () [0] - Dead Value +ImmutableArray.+unzip: 0 references () [0] - Dead Value +ImmutableArray.+zipBy: 0 references () [0] - Dead Value +ImmutableArray.+zipByU: 0 references () [0] - Dead Value +ImmutableArray.+zip: 0 references () [0] - Dead Value +ImmutableArray.+makeByAndShuffle: 0 references () [0] - Dead Value +ImmutableArray.+makeByAndShuffleU: 0 references () [0] - Dead Value +ImmutableArray.+makeBy: 0 references () [0] - Dead Value +ImmutableArray.+makeByU: 0 references () [0] - Dead Value +ImmutableArray.+rangeBy: 0 references () [0] - Dead Value +ImmutableArray.+range: 0 references () [0] - Dead Value +ImmutableArray.+make: 0 references () [0] - Dead Value +ImmutableArray.+makeUninitializedUnsafe: 0 references () [0] - Dead Value +ImmutableArray.+makeUninitialized: 0 references () [0] - Dead Value +ImmutableArray.+reverse: 0 references () [0] - Dead Value +ImmutableArray.+shuffle: 0 references () [0] - Dead Value +ImmutableArray.+getUndefined: 0 references () [0] - Dead Value +ImmutableArray.+getUnsafe: 0 references () [0] - Dead Value +ImmutableArray.+getExn: 0 references () [0] - Live Value +ImmutableArray.+get: 1 references (ImmutableArray.resi:6:2) [0] - Dead Value +ImmutableArray.+size: 0 references () [0] - Dead Value +ImmutableArray.+length: 0 references () [0] - Dead Value +ImmutableArray.+toArray: 0 references () [0] - Live Value +ImmutableArray.+fromArray: 1 references (ImmutableArray.resi:9:0) [0] - Live Value +OptArg.+wrapfourArgs: 2 references (OptArg.res:28:7, OptArg.res:29:7) [0] - Live Value +OptArg.+fourArgs: 1 references (OptArg.res:26:4) [0] - Live Value +OptArg.+wrapOneArg: 1 references (OptArg.res:22:7) [0] - Live Value +OptArg.+oneArg: 1 references (OptArg.res:20:4) [0] - Live Value +OptArg.+twoArgs: 1 references (OptArg.res:16:10) [0] - Live Value +OptArg.+threeArgs: 2 references (OptArg.res:11:7, OptArg.res:12:7) [0] - Live Value +OptArg.+bar: 2 references (OptArg.res:7:7, OptArg.resi:2:0) [0] - Live Value +OptArg.+foo: 1 references (OptArg.res:5:7) [0] +Forward Liveness Analysis + + Root (annotated): Value +Hooks.+default + Root (external ref): Value +FirstClassModules.M.InnerModule2.+k + Root (external ref): VariantCase DeadRT.moduleAccessPath.Root + Root (annotated): Value +NestedModules.Universe.Nested2.Nested3.+nested3Function + Root (annotated): Value +Docstrings.+tree + Root (annotated): Value +ImportJsValue.+areaValue + Root (annotated): Value +ImportJsValue.+useGetProp + Root (external ref): Value +CreateErrorHandler2.Error2.+notification + Root (annotated): Value +DeadTest.+fortyTwoButExported + Root (annotated): Value +Docstrings.+grouped + Root (external ref): RecordLabel +DeadTest.inlineRecord.IR.b + Root (annotated): Value +NestedModules.Universe.Nested2.+nested2Function + Root (annotated): Value +Tuples.+marry + Root (annotated): Value +Types.+i64Const + Root (external ref): VariantCase +DeadTypeTest.deadType.OnlyInImplementation + Root (annotated): Value +TestImport.+valueStartingWithUpperCaseLetter + Root (external ref): Value +OptionalArgsLiveDead.+liveCaller + Root (annotated): RecordLabel +ImportHookDefault.props.renderMe + Root (annotated): Value +TypeParams3.+test + Root (annotated): Value +Variants.+sunday + Root (annotated): Value +Docstrings.+unnamed1U + Root (annotated): Value +NestedModules.Universe.Nested2.Nested3.+nested3Value + Root (annotated): Value +DeadTest.GloobLive.+globallyLive2 + Root (annotated): Value +Hooks.+functionWithRenamedArgs + Root (external ref): RecordLabel +Unison.t.doc + Root (annotated): Value +Tuples.+computeAreaWithIdent + Root (annotated): Value +LetPrivate.+y + Root (annotated): Value +TestImport.+innerStuffContentsAsEmptyObject + Root (external ref): Value +TestOptArg.+notSuppressesOptArgs + Root (annotated): Value +Types.+testFunctionOnOptionsAsArgument + Root (annotated): Value +Docstrings.+unitArgWithoutConversionU + Root (annotated): Value +Uncurried.+sumU + Root (annotated): Value +Tuples.+getFirstName + Root (external ref): Value +Newton.+f + Root (external ref): RecordLabel +Records.record.v + Root (external ref): VariantCase +DeadTest.VariantUsedOnlyInImplementation.t.A + Root (annotated): Value +DeadTest.GloobLive.+globallyLive3 + Root (external ref): Value +Hooks.RenderPropRequiresConversion.+car + Root (external ref): RecordLabel +Records.person.address + Root (annotated): Value +Variants.+testConvert2 + Root (annotated): Value +Tuples.+coord2d + Root (external ref): Value +CreateErrorHandler1.Error1.+notification + Root (annotated): Value +Docstrings.+unnamed1 + Root (annotated): Value +Docstrings.+unitArgWithConversionU + Root (annotated): Value +TransitiveType3.+convertT3 + Root (annotated): Value +Variants.+swap + Root (annotated): Value +Shadow.+test + Root (annotated): Value +Variants.+testConvert3 + Root (annotated): Value +DeadTest.+thisIsMarkedLive + Root (annotated): Value +NestedModules.+notNested + Root (annotated): Value +Records.+computeArea + Root (annotated): Value +Docstrings.+oneU + Root (annotated): Value +ImportHooks.+foo + Root (annotated): RecordLabel +ImportIndex.props.method + Root (external ref): Value +FirstClassModules.M.Z.+u + Root (external ref): VariantCase +Docstrings.t.A + Root (annotated): Value +ImportJsValue.+convertVariant + Root (annotated): Value +VariantsWithPayload.+testVariant1Int + Root (external ref): Value +DynamicallyLoadedComponent.+make + Root (annotated): Value +Uncurried.+uncurried3 + Root (annotated): Value +UseImportJsValue.+useTypeImportedInOtherModule + Root (annotated): Value +Hooks.NoProps.+make + Root (external ref): Value +OptArg.+foo + Root (annotated): Value +Variants.+fortytwoOK + Root (external ref): Value OptArg.+bar + Root (annotated): Value +Records.+payloadValue + Root (external ref): RecordLabel +DeadTest.props.s + Root (annotated): Value +Uncurried.+callback2 + Root (annotated): Value +ImportJsValue.+higherOrder + Root (annotated): Value +TestEmitInnerModules.Inner.+y + Root (external ref): VariantCase InnerModuleTypes.I.t.Foo + Root (annotated): Value +Types.+selfRecursiveConverter + Root (external ref): Value +DeadTest.+thisIsUsedTwice + Root (annotated): Value +Opaque.+testConvertNestedRecordFromOtherFile + Root (annotated): Value +Hooks.Inner.Inner2.+make + Root (external ref): RecordLabel +Hooks.Inner.Inner2.props.vehicle + Root (external ref): Value +OptArg.+bar + Root (annotated): Value +TestFirstClassModules.+convertRecord + Root (external ref): VariantCase DeadTypeTest.deadType.OnlyInInterface + Root (external ref): RecordLabel +Records.myRecBsAs.type_ + Root (external ref): VariantCase +Unison.break.Never + Root (annotated): Value +Variants.+restResult3 + Root (external ref): RecordLabel +Tuples.person.name + Root (external ref): Value +FirstClassModules.M.InnerModule3.+k3 + Root (external ref): VariantCase +Unison.break.Always + Root (external ref): RecordLabel +Records.coord.x + Root (annotated): RecordLabel +DeadTypeTest.record.y + Root (annotated): Value +TestImport.+defaultValue + Root (external ref): Value +OptArg.+threeArgs + Root (annotated): Value +Types.+setMatch + Root (annotated): Value +Docstrings.+signMessage + Root (external ref): Value +DeadExn.+eInside + Root (external ref): VariantCase +DeadTest.inlineRecord.IR + Root (external ref): RecordLabel +ComponentAsProp.props.button + Root (annotated): Value +TestImport.+innerStuffContents + Root (external ref): Value +ModuleExceptionBug.+ddjdj + Root (annotated): Value +TransitiveType1.+convert + Root (annotated): Value +ImportJsValue.+polymorphic + Root (annotated): Value +ImportHooks.+make + Root (external ref): Value +DeadTest.+make + Root (annotated): Value +Records.+testMyRecBsAs + Root (external ref): Value +DeadTest.+ira + Root (external ref): Value +Unison.+toString + Root (external ref): Value +DeadTest.+deadIncorrect + Root (annotated): Value +Records.+origin + Root (annotated): Value +Variants.+onlySunday + Root (annotated): Value +TypeParams3.+test2 + Root (annotated): Value +Tuples.+origin + Root (annotated): Value +Uncurried.+sumLblCurried + Root (annotated): Value +Tuples.+computeArea + Root (annotated): Value +References.+get + Root (annotated): Value +ModuleAliases.+testNested + Root (external ref): Value +FirstClassModules.SomeFunctor.+ww + Root (external ref): VariantCase +DeadTest.WithInclude.t.A + Root (external ref): Value +Unison.+group + Root (annotated): Value +ImportJsValue.+area + Root (annotated): Value +Records.+testMyRec + Root (annotated): Value +ImportJsValue.+roundedNumber + Root (external ref): RecordLabel +RepeatedLabel.tabState.a + Root (external ref): Value ErrorHandler.Make.+notify + Root (annotated): Value +References.+make + Root (annotated): Value +Shadow.+test + Root (external ref): RecordLabel +Types.mutuallyRecursiveA.b + Root (annotated): RecordLabel +ImportHooks.props.renderMe + Root (annotated): Value +Uncurried.+callback + Root (annotated): Value +TestPromise.+convert + Root (external ref): Value +EmptyArray.Z.+make + Root (external ref): Value +Newton.+result + Root (annotated): Value +Records.+findAllAddresses + Root (annotated): Value +Variants.+id2 + Root (external ref): Value +TestOptArg.+bar + Root (external ref): RecordLabel +DeadTest.record.yyy + Root (annotated): Value +Docstrings.+unitArgWithoutConversion + Root (annotated): Value +Hooks.Inner.+make + Root (annotated): Value +Uncurried.+curried3 + Root (external ref): Value +OptArg.+twoArgs + Root (external ref): RecordLabel +Records.business2.address2 + Root (annotated): Value +Tuples.+testTuple + Root (annotated): Value +Records.+testMyObj2 + Root (annotated): Value +Uncurried.+uncurried1 + Root (external ref): Value +DeadTest.VariantUsedOnlyInImplementation.+a + Root (annotated): Value +ImportMyBanner.+make + Root (external ref): RecordLabel +Records.payload.payload + Root (annotated): Value +Docstrings.+one + Root (annotated): Value +ImportJsValue.+returnMixedArray + Root (annotated): Value +TestEmitInnerModules.Outer.Medium.Inner.+y + Root (annotated): Value +TestEmitInnerModules.Inner.+x + Root (external ref): Value +OptArg.+wrapOneArg + Root (external ref): RecordLabel +ComponentAsProp.props.title + Root (annotated): Value +Records.+findAddress + Root (annotated): Value +VariantsWithPayload.+printVariantWithPayload + Root (annotated): Value +Docstrings.+two + Root (annotated): Value +TestImmutableArray.+testImmutableArrayGet + Root (annotated): Value +Uncurried.+sumU2 + Root (annotated): Value +Hooks.RenderPropRequiresConversion.+make + Root (annotated): Value +LetPrivate.local_1.+x + Root (annotated): Value +TestImport.+make + Root (external ref): RecordLabel +Unison.t.break + Root (annotated): Value +ImportJsValue.+default + Root (annotated): Value +Types.+optFunction + Root (annotated): Value +Records.+getPayloadRecordPlusOne + Root (annotated): Value +Types.+swap + Root (annotated): Value +Types.+jsonStringify + Root (annotated): RecordLabel +ImportHookDefault.props.person + Root (annotated): Value +Variants.+saturday + Root (annotated): Value +Records.+findAddress2 + Root (annotated): Value +Records.+someBusiness + Root (external ref): RecordLabel +Hooks.vehicle.name + Root (external ref): RecordLabel +Uncurried.authU.loginU + Root (annotated): Value +Docstrings.+unnamed2 + Root (annotated): Value +References.+preserveRefIdentity + Root (annotated): Value +Types.+jsStringT + Root (annotated): Value +Variants.+restResult1 + Root (annotated): Value +Uncurried.+sumCurried + Root (annotated): Value +References.+set + Root (external ref): Value +DeadTest.MM.+x + Root (annotated): Value +ModuleAliases.+testInner + Root (external ref): RecordLabel +ComponentAsProp.props.description + Root (annotated): Value +Uncurried.+callback2U + Root (annotated): Value +Tuples.+changeSecondAge + Root (annotated): RecordLabel +DeadTest.inlineRecord.IR.e + Root (annotated): Value +Records.+recordValue + Root (annotated): Value +ImportHookDefault.+make + Root (annotated): Value +Types.+map + Root (annotated): Value +Types.+testInstantiateTypeParameter + Root (annotated): RecordLabel +DeadTypeTest.record.x + Root (external ref): RecordLabel +Records.myRec.type_ + Root (annotated): Value +TestOptArg.+liveSuppressesOptArgs + Root (annotated): Value +ImportJsValue.+useGetAbs + Root (annotated): Value NestedModulesInSignature.Universe.+theAnswer + Root (annotated): Value +References.+create + Root (annotated): Value +Types.+currentTime + Root (annotated): Value +Uncurried.+uncurried2 + Root (annotated): Value +Records.+someBusiness2 + Root (annotated): Value +FirstClassModules.+testConvert + Root (external ref): RecordLabel +DeadTest.inlineRecord.IR.c + Root (external ref): RecordLabel +Records.coord.z + Root (annotated): Value +Types.+someIntList + Root (annotated): Value +Types.+jsString2T + Root (annotated): Value +Records.+coord2d + Root (external ref): RecordLabel +DynamicallyLoadedComponent.props.s + Root (external ref): RecordLabel +Tuples.person.age + Root (annotated): Value +NestedModules.Universe.+someString + Root (annotated): Value +TestFirstClassModules.+convertInterface + Root (external ref): RecordLabel +TestPromise.fromPayload.s + Root (annotated): Value +Types.+testMarshalFields + Root (external ref): RecordLabel +VariantsWithPayload.payload.x + Root (annotated): RecordLabel +ImportHooks.props.children + Root (external ref): VariantCase +DeadTypeTest.t.A + Root (annotated): RecordLabel +DeadTypeTest.record.z + Root (annotated): Value +Docstrings.+flat + Root (annotated): Value +NestedModules.Universe.Nested2.+nested2Value + Root (annotated): Value +Records.+testMyObj + Root (external ref): VariantCase DeadTypeTest.deadType.InBoth + Root (annotated): Value +Records.+testMyRecBsAs2 + Root (annotated): Value +VariantsWithPayload.+testManyPayloads + Root (annotated): Value +FirstClassModules.+someFunctorAsFunction + Root (annotated): Value +Records.+computeArea3 + Root (annotated): Value +Variants.+fortytwoBAD + Root (external ref): Value +DeadTest.+thisIsUsedOnce + Root (annotated): Value +ImportJsValue.+returnedFromHigherOrder + Root (external ref): Value ImmutableArray.+fromArray + Root (external ref): Value +RepeatedLabel.+userData + Root (annotated): Value +Variants.+testConvert2to3 + Root (external ref): Value +OptArg.+wrapfourArgs + Root (annotated): Value +ImportJsValue.+round + Root (annotated): Value +TestModuleAliases.+testInner2 + Root (annotated): Value +VariantsWithPayload.+testSimpleVariant + Root (annotated): Value +TestFirstClassModules.+convert + Root (external ref): VariantCase +DeadRT.moduleAccessPath.Kaboom + Root (external ref): Value +DeadCodeImplementation.M.+x + Root (external ref): RecordLabel +Uncurried.auth.login + Root (annotated): Value +VariantsWithPayload.+testVariantWithPayloads + Root (annotated): Value +Variants.+restResult2 + Root (annotated): Value +Docstrings.+unitArgWithConversion + Root (annotated): Value +ImportJsValue.+useColor + Root (annotated): Value +Records.+getPayload + Root (external ref): VariantCase +Unison.break.IfNeed + Root (external ref): Value +FirstClassModules.M.+y + Root (annotated): Value +ModuleAliases.+testInner2 + Root (annotated): RecordLabel +ImportHooks.props.person + Root (external ref): Value DeadValueTest.+valueAlive + Root (external ref): RecordLabel +Hooks.Inner.props.vehicle + Root (annotated): Value +Shadow.M.+test + Root (annotated): Value +ComponentAsProp.+make + Root (annotated): Value +Records.+testMyRec2 + Root (annotated): Value +VariantsWithPayload.+printManyPayloads + Root (annotated): Value +TestFirstClassModules.+convertFirstClassModuleWithTypeEquations + Root (annotated): Value +TransitiveType1.+convertAlias + Root (external ref): Exception +DeadExn.Inside.Einside + Root (annotated): Value +TestImport.+defaultValue2 + Root (external ref): Exception +DeadExn.Etoplevel + Root (annotated): Value +Variants.+monday + Root (annotated): Value +VariantsWithPayload.+printVariantWithPayloads + Root (annotated): Value +Unboxed.+r2Test + Root (external ref): RecordLabel +Records.coord.y + Root (external ref): RecordLabel +DeadTest.record.xxx + Root (annotated): Value +FirstClassModules.+firstClassModule + Root (external ref): RecordLabel +Hooks.props.vehicle + Root (annotated): Value +Docstrings.+useParamU + Root (external ref): Value +JsxV4.C.+make + Root (external ref): RecordLabel +Types.selfRecursive.self + Root (annotated): Value +Variants.+polyWithOpt + Root (annotated): Value +References.+destroysRefIdentity + Root (annotated): Value +Docstrings.+unnamed2U + Root (external ref): Value +FirstClassModules.M.+x + Root (annotated): Value +Uncurried.+uncurried0 + Root (external ref): VariantCase +Unison.stack.Empty + Root (annotated): Value +Records.+computeArea4 + Root (annotated): Value +TestModuleAliases.+testInner1Expanded + Root (annotated): Value +ImportIndex.+make + Root (annotated): Value +Unboxed.+testV1 + Root (annotated): Value +NestedModules.Universe.+theAnswer + Root (annotated): Value +References.+access + Root (annotated): Value +TestModuleAliases.+testInner2Expanded + Root (annotated): Value +Variants.+isWeekend + Root (annotated): Value +Variants.+testConvert + Root (annotated): Value +Variants.+id1 + Root (annotated): Value +VariantsWithPayload.+testVariant1Object + Root (annotated): Value +References.+update + Root (annotated): Value +Docstrings.+treeU + Root (annotated): Value +Opaque.+noConversion + Root (external ref): RecordLabel +RepeatedLabel.tabState.b + Root (annotated): Value +Docstrings.+twoU + Root (annotated): Value +DeadTest.GloobLive.+globallyLive1 + Root (external ref): RecordLabel +Records.business.owner + Root (external ref): VariantCase +Unison.stack.Cons + Root (external ref): VariantCase +DeadTypeTest.deadType.InBoth + Root (external ref): RecordLabel +Records.business.address + Root (external ref): RecordLabel +VariantsWithPayload.payload.y + Root (annotated): RecordLabel +ImportHookDefault.props.children + Root (annotated): Value +TestModuleAliases.+testInner1 + Root (annotated): Value +VariantsWithPayload.+testWithPayload + Root (annotated): Value +Types.+testConvertNull + Root (annotated): Value +Records.+getPayloadRecord + Root (annotated): Value +Tuples.+computeAreaNoConverters + Root (annotated): Value +Docstrings.+useParam + Root (annotated): Value +Types.+mutuallyRecursiveConverter + Root (annotated): Value +UseImportJsValue.+useGetProp + Root (external ref): RecordLabel +Hooks.RenderPropRequiresConversion.props.renderVehicle + + 300 roots found + + Propagate: +Hooks.+default -> +Hooks.+make + Propagate: DeadRT.moduleAccessPath.Root -> +DeadRT.moduleAccessPath.Root + Propagate: +DeadTypeTest.deadType.OnlyInImplementation -> DeadTypeTest.deadType.OnlyInImplementation + Propagate: +OptionalArgsLiveDead.+liveCaller -> +OptionalArgsLiveDead.+formatDate + Propagate: +Newton.+f -> +Newton.+- + Propagate: +Newton.+f -> +Newton.++ + Propagate: +Newton.+f -> +Newton.+* + Propagate: +DeadTest.VariantUsedOnlyInImplementation.t.A -> +DeadTest.VariantUsedOnlyInImplementation.t.A + Propagate: +DeadTest.+thisIsMarkedLive -> +DeadTest.+thisIsKeptAlive + Propagate: InnerModuleTypes.I.t.Foo -> +InnerModuleTypes.I.t.Foo + Propagate: DeadTypeTest.deadType.OnlyInInterface -> +DeadTypeTest.deadType.OnlyInInterface + Propagate: +Unison.+toString -> +Unison.+fits + Propagate: +References.+get -> +References.R.+get + Propagate: +DeadTest.WithInclude.t.A -> +DeadTest.WithInclude.t.A + Propagate: ErrorHandler.Make.+notify -> +ErrorHandler.Make.+notify + Propagate: +References.+make -> +References.R.+make + Propagate: +Newton.+result -> +Newton.+newton + Propagate: +Newton.+result -> +Newton.+fPrimed + Propagate: +Records.+findAllAddresses -> +Records.+getOpt + Propagate: +TestOptArg.+bar -> +TestOptArg.+foo + Propagate: +DeadTest.VariantUsedOnlyInImplementation.+a -> +DeadTest.VariantUsedOnlyInImplementation.+a + Propagate: +OptArg.+wrapOneArg -> +OptArg.+oneArg + Propagate: +TestImmutableArray.+testImmutableArrayGet -> ImmutableArray.Array.+get + Propagate: +References.+set -> +References.R.+set + Propagate: +DeadTest.MM.+x -> +DeadTest.MM.+x + Propagate: +ImportJsValue.+useGetAbs -> +ImportJsValue.AbsoluteValue.+getAbs + Propagate: NestedModulesInSignature.Universe.+theAnswer -> +NestedModulesInSignature.Universe.+theAnswer + Propagate: +DeadTypeTest.t.A -> DeadTypeTest.t.A + Propagate: ImmutableArray.+fromArray -> +ImmutableArray.+fromArray + Propagate: +OptArg.+wrapfourArgs -> +OptArg.+fourArgs + Propagate: +DeadRT.moduleAccessPath.Kaboom -> DeadRT.moduleAccessPath.Kaboom + Propagate: DeadValueTest.+valueAlive -> +DeadValueTest.+valueAlive + Propagate: +References.R.+get -> +References.R.+get + Propagate: +References.R.+make -> +References.R.+make + Propagate: +Newton.+newton -> +Newton.+/ + Propagate: +Newton.+newton -> +Newton.+current + Propagate: +Newton.+newton -> +Newton.+iterateMore + Propagate: +Newton.+newton -> +Newton.+delta + Propagate: +Newton.+newton -> +Newton.+loop + Propagate: +Newton.+newton -> +Newton.+previous + Propagate: +Newton.+newton -> +Newton.+next + Propagate: ImmutableArray.Array.+get -> +ImmutableArray.+get + Propagate: +References.R.+set -> +References.R.+set + Propagate: +DeadTest.MM.+x -> +DeadTest.MM.+y + Propagate: +ImportJsValue.AbsoluteValue.+getAbs -> +ImportJsValue.AbsoluteValue.+getAbs + + 45 declarations marked live via propagation + + Dead VariantCase +AutoAnnotate.variant.R: 0 references () + Dead RecordLabel +AutoAnnotate.record.variant: 0 references () + Dead RecordLabel +AutoAnnotate.r2.r2: 0 references () + Dead RecordLabel +AutoAnnotate.r3.r3: 0 references () + Dead RecordLabel +AutoAnnotate.r4.r4: 0 references () + Dead VariantCase +AutoAnnotate.annotatedVariant.R2: 0 references () + Dead VariantCase +AutoAnnotate.annotatedVariant.R4: 0 references () + Dead Value +BucklescriptAnnotations.+bar: 0 references () + Dead Value +BucklescriptAnnotations.+f: 1 references (BucklescriptAnnotations.res:22:4) + Live (annotated) Value +ComponentAsProp.+make: 0 references () + Live (external ref) RecordLabel +ComponentAsProp.props.title: 1 references (_none_:1:-1) + Live (external ref) RecordLabel +ComponentAsProp.props.description: 1 references (_none_:1:-1) + Live (external ref) RecordLabel +ComponentAsProp.props.button: 1 references (_none_:1:-1) + Live (external ref) Value +CreateErrorHandler1.Error1.+notification: 1 references (ErrorHandler.resi:3:2) + Live (external ref) Value +CreateErrorHandler2.Error2.+notification: 1 references (ErrorHandler.resi:3:2) + Live (external ref) Value +DeadCodeImplementation.M.+x: 1 references (DeadCodeInterface.res:2:2) + Live (external ref) Exception +DeadExn.Etoplevel: 1 references (DeadExn.res:8:16) + Live (external ref) Exception +DeadExn.Inside.Einside: 1 references (DeadExn.res:10:14) + Dead Exception +DeadExn.DeadE: 0 references () + Dead Value +DeadExn.+eToplevel: 0 references () + Live (external ref) Value +DeadExn.+eInside: 1 references (DeadExn.res:12:7) + Live (propagated) VariantCase +DeadRT.moduleAccessPath.Root: 1 references (DeadRT.resi:2:2) + Live (external ref) VariantCase +DeadRT.moduleAccessPath.Kaboom: 2 references (DeadRT.res:11:16, DeadRT.resi:3:2) + Dead Value +DeadRT.+emitModuleAccessPath: 0 references () + Live (external ref) VariantCase DeadRT.moduleAccessPath.Root: 2 references (DeadRT.res:2:2, DeadTest.res:98:16) + Live (propagated) VariantCase DeadRT.moduleAccessPath.Kaboom: 1 references (DeadRT.res:3:2) + Dead Value +DeadTest.+fortytwo: 0 references () + Live (annotated) Value +DeadTest.+fortyTwoButExported: 0 references () + Live (external ref) Value +DeadTest.+thisIsUsedOnce: 1 references (DeadTest.res:8:7) + Live (external ref) Value +DeadTest.+thisIsUsedTwice: 2 references (DeadTest.res:11:7, DeadTest.res:12:7) + Dead Value +DeadTest.+thisIsMarkedDead: 0 references () + Live (propagated) Value +DeadTest.+thisIsKeptAlive: 1 references (DeadTest.res:20:4) + Live (annotated) Value +DeadTest.+thisIsMarkedLive: 0 references () + Dead Value +DeadTest.Inner.+thisIsAlsoMarkedDead: 0 references () + Dead Value +DeadTest.M.+thisSignatureItemIsDead: 0 references () + Dead Value +DeadTest.M.+thisSignatureItemIsDead: 1 references (DeadTest.res:28:2) + Live (propagated) VariantCase +DeadTest.VariantUsedOnlyInImplementation.t.A: 1 references (DeadTest.res:38:11) + Live (external ref) Value +DeadTest.VariantUsedOnlyInImplementation.+a: 1 references (DeadTest.res:42:17) + Live (external ref) VariantCase +DeadTest.VariantUsedOnlyInImplementation.t.A: 2 references (DeadTest.res:35:11, DeadTest.res:39:10) + Live (propagated) Value +DeadTest.VariantUsedOnlyInImplementation.+a: 1 references (DeadTest.res:36:2) + Dead Value +DeadTest.+_: 0 references () + Dead Value +DeadTest.+_: 0 references () + Live (external ref) RecordLabel +DeadTest.record.xxx: 1 references (DeadTest.res:52:13) + Live (external ref) RecordLabel +DeadTest.record.yyy: 1 references (DeadTest.res:53:9) + Dead Value +DeadTest.+_: 0 references () + Dead Value +DeadTest.+_: 0 references () + Dead Value +DeadTest.UnderscoreInside.+_: 0 references () + Live (external ref) Value +DeadTest.MM.+x: 1 references (DeadTest.res:69:9) + Dead Value +DeadTest.MM.+y: 0 references () + Live (propagated) Value +DeadTest.MM.+y: 2 references (DeadTest.res:61:2, DeadTest.res:64:6) + Live (propagated) Value +DeadTest.MM.+x: 1 references (DeadTest.res:60:2) + Dead Value +DeadTest.MM.+valueOnlyInImplementation: 0 references () + Dead Value +DeadTest.+unusedRec: 1 references (DeadTest.res:75:8) + Dead Value +DeadTest.+split_map: 1 references (DeadTest.res:77:8) + Dead Value +DeadTest.+rec1: 1 references (DeadTest.res:83:4) + Dead Value +DeadTest.+rec2: 1 references (DeadTest.res:82:8) + Dead Value +DeadTest.+recWithCallback: 1 references (DeadTest.res:86:6) + Dead Value +DeadTest.+cb: 1 references (DeadTest.res:85:8) + Dead Value +DeadTest.+foo: 1 references (DeadTest.res:94:4) + Dead Value +DeadTest.+cb: 1 references (DeadTest.res:90:8) + Dead Value +DeadTest.+bar: 1 references (DeadTest.res:91:6) + Dead Value +DeadTest.+withDefaultValue: 0 references () + Dead Value +DeadTest.+zzz: 0 references () + Dead Value +DeadTest.+a1: 0 references () + Dead Value +DeadTest.+a2: 0 references () + Dead Value +DeadTest.+a3: 0 references () + Dead Value +DeadTest.+second: 0 references () + Dead Value +DeadTest.+deadRef: 0 references () + Live (external ref) Value +DeadTest.+make: 1 references (DeadTest.res:119:16) + Live (external ref) RecordLabel +DeadTest.props.s: 1 references (_none_:1:-1) + Dead Value +DeadTest.+theSideEffectIsLogging: 0 references () + Dead Value +DeadTest.+stringLengthNoSideEffects: 0 references () + Live (annotated) Value +DeadTest.GloobLive.+globallyLive1: 0 references () + Live (annotated) Value +DeadTest.GloobLive.+globallyLive2: 0 references () + Live (annotated) Value +DeadTest.GloobLive.+globallyLive3: 0 references () + Live (external ref) VariantCase +DeadTest.WithInclude.t.A: 2 references (DeadTest.res:137:13, DeadTest.res:142:7) + Live (propagated) VariantCase +DeadTest.WithInclude.t.A: 1 references (DeadTest.res:134:11) + Dead Value +DeadTest.+funWithInnerVars: 0 references () + Dead Value +DeadTest.+x: 1 references (DeadTest.res:145:4) + Dead Value +DeadTest.+y: 1 references (DeadTest.res:145:4) + Dead RecordLabel +DeadTest.rc.a: 0 references () + Live (external ref) Value +DeadTest.+deadIncorrect: 1 references (DeadTest.res:156:8) + Dead Value +DeadTest.+_: 0 references () + Live (external ref) VariantCase +DeadTest.inlineRecord.IR: 1 references (DeadTest.res:163:20) + Dead RecordLabel +DeadTest.inlineRecord.IR.a: 0 references () + Live (external ref) RecordLabel +DeadTest.inlineRecord.IR.b: 1 references (DeadTest.res:163:35) + Live (external ref) RecordLabel +DeadTest.inlineRecord.IR.c: 1 references (DeadTest.res:163:7) + Dead RecordLabel +DeadTest.inlineRecord.IR.d: 0 references () + Live (annotated) RecordLabel +DeadTest.inlineRecord.IR.e: 0 references () + Live (external ref) Value +DeadTest.+ira: 1 references (DeadTest.res:163:27) + Dead Value +DeadTest.+_: 0 references () + Dead VariantCase +DeadTest.inlineRecord2.IR2: 0 references () + Dead RecordLabel +DeadTest.inlineRecord2.IR2.a: 0 references () + Dead RecordLabel +DeadTest.inlineRecord2.IR2.b: 0 references () + Dead VariantCase +DeadTest.inlineRecord3.IR3: 0 references () + Dead RecordLabel +DeadTest.inlineRecord3.IR3.a: 0 references () + Dead RecordLabel +DeadTest.inlineRecord3.IR3.b: 0 references () + Dead Value +DeadTestBlacklist.+x: 0 references () + Dead Value +DeadTestWithInterface.Ext_buffer.+x: 0 references () + Dead Value +DeadTestWithInterface.Ext_buffer.+x: 1 references (DeadTestWithInterface.res:2:2) + Live (external ref) VariantCase +DeadTypeTest.t.A: 2 references (DeadTypeTest.res:4:8, DeadTypeTest.resi:2:2) + Dead VariantCase +DeadTypeTest.t.B: 1 references (DeadTypeTest.resi:3:2) + Dead Value +DeadTypeTest.+a: 1 references (DeadTypeTest.resi:4:0) + Live (external ref) VariantCase +DeadTypeTest.deadType.OnlyInImplementation: 2 references (DeadTypeTest.res:12:8, DeadTypeTest.resi:7:2) + Live (propagated) VariantCase +DeadTypeTest.deadType.OnlyInInterface: 1 references (DeadTypeTest.resi:8:2) + Live (external ref) VariantCase +DeadTypeTest.deadType.InBoth: 2 references (DeadTypeTest.res:13:8, DeadTypeTest.resi:9:2) + Dead VariantCase +DeadTypeTest.deadType.InNeither: 1 references (DeadTypeTest.resi:10:2) + Dead Value +DeadTypeTest.+_: 0 references () + Dead Value +DeadTypeTest.+_: 0 references () + Live (annotated) RecordLabel +DeadTypeTest.record.x: 0 references () + Live (annotated) RecordLabel +DeadTypeTest.record.y: 0 references () + Live (annotated) RecordLabel +DeadTypeTest.record.z: 0 references () + Live (propagated) VariantCase DeadTypeTest.t.A: 1 references (DeadTypeTest.res:2:2) + Dead VariantCase DeadTypeTest.t.B: 1 references (DeadTypeTest.res:3:2) + Dead Value DeadTypeTest.+a: 0 references () + Live (propagated) VariantCase DeadTypeTest.deadType.OnlyInImplementation: 1 references (DeadTypeTest.res:7:2) + Live (external ref) VariantCase DeadTypeTest.deadType.OnlyInInterface: 2 references (DeadTest.res:44:8, DeadTypeTest.res:8:2) + Live (external ref) VariantCase DeadTypeTest.deadType.InBoth: 2 references (DeadTest.res:45:8, DeadTypeTest.res:9:2) + Dead VariantCase DeadTypeTest.deadType.InNeither: 1 references (DeadTypeTest.res:10:2) + Live (propagated) Value +DeadValueTest.+valueAlive: 1 references (DeadValueTest.resi:1:0) + Dead Value +DeadValueTest.+valueDead: 1 references (DeadValueTest.resi:2:0) + Dead Value +DeadValueTest.+valueOnlyInImplementation: 0 references () + Dead Value +DeadValueTest.+subList: 1 references (DeadValueTest.res:10:8) + Dead Value +DeadValueTest.+tail: 1 references (DeadValueTest.res:6:8) + Live (external ref) Value DeadValueTest.+valueAlive: 1 references (DeadTest.res:73:16) + Dead Value DeadValueTest.+valueDead: 0 references () + Live (annotated) Value +Docstrings.+flat: 0 references () + Live (annotated) Value +Docstrings.+signMessage: 0 references () + Live (annotated) Value +Docstrings.+one: 0 references () + Live (annotated) Value +Docstrings.+two: 0 references () + Live (annotated) Value +Docstrings.+tree: 0 references () + Live (annotated) Value +Docstrings.+oneU: 0 references () + Live (annotated) Value +Docstrings.+twoU: 0 references () + Live (annotated) Value +Docstrings.+treeU: 0 references () + Live (annotated) Value +Docstrings.+useParam: 0 references () + Live (annotated) Value +Docstrings.+useParamU: 0 references () + Live (annotated) Value +Docstrings.+unnamed1: 0 references () + Live (annotated) Value +Docstrings.+unnamed1U: 0 references () + Live (annotated) Value +Docstrings.+unnamed2: 0 references () + Live (annotated) Value +Docstrings.+unnamed2U: 0 references () + Live (annotated) Value +Docstrings.+grouped: 0 references () + Live (annotated) Value +Docstrings.+unitArgWithoutConversion: 0 references () + Live (annotated) Value +Docstrings.+unitArgWithoutConversionU: 0 references () + Live (external ref) VariantCase +Docstrings.t.A: 2 references (Docstrings.res:64:34, Docstrings.res:67:39) + Dead VariantCase +Docstrings.t.B: 0 references () + Live (annotated) Value +Docstrings.+unitArgWithConversion: 0 references () + Live (annotated) Value +Docstrings.+unitArgWithConversionU: 0 references () + Live (external ref) Value +DynamicallyLoadedComponent.+make: 1 references (DeadTest.res:110:17) + Live (external ref) RecordLabel +DynamicallyLoadedComponent.props.s: 1 references (_none_:1:-1) + Live (external ref) Value +EmptyArray.Z.+make: 1 references (EmptyArray.res:10:9) + Live (propagated) Value +ErrorHandler.Make.+notify: 1 references (ErrorHandler.resi:7:2) + Dead Value +ErrorHandler.+x: 1 references (ErrorHandler.resi:10:0) + Live (external ref) Value ErrorHandler.Make.+notify: 1 references (CreateErrorHandler1.res:8:0) + Dead Value ErrorHandler.+x: 0 references () + Dead Value +EverythingLiveHere.+x: 0 references () + Dead Value +EverythingLiveHere.+y: 0 references () + Dead Value +EverythingLiveHere.+z: 0 references () + Live (external ref) Value +FirstClassModules.M.+y: 1 references (FirstClassModules.res:20:2) + Live (external ref) Value +FirstClassModules.M.InnerModule2.+k: 1 references (FirstClassModules.res:10:4) + Live (external ref) Value +FirstClassModules.M.InnerModule3.+k3: 1 references (FirstClassModules.res:14:4) + Live (external ref) Value +FirstClassModules.M.Z.+u: 1 references (FirstClassModules.res:37:4) + Live (external ref) Value +FirstClassModules.M.+x: 1 references (FirstClassModules.res:2:2) + Live (annotated) Value +FirstClassModules.+firstClassModule: 0 references () + Live (annotated) Value +FirstClassModules.+testConvert: 0 references () + Live (external ref) Value +FirstClassModules.SomeFunctor.+ww: 1 references (FirstClassModules.res:57:2) + Live (annotated) Value +FirstClassModules.+someFunctorAsFunction: 0 references () + Dead RecordLabel +FirstClassModulesInterface.record.x: 1 references (FirstClassModulesInterface.resi:3:2) + Dead RecordLabel +FirstClassModulesInterface.record.y: 1 references (FirstClassModulesInterface.resi:4:2) + Dead Value +FirstClassModulesInterface.+r: 1 references (FirstClassModulesInterface.resi:7:0) + Dead RecordLabel FirstClassModulesInterface.record.x: 1 references (FirstClassModulesInterface.res:2:2) + Dead RecordLabel FirstClassModulesInterface.record.y: 1 references (FirstClassModulesInterface.res:3:2) + Dead Value FirstClassModulesInterface.+r: 0 references () + Live (external ref) RecordLabel +Hooks.vehicle.name: 5 references (Hooks.res:10:29, Hooks.res:29:66, Hooks.res:33:68, Hooks.res:47:2, Hooks.res:47:14) + Live (propagated) Value +Hooks.+make: 1 references (Hooks.res:25:4) + Live (external ref) RecordLabel +Hooks.props.vehicle: 1 references (_none_:1:-1) + Live (annotated) Value +Hooks.+default: 0 references () + Live (annotated) Value +Hooks.Inner.+make: 0 references () + Live (external ref) RecordLabel +Hooks.Inner.props.vehicle: 1 references (_none_:1:-1) + Live (annotated) Value +Hooks.Inner.Inner2.+make: 0 references () + Live (external ref) RecordLabel +Hooks.Inner.Inner2.props.vehicle: 1 references (_none_:1:-1) + Live (annotated) Value +Hooks.NoProps.+make: 0 references () + Live (annotated) Value +Hooks.+functionWithRenamedArgs: 0 references () + Dead RecordLabel +Hooks.r.x: 0 references () + Live (annotated) Value +Hooks.RenderPropRequiresConversion.+make: 0 references () + Live (external ref) RecordLabel +Hooks.RenderPropRequiresConversion.props.renderVehicle: 1 references (_none_:1:-1) + Live (external ref) Value +Hooks.RenderPropRequiresConversion.+car: 1 references (Hooks.res:65:30) + Live (propagated) Value +ImmutableArray.+fromArray: 1 references (ImmutableArray.resi:9:0) + Dead Value +ImmutableArray.+toArray: 1 references (ImmutableArray.resi:12:0) + Dead Value +ImmutableArray.+length: 1 references (ImmutableArray.resi:14:0) + Dead Value +ImmutableArray.+size: 1 references (ImmutableArray.resi:17:0) + Live (propagated) Value +ImmutableArray.+get: 2 references (ImmutableArray.resi:6:2, ImmutableArray.resi:19:0) + Dead Value +ImmutableArray.+getExn: 1 references (ImmutableArray.resi:21:0) + Dead Value +ImmutableArray.+getUnsafe: 1 references (ImmutableArray.resi:23:0) + Dead Value +ImmutableArray.+getUndefined: 1 references (ImmutableArray.resi:25:0) + Dead Value +ImmutableArray.+shuffle: 1 references (ImmutableArray.resi:27:0) + Dead Value +ImmutableArray.+reverse: 1 references (ImmutableArray.resi:29:0) + Dead Value +ImmutableArray.+makeUninitialized: 1 references (ImmutableArray.resi:31:0) + Dead Value +ImmutableArray.+makeUninitializedUnsafe: 1 references (ImmutableArray.resi:33:0) + Dead Value +ImmutableArray.+make: 1 references (ImmutableArray.resi:35:0) + Dead Value +ImmutableArray.+range: 1 references (ImmutableArray.resi:37:0) + Dead Value +ImmutableArray.+rangeBy: 1 references (ImmutableArray.resi:39:0) + Dead Value +ImmutableArray.+makeByU: 1 references (ImmutableArray.resi:41:0) + Dead Value +ImmutableArray.+makeBy: 1 references (ImmutableArray.resi:42:0) + Dead Value +ImmutableArray.+makeByAndShuffleU: 1 references (ImmutableArray.resi:44:0) + Dead Value +ImmutableArray.+makeByAndShuffle: 1 references (ImmutableArray.resi:45:0) + Dead Value +ImmutableArray.+zip: 1 references (ImmutableArray.resi:47:0) + Dead Value +ImmutableArray.+zipByU: 1 references (ImmutableArray.resi:49:0) + Dead Value +ImmutableArray.+zipBy: 1 references (ImmutableArray.resi:50:0) + Dead Value +ImmutableArray.+unzip: 1 references (ImmutableArray.resi:52:0) + Dead Value +ImmutableArray.+concat: 1 references (ImmutableArray.resi:54:0) + Dead Value +ImmutableArray.+concatMany: 1 references (ImmutableArray.resi:56:0) + Dead Value +ImmutableArray.+slice: 1 references (ImmutableArray.resi:58:0) + Dead Value +ImmutableArray.+sliceToEnd: 1 references (ImmutableArray.resi:60:0) + Dead Value +ImmutableArray.+copy: 1 references (ImmutableArray.resi:62:0) + Dead Value +ImmutableArray.+forEachU: 1 references (ImmutableArray.resi:64:0) + Dead Value +ImmutableArray.+forEach: 1 references (ImmutableArray.resi:65:0) + Dead Value +ImmutableArray.+mapU: 1 references (ImmutableArray.resi:67:0) + Dead Value +ImmutableArray.+map: 1 references (ImmutableArray.resi:68:0) + Dead Value +ImmutableArray.+keepWithIndexU: 1 references (ImmutableArray.resi:70:0) + Dead Value +ImmutableArray.+keepWithIndex: 1 references (ImmutableArray.resi:71:0) + Dead Value +ImmutableArray.+keepMapU: 1 references (ImmutableArray.resi:73:0) + Dead Value +ImmutableArray.+keepMap: 1 references (ImmutableArray.resi:74:0) + Dead Value +ImmutableArray.+forEachWithIndexU: 1 references (ImmutableArray.resi:76:0) + Dead Value +ImmutableArray.+forEachWithIndex: 1 references (ImmutableArray.resi:77:0) + Dead Value +ImmutableArray.+mapWithIndexU: 1 references (ImmutableArray.resi:79:0) + Dead Value +ImmutableArray.+mapWithIndex: 1 references (ImmutableArray.resi:80:0) + Dead Value +ImmutableArray.+partitionU: 1 references (ImmutableArray.resi:82:0) + Dead Value +ImmutableArray.+partition: 1 references (ImmutableArray.resi:83:0) + Dead Value +ImmutableArray.+reduceU: 1 references (ImmutableArray.resi:85:0) + Dead Value +ImmutableArray.+reduce: 1 references (ImmutableArray.resi:86:0) + Dead Value +ImmutableArray.+reduceReverseU: 1 references (ImmutableArray.resi:88:0) + Dead Value +ImmutableArray.+reduceReverse: 1 references (ImmutableArray.resi:89:0) + Dead Value +ImmutableArray.+reduceReverse2U: 1 references (ImmutableArray.resi:91:0) + Dead Value +ImmutableArray.+reduceReverse2: 1 references (ImmutableArray.resi:92:0) + Dead Value +ImmutableArray.+someU: 1 references (ImmutableArray.resi:94:0) + Dead Value +ImmutableArray.+some: 1 references (ImmutableArray.resi:95:0) + Dead Value +ImmutableArray.+everyU: 1 references (ImmutableArray.resi:97:0) + Dead Value +ImmutableArray.+every: 1 references (ImmutableArray.resi:98:0) + Dead Value +ImmutableArray.+every2U: 1 references (ImmutableArray.resi:100:0) + Dead Value +ImmutableArray.+every2: 1 references (ImmutableArray.resi:101:0) + Dead Value +ImmutableArray.+some2U: 1 references (ImmutableArray.resi:103:0) + Dead Value +ImmutableArray.+some2: 1 references (ImmutableArray.resi:104:0) + Dead Value +ImmutableArray.+cmpU: 1 references (ImmutableArray.resi:106:0) + Dead Value +ImmutableArray.+cmp: 1 references (ImmutableArray.resi:107:0) + Dead Value +ImmutableArray.+eqU: 1 references (ImmutableArray.resi:109:0) + Dead Value +ImmutableArray.+eq: 1 references (ImmutableArray.resi:110:0) + Live (propagated) Value ImmutableArray.Array.+get: 1 references (TestImmutableArray.res:2:4) + Live (external ref) Value ImmutableArray.+fromArray: 1 references (DeadTest.res:1:15) + Dead Value ImmutableArray.+toArray: 0 references () + Dead Value ImmutableArray.+length: 0 references () + Dead Value ImmutableArray.+size: 0 references () + Dead Value ImmutableArray.+get: 0 references () + Dead Value ImmutableArray.+getExn: 0 references () + Dead Value ImmutableArray.+getUnsafe: 0 references () + Dead Value ImmutableArray.+getUndefined: 0 references () + Dead Value ImmutableArray.+shuffle: 0 references () + Dead Value ImmutableArray.+reverse: 0 references () + Dead Value ImmutableArray.+makeUninitialized: 0 references () + Dead Value ImmutableArray.+makeUninitializedUnsafe: 0 references () + Dead Value ImmutableArray.+make: 0 references () + Dead Value ImmutableArray.+range: 0 references () + Dead Value ImmutableArray.+rangeBy: 0 references () + Dead Value ImmutableArray.+makeByU: 0 references () + Dead Value ImmutableArray.+makeBy: 0 references () + Dead Value ImmutableArray.+makeByAndShuffleU: 0 references () + Dead Value ImmutableArray.+makeByAndShuffle: 0 references () + Dead Value ImmutableArray.+zip: 0 references () + Dead Value ImmutableArray.+zipByU: 0 references () + Dead Value ImmutableArray.+zipBy: 0 references () + Dead Value ImmutableArray.+unzip: 0 references () + Dead Value ImmutableArray.+concat: 0 references () + Dead Value ImmutableArray.+concatMany: 0 references () + Dead Value ImmutableArray.+slice: 0 references () + Dead Value ImmutableArray.+sliceToEnd: 0 references () + Dead Value ImmutableArray.+copy: 0 references () + Dead Value ImmutableArray.+forEachU: 0 references () + Dead Value ImmutableArray.+forEach: 0 references () + Dead Value ImmutableArray.+mapU: 0 references () + Dead Value ImmutableArray.+map: 0 references () + Dead Value ImmutableArray.+keepWithIndexU: 0 references () + Dead Value ImmutableArray.+keepWithIndex: 0 references () + Dead Value ImmutableArray.+keepMapU: 0 references () + Dead Value ImmutableArray.+keepMap: 0 references () + Dead Value ImmutableArray.+forEachWithIndexU: 0 references () + Dead Value ImmutableArray.+forEachWithIndex: 0 references () + Dead Value ImmutableArray.+mapWithIndexU: 0 references () + Dead Value ImmutableArray.+mapWithIndex: 0 references () + Dead Value ImmutableArray.+partitionU: 0 references () + Dead Value ImmutableArray.+partition: 0 references () + Dead Value ImmutableArray.+reduceU: 0 references () + Dead Value ImmutableArray.+reduce: 0 references () + Dead Value ImmutableArray.+reduceReverseU: 0 references () + Dead Value ImmutableArray.+reduceReverse: 0 references () + Dead Value ImmutableArray.+reduceReverse2U: 0 references () + Dead Value ImmutableArray.+reduceReverse2: 0 references () + Dead Value ImmutableArray.+someU: 0 references () + Dead Value ImmutableArray.+some: 0 references () + Dead Value ImmutableArray.+everyU: 0 references () + Dead Value ImmutableArray.+every: 0 references () + Dead Value ImmutableArray.+every2U: 0 references () + Dead Value ImmutableArray.+every2: 0 references () + Dead Value ImmutableArray.+some2U: 0 references () + Dead Value ImmutableArray.+some2: 0 references () + Dead Value ImmutableArray.+cmpU: 0 references () + Dead Value ImmutableArray.+cmp: 0 references () + Dead Value ImmutableArray.+eqU: 0 references () + Dead Value ImmutableArray.+eq: 0 references () + Dead RecordLabel +ImportHookDefault.person.name: 0 references () + Dead RecordLabel +ImportHookDefault.person.age: 0 references () + Live (annotated) Value +ImportHookDefault.+make: 1 references (Hooks.res:17:5) + Live (annotated) RecordLabel +ImportHookDefault.props.person: 0 references () + Live (annotated) RecordLabel +ImportHookDefault.props.children: 0 references () + Live (annotated) RecordLabel +ImportHookDefault.props.renderMe: 0 references () + Dead RecordLabel +ImportHooks.person.name: 0 references () + Dead RecordLabel +ImportHooks.person.age: 0 references () + Live (annotated) Value +ImportHooks.+make: 1 references (Hooks.res:14:5) + Live (annotated) RecordLabel +ImportHooks.props.person: 0 references () + Live (annotated) RecordLabel +ImportHooks.props.children: 0 references () + Live (annotated) RecordLabel +ImportHooks.props.renderMe: 0 references () + Live (annotated) Value +ImportHooks.+foo: 0 references () + Live (annotated) Value +ImportIndex.+make: 0 references () + Live (annotated) RecordLabel +ImportIndex.props.method: 0 references () + Live (annotated) Value +ImportJsValue.+round: 1 references (ImportJsValue.res:27:4) + Dead RecordLabel +ImportJsValue.point.x: 0 references () + Dead RecordLabel +ImportJsValue.point.y: 0 references () + Live (annotated) Value +ImportJsValue.+area: 1 references (ImportJsValue.res:30:4) + Live (annotated) Value +ImportJsValue.+returnMixedArray: 0 references () + Live (annotated) Value +ImportJsValue.+roundedNumber: 0 references () + Live (annotated) Value +ImportJsValue.+areaValue: 0 references () + Live (propagated) Value +ImportJsValue.AbsoluteValue.+getAbs: 1 references (ImportJsValue.res:50:4) + Live (propagated) Value +ImportJsValue.AbsoluteValue.+getAbs: 1 references (ImportJsValue.res:40:6) + Live (annotated) Value +ImportJsValue.+useGetProp: 0 references () + Live (annotated) Value +ImportJsValue.+useGetAbs: 0 references () + Live (annotated) Value +ImportJsValue.+useColor: 0 references () + Live (annotated) Value +ImportJsValue.+higherOrder: 1 references (ImportJsValue.res:64:4) + Live (annotated) Value +ImportJsValue.+returnedFromHigherOrder: 0 references () + Dead VariantCase +ImportJsValue.variant.I: 0 references () + Dead VariantCase +ImportJsValue.variant.S: 0 references () + Live (annotated) Value +ImportJsValue.+convertVariant: 0 references () + Live (annotated) Value +ImportJsValue.+polymorphic: 0 references () + Live (annotated) Value +ImportJsValue.+default: 0 references () + Dead RecordLabel +ImportMyBanner.message.text: 0 references () + Live (annotated) Value +ImportMyBanner.+make: 1 references (ImportMyBanner.res:12:4) + Dead Value +ImportMyBanner.+make: 0 references () + Live (propagated) VariantCase +InnerModuleTypes.I.t.Foo: 1 references (InnerModuleTypes.resi:2:11) + Live (external ref) VariantCase InnerModuleTypes.I.t.Foo: 2 references (InnerModuleTypes.res:2:11, TestInnedModuleTypes.res:1:8) + Live (external ref) Value +JsxV4.C.+make: 1 references (JsxV4.res:7:9) + Live (annotated) Value +LetPrivate.local_1.+x: 1 references (LetPrivate.res:7:4) + Live (annotated) Value +LetPrivate.+y: 0 references () + Dead RecordLabel +ModuleAliases.Outer.Inner.innerT.inner: 0 references () + Dead RecordLabel +ModuleAliases.Outer2.Inner2.InnerNested.t.nested: 0 references () + Live (annotated) Value +ModuleAliases.+testNested: 0 references () + Live (annotated) Value +ModuleAliases.+testInner: 0 references () + Live (annotated) Value +ModuleAliases.+testInner2: 0 references () + Dead RecordLabel +ModuleAliases2.record.x: 0 references () + Dead RecordLabel +ModuleAliases2.record.y: 0 references () + Dead RecordLabel +ModuleAliases2.Outer.outer.outer: 0 references () + Dead RecordLabel +ModuleAliases2.Outer.Inner.inner.inner: 0 references () + Dead Value +ModuleAliases2.+q: 0 references () + Dead Value +ModuleExceptionBug.Dep.+customDouble: 0 references () + Dead Exception +ModuleExceptionBug.MyOtherException: 0 references () + Live (external ref) Value +ModuleExceptionBug.+ddjdj: 1 references (ModuleExceptionBug.res:8:7) + Live (annotated) Value +NestedModules.+notNested: 0 references () + Live (annotated) Value +NestedModules.Universe.+theAnswer: 0 references () + Dead Value +NestedModules.Universe.+notExported: 0 references () + Dead Value +NestedModules.Universe.Nested2.+x: 0 references () + Live (annotated) Value +NestedModules.Universe.Nested2.+nested2Value: 0 references () + Dead Value +NestedModules.Universe.Nested2.+y: 0 references () + Dead Value +NestedModules.Universe.Nested2.Nested3.+x: 0 references () + Dead Value +NestedModules.Universe.Nested2.Nested3.+y: 0 references () + Dead Value +NestedModules.Universe.Nested2.Nested3.+z: 0 references () + Dead Value +NestedModules.Universe.Nested2.Nested3.+w: 0 references () + Live (annotated) Value +NestedModules.Universe.Nested2.Nested3.+nested3Value: 0 references () + Live (annotated) Value +NestedModules.Universe.Nested2.Nested3.+nested3Function: 0 references () + Live (annotated) Value +NestedModules.Universe.Nested2.+nested2Function: 0 references () + Dead VariantCase +NestedModules.Universe.variant.A: 0 references () + Dead VariantCase +NestedModules.Universe.variant.B: 0 references () + Live (annotated) Value +NestedModules.Universe.+someString: 0 references () + Live (propagated) Value +NestedModulesInSignature.Universe.+theAnswer: 1 references (NestedModulesInSignature.resi:2:2) + Live (annotated) Value NestedModulesInSignature.Universe.+theAnswer: 0 references () + Dead Value +Newsyntax.+x: 0 references () + Dead Value +Newsyntax.+y: 0 references () + Dead RecordLabel +Newsyntax.record.xxx: 0 references () + Dead RecordLabel +Newsyntax.record.yyy: 0 references () + Dead VariantCase +Newsyntax.variant.A: 0 references () + Dead VariantCase +Newsyntax.variant.B: 0 references () + Dead VariantCase +Newsyntax.variant.C: 0 references () + Dead RecordLabel +Newsyntax.record2.xx: 0 references () + Dead RecordLabel +Newsyntax.record2.yy: 0 references () + Live (propagated) Value +Newton.+-: 4 references (Newton.res:9:8, Newton.res:16:8, Newton.res:25:4, Newton.res:27:4) + Live (propagated) Value +Newton.++: 1 references (Newton.res:25:4) + Live (propagated) Value +Newton.+*: 2 references (Newton.res:25:4, Newton.res:27:4) + Live (propagated) Value +Newton.+/: 1 references (Newton.res:16:8) + Live (propagated) Value +Newton.+newton: 1 references (Newton.res:29:4) + Live (propagated) Value +Newton.+current: 3 references (Newton.res:8:6, Newton.res:14:10, Newton.res:15:8) + Live (propagated) Value +Newton.+iterateMore: 1 references (Newton.res:14:10) + Live (propagated) Value +Newton.+delta: 1 references (Newton.res:8:6) + Live (propagated) Value +Newton.+loop: 2 references (Newton.res:6:4, Newton.res:14:10) + Live (propagated) Value +Newton.+previous: 2 references (Newton.res:14:10, Newton.res:16:8) + Live (propagated) Value +Newton.+next: 1 references (Newton.res:14:10) + Live (external ref) Value +Newton.+f: 2 references (Newton.res:29:4, Newton.res:31:16) + Live (propagated) Value +Newton.+fPrimed: 1 references (Newton.res:29:4) + Live (external ref) Value +Newton.+result: 2 references (Newton.res:31:8, Newton.res:31:18) + Dead VariantCase +Opaque.opaqueFromRecords.A: 0 references () + Live (annotated) Value +Opaque.+noConversion: 0 references () + Live (annotated) Value +Opaque.+testConvertNestedRecordFromOtherFile: 0 references () + Live (external ref) Value +OptArg.+foo: 2 references (OptArg.res:5:7, OptArg.resi:1:0) + Live (external ref) Value +OptArg.+bar: 2 references (OptArg.res:7:7, OptArg.resi:2:0) + Live (external ref) Value +OptArg.+threeArgs: 2 references (OptArg.res:11:7, OptArg.res:12:7) + Live (external ref) Value +OptArg.+twoArgs: 1 references (OptArg.res:16:10) + Live (propagated) Value +OptArg.+oneArg: 1 references (OptArg.res:20:4) + Live (external ref) Value +OptArg.+wrapOneArg: 1 references (OptArg.res:22:7) + Live (propagated) Value +OptArg.+fourArgs: 1 references (OptArg.res:26:4) + Live (external ref) Value +OptArg.+wrapfourArgs: 2 references (OptArg.res:28:7, OptArg.res:29:7) + Dead Value OptArg.+foo: 0 references () + Live (external ref) Value OptArg.+bar: 1 references (TestOptArg.res:1:7) + Live (propagated) Value +OptionalArgsLiveDead.+formatDate: 2 references (OptionalArgsLiveDead.res:3:4, OptionalArgsLiveDead.res:5:4) + Dead Value +OptionalArgsLiveDead.+deadCaller: 0 references () + Live (external ref) Value +OptionalArgsLiveDead.+liveCaller: 1 references (OptionalArgsLiveDead.res:7:8) + Live (external ref) RecordLabel +Records.coord.x: 1 references (Records.res:14:19) + Live (external ref) RecordLabel +Records.coord.y: 1 references (Records.res:14:19) + Live (external ref) RecordLabel +Records.coord.z: 1 references (Records.res:14:19) + Live (annotated) Value +Records.+origin: 0 references () + Live (annotated) Value +Records.+computeArea: 0 references () + Live (annotated) Value +Records.+coord2d: 0 references () + Dead RecordLabel +Records.person.name: 0 references () + Dead RecordLabel +Records.person.age: 0 references () + Live (external ref) RecordLabel +Records.person.address: 1 references (Records.res:51:42) + Dead RecordLabel +Records.business.name: 0 references () + Live (external ref) RecordLabel +Records.business.owner: 1 references (Records.res:51:6) + Live (external ref) RecordLabel +Records.business.address: 2 references (Records.res:40:2, Records.res:50:6) + Live (propagated) Value +Records.+getOpt: 3 references (Records.res:39:4, Records.res:46:4, Records.res:96:4) + Live (annotated) Value +Records.+findAddress: 0 references () + Live (annotated) Value +Records.+someBusiness: 0 references () + Live (annotated) Value +Records.+findAllAddresses: 0 references () + Dead RecordLabel +Records.payload.num: 0 references () + Live (external ref) RecordLabel +Records.payload.payload: 3 references (Records.res:65:18, Records.res:74:24, Records.res:83:31) + Live (annotated) Value +Records.+getPayload: 0 references () + Live (external ref) RecordLabel +Records.record.v: 1 references (Records.res:85:5) + Dead RecordLabel +Records.record.w: 0 references () + Live (annotated) Value +Records.+getPayloadRecord: 0 references () + Live (annotated) Value +Records.+recordValue: 1 references (Records.res:80:4) + Live (annotated) Value +Records.+payloadValue: 0 references () + Live (annotated) Value +Records.+getPayloadRecordPlusOne: 0 references () + Dead RecordLabel +Records.business2.name: 0 references () + Dead RecordLabel +Records.business2.owner: 0 references () + Live (external ref) RecordLabel +Records.business2.address2: 1 references (Records.res:97:2) + Live (annotated) Value +Records.+findAddress2: 0 references () + Live (annotated) Value +Records.+someBusiness2: 0 references () + Live (annotated) Value +Records.+computeArea3: 0 references () + Live (annotated) Value +Records.+computeArea4: 0 references () + Live (external ref) RecordLabel +Records.myRec.type_: 1 references (Records.res:127:30) + Live (annotated) Value +Records.+testMyRec: 0 references () + Live (annotated) Value +Records.+testMyRec2: 0 references () + Live (annotated) Value +Records.+testMyObj: 0 references () + Live (annotated) Value +Records.+testMyObj2: 0 references () + Live (external ref) RecordLabel +Records.myRecBsAs.type_: 1 references (Records.res:145:38) + Live (annotated) Value +Records.+testMyRecBsAs: 0 references () + Live (annotated) Value +Records.+testMyRecBsAs2: 0 references () + Live (annotated) Value +References.+create: 0 references () + Live (annotated) Value +References.+access: 0 references () + Live (annotated) Value +References.+update: 0 references () + Live (propagated) Value +References.R.+get: 1 references (References.res:31:4) + Live (propagated) Value +References.R.+make: 1 references (References.res:34:4) + Live (propagated) Value +References.R.+set: 1 references (References.res:37:4) + Live (propagated) Value +References.R.+get: 1 references (References.res:17:2) + Live (propagated) Value +References.R.+make: 1 references (References.res:18:2) + Live (propagated) Value +References.R.+set: 1 references (References.res:19:2) + Live (annotated) Value +References.+get: 0 references () + Live (annotated) Value +References.+make: 0 references () + Live (annotated) Value +References.+set: 0 references () + Dead RecordLabel +References.requiresConversion.x: 0 references () + Live (annotated) Value +References.+destroysRefIdentity: 0 references () + Live (annotated) Value +References.+preserveRefIdentity: 0 references () + Dead RecordLabel +RepeatedLabel.userData.a: 0 references () + Dead RecordLabel +RepeatedLabel.userData.b: 0 references () + Live (external ref) RecordLabel +RepeatedLabel.tabState.a: 1 references (RepeatedLabel.res:12:16) + Live (external ref) RecordLabel +RepeatedLabel.tabState.b: 1 references (RepeatedLabel.res:12:16) + Dead RecordLabel +RepeatedLabel.tabState.f: 0 references () + Live (external ref) Value +RepeatedLabel.+userData: 1 references (RepeatedLabel.res:14:7) + Live (annotated) Value +Shadow.+test: 0 references () + Live (annotated) Value +Shadow.+test: 0 references () + Live (annotated) Value +Shadow.M.+test: 0 references () + Dead Value +Shadow.M.+test: 0 references () + Live (annotated) Value +TestEmitInnerModules.Inner.+x: 0 references () + Live (annotated) Value +TestEmitInnerModules.Inner.+y: 0 references () + Live (annotated) Value +TestEmitInnerModules.Outer.Medium.Inner.+y: 0 references () + Live (annotated) Value +TestFirstClassModules.+convert: 0 references () + Live (annotated) Value +TestFirstClassModules.+convertInterface: 0 references () + Live (annotated) Value +TestFirstClassModules.+convertRecord: 0 references () + Live (annotated) Value +TestFirstClassModules.+convertFirstClassModuleWithTypeEquations: 0 references () + Live (annotated) Value +TestImmutableArray.+testImmutableArrayGet: 0 references () + Dead Value +TestImmutableArray.+testBeltArrayGet: 0 references () + Dead Value +TestImmutableArray.+testBeltArraySet: 0 references () + Live (annotated) Value +TestImport.+innerStuffContents: 1 references (TestImport.res:13:4) + Live (annotated) Value +TestImport.+innerStuffContentsAsEmptyObject: 0 references () + Dead Value +TestImport.+innerStuffContents: 0 references () + Live (annotated) Value +TestImport.+valueStartingWithUpperCaseLetter: 0 references () + Live (annotated) Value +TestImport.+defaultValue: 0 references () + Dead RecordLabel +TestImport.message.text: 0 references () + Live (annotated) Value +TestImport.+make: 1 references (TestImport.res:27:4) + Dead Value +TestImport.+make: 0 references () + Live (annotated) Value +TestImport.+defaultValue2: 0 references () + Dead Value +TestInnedModuleTypes.+_: 0 references () + Live (annotated) Value +TestModuleAliases.+testInner1: 0 references () + Live (annotated) Value +TestModuleAliases.+testInner1Expanded: 0 references () + Live (annotated) Value +TestModuleAliases.+testInner2: 0 references () + Live (annotated) Value +TestModuleAliases.+testInner2Expanded: 0 references () + Live (propagated) Value +TestOptArg.+foo: 1 references (TestOptArg.res:5:4) + Live (external ref) Value +TestOptArg.+bar: 1 references (TestOptArg.res:7:7) + Live (external ref) Value +TestOptArg.+notSuppressesOptArgs: 1 references (TestOptArg.res:11:8) + Live (annotated) Value +TestOptArg.+liveSuppressesOptArgs: 1 references (TestOptArg.res:16:8) + Dead RecordLabel +TestPromise.fromPayload.x: 0 references () + Live (external ref) RecordLabel +TestPromise.fromPayload.s: 1 references (TestPromise.res:14:32) + Dead RecordLabel +TestPromise.toPayload.result: 0 references () + Live (annotated) Value +TestPromise.+convert: 0 references () + Dead Value +ToSuppress.+toSuppress: 0 references () + Live (annotated) Value +TransitiveType1.+convert: 0 references () + Live (annotated) Value +TransitiveType1.+convertAlias: 0 references () + Dead Value +TransitiveType2.+convertT2: 0 references () + Dead RecordLabel +TransitiveType3.t3.i: 0 references () + Dead RecordLabel +TransitiveType3.t3.s: 0 references () + Live (annotated) Value +TransitiveType3.+convertT3: 0 references () + Live (annotated) Value +Tuples.+testTuple: 0 references () + Live (annotated) Value +Tuples.+origin: 0 references () + Live (annotated) Value +Tuples.+computeArea: 0 references () + Live (annotated) Value +Tuples.+computeAreaWithIdent: 0 references () + Live (annotated) Value +Tuples.+computeAreaNoConverters: 0 references () + Live (annotated) Value +Tuples.+coord2d: 0 references () + Live (external ref) RecordLabel +Tuples.person.name: 1 references (Tuples.res:43:49) + Live (external ref) RecordLabel +Tuples.person.age: 1 references (Tuples.res:49:84) + Live (annotated) Value +Tuples.+getFirstName: 0 references () + Live (annotated) Value +Tuples.+marry: 0 references () + Live (annotated) Value +Tuples.+changeSecondAge: 0 references () + Dead Value +TypeParams1.+exportSomething: 0 references () + Dead RecordLabel +TypeParams2.item.id: 0 references () + Dead Value +TypeParams2.+exportSomething: 0 references () + Live (annotated) Value +TypeParams3.+test: 0 references () + Live (annotated) Value +TypeParams3.+test2: 0 references () + Live (annotated) Value +Types.+someIntList: 0 references () + Live (annotated) Value +Types.+map: 0 references () + Dead VariantCase +Types.typeWithVars.A: 0 references () + Dead VariantCase +Types.typeWithVars.B: 0 references () + Live (annotated) Value +Types.+swap: 1 references (Types.res:23:8) + Live (external ref) RecordLabel +Types.selfRecursive.self: 1 references (Types.res:42:30) + Live (external ref) RecordLabel +Types.mutuallyRecursiveA.b: 1 references (Types.res:49:34) + Dead RecordLabel +Types.mutuallyRecursiveB.a: 0 references () + Live (annotated) Value +Types.+selfRecursiveConverter: 0 references () + Live (annotated) Value +Types.+mutuallyRecursiveConverter: 0 references () + Live (annotated) Value +Types.+testFunctionOnOptionsAsArgument: 0 references () + Dead VariantCase +Types.opaqueVariant.A: 0 references () + Dead VariantCase +Types.opaqueVariant.B: 0 references () + Live (annotated) Value +Types.+jsStringT: 0 references () + Live (annotated) Value +Types.+jsString2T: 0 references () + Live (annotated) Value +Types.+jsonStringify: 0 references () + Dead RecordLabel +Types.record.i: 0 references () + Dead RecordLabel +Types.record.s: 0 references () + Live (annotated) Value +Types.+testConvertNull: 0 references () + Live (annotated) Value +Types.+testMarshalFields: 0 references () + Live (annotated) Value +Types.+setMatch: 0 references () + Dead RecordLabel +Types.someRecord.id: 0 references () + Live (annotated) Value +Types.+testInstantiateTypeParameter: 0 references () + Live (annotated) Value +Types.+currentTime: 0 references () + Live (annotated) Value +Types.+i64Const: 0 references () + Live (annotated) Value +Types.+optFunction: 0 references () + Dead Value +Types.ObjectId.+x: 0 references () + Dead VariantCase +Unboxed.v1.A: 0 references () + Dead VariantCase +Unboxed.v2.A: 0 references () + Live (annotated) Value +Unboxed.+testV1: 0 references () + Dead RecordLabel +Unboxed.r1.x: 0 references () + Dead VariantCase +Unboxed.r2.B: 0 references () + Dead RecordLabel +Unboxed.r2.B.g: 0 references () + Live (annotated) Value +Unboxed.+r2Test: 0 references () + Live (annotated) Value +Uncurried.+uncurried0: 0 references () + Live (annotated) Value +Uncurried.+uncurried1: 0 references () + Live (annotated) Value +Uncurried.+uncurried2: 0 references () + Live (annotated) Value +Uncurried.+uncurried3: 0 references () + Live (annotated) Value +Uncurried.+curried3: 0 references () + Live (annotated) Value +Uncurried.+callback: 0 references () + Live (external ref) RecordLabel +Uncurried.auth.login: 1 references (Uncurried.res:35:24) + Live (external ref) RecordLabel +Uncurried.authU.loginU: 1 references (Uncurried.res:38:25) + Live (annotated) Value +Uncurried.+callback2: 0 references () + Live (annotated) Value +Uncurried.+callback2U: 0 references () + Live (annotated) Value +Uncurried.+sumU: 0 references () + Live (annotated) Value +Uncurried.+sumU2: 0 references () + Live (annotated) Value +Uncurried.+sumCurried: 0 references () + Live (annotated) Value +Uncurried.+sumLblCurried: 0 references () + Live (external ref) VariantCase +Unison.break.IfNeed: 1 references (Unison.res:17:20) + Live (external ref) VariantCase +Unison.break.Never: 1 references (Unison.res:38:38) + Live (external ref) VariantCase +Unison.break.Always: 1 references (Unison.res:39:38) + Live (external ref) RecordLabel +Unison.t.break: 1 references (Unison.res:28:9) + Live (external ref) RecordLabel +Unison.t.doc: 2 references (Unison.res:23:9, Unison.res:28:9) + Live (external ref) VariantCase +Unison.stack.Empty: 3 references (Unison.res:37:20, Unison.res:38:53, Unison.res:39:52) + Live (external ref) VariantCase +Unison.stack.Cons: 2 references (Unison.res:38:20, Unison.res:39:20) + Live (external ref) Value +Unison.+group: 2 references (Unison.res:38:25, Unison.res:39:25) + Live (propagated) Value +Unison.+fits: 2 references (Unison.res:19:8, Unison.res:26:8) + Live (external ref) Value +Unison.+toString: 4 references (Unison.res:26:8, Unison.res:37:0, Unison.res:38:0, Unison.res:39:0) + Live (annotated) Value +UseImportJsValue.+useGetProp: 0 references () + Live (annotated) Value +UseImportJsValue.+useTypeImportedInOtherModule: 0 references () + Live (annotated) Value +Variants.+isWeekend: 0 references () + Live (annotated) Value +Variants.+monday: 0 references () + Live (annotated) Value +Variants.+saturday: 0 references () + Live (annotated) Value +Variants.+sunday: 0 references () + Live (annotated) Value +Variants.+onlySunday: 0 references () + Live (annotated) Value +Variants.+swap: 0 references () + Live (annotated) Value +Variants.+testConvert: 0 references () + Live (annotated) Value +Variants.+fortytwoOK: 0 references () + Live (annotated) Value +Variants.+fortytwoBAD: 0 references () + Live (annotated) Value +Variants.+testConvert2: 0 references () + Live (annotated) Value +Variants.+testConvert3: 0 references () + Live (annotated) Value +Variants.+testConvert2to3: 0 references () + Live (annotated) Value +Variants.+id1: 0 references () + Live (annotated) Value +Variants.+id2: 0 references () + Dead VariantCase +Variants.type_.Type: 0 references () + Live (annotated) Value +Variants.+polyWithOpt: 0 references () + Dead VariantCase +Variants.result1.Ok: 0 references () + Dead VariantCase +Variants.result1.Error: 0 references () + Live (annotated) Value +Variants.+restResult1: 0 references () + Live (annotated) Value +Variants.+restResult2: 0 references () + Live (annotated) Value +Variants.+restResult3: 0 references () + Live (external ref) RecordLabel +VariantsWithPayload.payload.x: 2 references (VariantsWithPayload.res:26:57, VariantsWithPayload.res:44:55) + Live (external ref) RecordLabel +VariantsWithPayload.payload.y: 2 references (VariantsWithPayload.res:26:74, VariantsWithPayload.res:44:72) + Live (annotated) Value +VariantsWithPayload.+testWithPayload: 0 references () + Live (annotated) Value +VariantsWithPayload.+printVariantWithPayload: 0 references () + Live (annotated) Value +VariantsWithPayload.+testManyPayloads: 0 references () + Live (annotated) Value +VariantsWithPayload.+printManyPayloads: 0 references () + Dead VariantCase +VariantsWithPayload.simpleVariant.A: 0 references () + Dead VariantCase +VariantsWithPayload.simpleVariant.B: 0 references () + Dead VariantCase +VariantsWithPayload.simpleVariant.C: 0 references () + Live (annotated) Value +VariantsWithPayload.+testSimpleVariant: 0 references () + Dead VariantCase +VariantsWithPayload.variantWithPayloads.A: 0 references () + Dead VariantCase +VariantsWithPayload.variantWithPayloads.B: 0 references () + Dead VariantCase +VariantsWithPayload.variantWithPayloads.C: 0 references () + Dead VariantCase +VariantsWithPayload.variantWithPayloads.D: 0 references () + Dead VariantCase +VariantsWithPayload.variantWithPayloads.E: 0 references () + Live (annotated) Value +VariantsWithPayload.+testVariantWithPayloads: 0 references () + Live (annotated) Value +VariantsWithPayload.+printVariantWithPayloads: 0 references () + Dead VariantCase +VariantsWithPayload.variant1Int.R: 0 references () + Live (annotated) Value +VariantsWithPayload.+testVariant1Int: 0 references () + Dead VariantCase +VariantsWithPayload.variant1Object.R: 0 references () + Live (annotated) Value +VariantsWithPayload.+testVariant1Object: 0 references () Incorrect Dead Annotation DeadTest.res:153:1-28 From 30d331011494119abd51176153a85978d8dcd659 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 12:47:56 +0100 Subject: [PATCH 11/45] Remove refs_to storage, compute lazily only when needed Removes refs_to direction from core data structures. Now only refs_from is stored, which is what the forward liveness algorithm needs. **Removed from storage:** - References.builder/t: removed value_refs_to, type_refs_to hashtables - ReactiveMerge.t: removed value_refs, type_refs fields - ReferenceStore: removed find_value_refs, find_type_refs functions **Added lazy computation:** - DeadCommon.RefsToLazy: computes refs_to by inverting refs_from - Only computed when debug OR transitive mode is enabled - Zero cost in the common case (no storage, no computation) Cost analysis: - Common case (no debug, no transitive): 0 storage, 0 computation - Debug or transitive enabled: O(refs) one-time cost at reporting This simplifies the reactive architecture - it now only deals with refs_from direction, matching the forward liveness algorithm. Signed-Off-By: Cristiano Calcagno --- analysis/reanalyze/ARCHITECTURE.md | 16 +-- .../reanalyze/diagrams/reactive-pipeline.mmd | 22 ++- .../reanalyze/diagrams/reactive-pipeline.svg | 2 +- analysis/reanalyze/src/DeadCommon.ml | 126 ++++++++++++------ analysis/reanalyze/src/ReactiveMerge.ml | 105 ++++----------- analysis/reanalyze/src/ReactiveMerge.mli | 8 +- analysis/reanalyze/src/Reanalyze.ml | 12 +- analysis/reanalyze/src/ReferenceStore.ml | 63 ++------- analysis/reanalyze/src/ReferenceStore.mli | 12 +- analysis/reanalyze/src/References.ml | 77 ++--------- analysis/reanalyze/src/References.mli | 45 ++----- 11 files changed, 181 insertions(+), 307 deletions(-) diff --git a/analysis/reanalyze/ARCHITECTURE.md b/analysis/reanalyze/ARCHITECTURE.md index b649087b60..5bc5ad4e50 100644 --- a/analysis/reanalyze/ARCHITECTURE.md +++ b/analysis/reanalyze/ARCHITECTURE.md @@ -34,7 +34,7 @@ This design enables: | `DceFileProcessing.file_data` | Per-file collected data | Builders (mutable during AST walk) | | `FileAnnotations.t` | Source annotations (`@dead`, `@live`) | Immutable after merge | | `Declarations.t` | All exported declarations (pos → Decl.t) | Immutable after merge | -| `References.t` | Value/type references (pos → PosSet.t) | Immutable after merge | +| `References.t` | Value/type references (source → targets) | Immutable after merge | | `FileDeps.t` | Cross-file dependencies (file → FileSet.t) | Immutable after merge | | `OptionalArgsState.t` | Computed optional arg state per-decl | Immutable | | `AnalysisResult.t` | Solver output with Issue.t list | Immutable | @@ -173,16 +173,14 @@ The reactive layer (`analysis/reactive/`) provides delta-based incremental updat | **FD** | `file_data` | `path → file_data option` | | **D** | `decls` | `pos → Decl.t` | | **A** | `annotations` | `pos → annotation` | -| **VR→** | `value_refs` | `pos → PosSet` (refs_to: target → sources) | -| **TR→** | `type_refs` | `pos → PosSet` (refs_to: target → sources) | -| **VR←** | `value_refs_from` | `pos → PosSet` (refs_from: source → targets) | -| **TR←** | `type_refs_from` | `pos → PosSet` (refs_from: source → targets) | +| **VR** | `value_refs_from` | `pos → PosSet` (source → targets) | +| **TR** | `type_refs_from` | `pos → PosSet` (source → targets) | | **CFI** | `cross_file_items` | `path → CrossFileItems.t` | | **DBP** | `decl_by_path` | `path → decl_info list` | -| **ATR←** | `all_type_refs_from` | Combined type refs (refs_from direction) | +| **ATR** | `all_type_refs_from` | Combined type refs | | **ER** | `exception_refs` | Exception references | | **ED** | `exception_decls` | Exception declarations | -| **RR←** | `resolved_refs` | Resolved exception refs (refs_from direction) | +| **RR** | `resolved_refs_from` | Resolved exception refs | | **DR** | `decl_refs` | `pos → (value_targets, type_targets)` | | **roots** | Root declarations | `@live`/`@genType` or externally referenced | | **edges** | Reference graph | Declaration → referenced declarations | @@ -212,7 +210,7 @@ The reactive layer (`analysis/reactive/`) provides delta-based incremental updat | `Reactive` | Core primitives: `flatMap`, `join`, `union`, `fixpoint`, delta types | | `ReactiveFileCollection` | File-backed collection with change detection | | `ReactiveAnalysis` | CMT processing with file caching | -| `ReactiveMerge` | Derives decls, annotations, refs (both directions) from file_data | +| `ReactiveMerge` | Derives decls, annotations, refs from file_data | | `ReactiveTypeDeps` | Type-label dependency resolution, produces `all_type_refs_from` | | `ReactiveExceptionRefs` | Exception ref resolution via join | | `ReactiveDeclRefs` | Maps declarations to their outgoing references | @@ -241,7 +239,7 @@ The reactive layer (`analysis/reactive/`) provides delta-based incremental updat | `DeadCommon` | Phase 3: Solver (`solveDead`, `solveDeadReactive`) | | `Liveness` | Forward fixpoint liveness computation | | `Declarations` | Declaration storage (builder/immutable) | -| `References` | Reference tracking (both refs_to and refs_from directions) | +| `References` | Reference tracking (source → targets) | | `FileAnnotations` | Source annotation tracking | | `FileDeps` | Cross-file dependency graph | | `CrossFileItems` | Cross-file optional args and exceptions | diff --git a/analysis/reanalyze/diagrams/reactive-pipeline.mmd b/analysis/reanalyze/diagrams/reactive-pipeline.mmd index 5e9cb1fa5e..4646c40fc3 100644 --- a/analysis/reanalyze/diagrams/reactive-pipeline.mmd +++ b/analysis/reanalyze/diagrams/reactive-pipeline.mmd @@ -11,22 +11,20 @@ flowchart TB subgraph Extracted["Extracted (ReactiveMerge)"] DECLS["D"] ANNOT["A"] - VREFS_TO["VR→"] - TREFS_TO["TR→"] - VREFS_FROM["VR←"] - TREFS_FROM["TR←"] + VREFS["VR"] + TREFS["TR"] CFI["CFI"] end subgraph TypeDeps["ReactiveTypeDeps"] DBP["DBP"] - ATR["ATR←"] + ATR["ATR"] end subgraph ExcDeps["ReactiveExceptionRefs"] EXCREF["ER"] EXCDECL["ED"] - RESOLVED["RR←"] + RESOLVED["RR"] end subgraph DeclRefs["ReactiveDeclRefs"] @@ -43,10 +41,8 @@ flowchart TB RFC -->|"process"| FD FD -->|"flatMap"| DECLS FD -->|"flatMap"| ANNOT - FD -->|"flatMap"| VREFS_TO - FD -->|"flatMap"| TREFS_TO - FD -->|"flatMap"| VREFS_FROM - FD -->|"flatMap"| TREFS_FROM + FD -->|"flatMap"| VREFS + FD -->|"flatMap"| TREFS FD -->|"flatMap"| CFI DECLS -->|"flatMap"| DBP @@ -58,8 +54,8 @@ flowchart TB EXCDECL -->|"join"| RESOLVED DECLS --> DR - VREFS_FROM --> DR - TREFS_FROM --> DR + VREFS --> DR + TREFS --> DR ATR --> DR RESOLVED --> DR @@ -80,7 +76,7 @@ flowchart TB classDef output fill:#e6ffe6,stroke:#2e8b2e,stroke-width:2px class RFC,FD fileLayer - class DECLS,ANNOT,VREFS_TO,TREFS_TO,VREFS_FROM,TREFS_FROM,CFI extracted + class DECLS,ANNOT,VREFS,TREFS,CFI extracted class DBP,ATR typeDeps class EXCREF,EXCDECL,RESOLVED excDeps class DR declRefs diff --git a/analysis/reanalyze/diagrams/reactive-pipeline.svg b/analysis/reanalyze/diagrams/reactive-pipeline.svg index 9ff00a23b0..c93c9e0d61 100644 --- a/analysis/reanalyze/diagrams/reactive-pipeline.svg +++ b/analysis/reanalyze/diagrams/reactive-pipeline.svg @@ -1 +1 @@ -

ReactiveLiveness

ReactiveDeclRefs

ReactiveExceptionRefs

ReactiveTypeDeps

Extracted (ReactiveMerge)

Per-File Data

File Layer

process

flatMap

flatMap

flatMap

flatMap

flatMap

flatMap

flatMap

flatMap

union+flatMap

flatMap

flatMap

join

join

flatMap

fixpoint

RFC

FD

D

A

VR→

TR→

VR←

TR←

CFI

DBP

ATR←

ER

ED

RR←

DR

roots

edges

fixpoint

LIVE

\ No newline at end of file +

ReactiveLiveness

ReactiveDeclRefs

ReactiveExceptionRefs

ReactiveTypeDeps

Extracted (ReactiveMerge)

Per-File Data

File Layer

process

flatMap

flatMap

flatMap

flatMap

flatMap

flatMap

union+flatMap

flatMap

flatMap

join

join

flatMap

fixpoint

RFC

FD

D

A

VR

TR

CFI

DBP

ATR

ER

ED

RR

DR

roots

edges

fixpoint

LIVE

\ No newline at end of file diff --git a/analysis/reanalyze/src/DeadCommon.ml b/analysis/reanalyze/src/DeadCommon.ml index 4e98347b85..bcf9a73342 100644 --- a/analysis/reanalyze/src/DeadCommon.ml +++ b/analysis/reanalyze/src/DeadCommon.ml @@ -55,6 +55,30 @@ end (* NOTE: Global TypeReferences removed - now using References.builder/t pattern *) +(** Lazy computation of refs_to from refs_from. + Only computed when debug or transitive mode is enabled. + Zero cost in the common case. *) +module RefsToLazy = struct + type t = {value_refs_to: PosSet.t PosHash.t; type_refs_to: PosSet.t PosHash.t} + + (** Compute refs_to by inverting refs_from. O(total_refs) one-time cost. *) + let compute (refs : References.t) : t = + let value_refs_to = PosHash.create 256 in + let type_refs_to = PosHash.create 256 in + References.iter_value_refs_from refs (fun posFrom posToSet -> + PosSet.iter + (fun posTo -> posHashAddSet value_refs_to posTo posFrom) + posToSet); + References.iter_type_refs_from refs (fun posFrom posToSet -> + PosSet.iter + (fun posTo -> posHashAddSet type_refs_to posTo posFrom) + posToSet); + {value_refs_to; type_refs_to} + + let find_value_refs (t : t) pos = posHashFindSet t.value_refs_to pos + let find_type_refs (t : t) pos = posHashFindSet t.type_refs_to pos +end + let declGetLoc decl = let loc_start = let offset = @@ -155,8 +179,9 @@ let isInsideReportedValue (ctx : ReportingContext.t) decl = insideReportedValue (** Report a dead declaration. Returns list of issues (dead module first, then dead value). - Caller is responsible for logging. *) -let reportDeclaration ~config ~ref_store (ctx : ReportingContext.t) decl : + [refs_to_opt] is only needed when [config.run.transitive] is false. + Caller is responsible for logging. *) +let reportDeclaration ~config ~refs_to_opt (ctx : ReportingContext.t) decl : Issue.t list = let insideReportedValue = decl |> isInsideReportedValue ctx in if not decl.report then [] @@ -191,15 +216,18 @@ let reportDeclaration ~config ~ref_store (ctx : ReportingContext.t) decl : (WarningDeadType, "is a variant case which is never constructed") in let hasRefBelow () = - let decl_refs = ReferenceStore.find_value_refs ref_store decl.pos in - let refIsBelow (pos : Lexing.position) = - decl.pos.pos_fname <> pos.pos_fname - || decl.pos.pos_cnum < pos.pos_cnum - && - (* not a function defined inside a function, e.g. not a callback *) - decl.posEnd.pos_cnum < pos.pos_cnum - in - decl_refs |> PosSet.exists refIsBelow + match refs_to_opt with + | None -> false (* No refs_to available, assume no ref below *) + | Some refs_to -> + let decl_refs = RefsToLazy.find_value_refs refs_to decl.pos in + let refIsBelow (pos : Lexing.position) = + decl.pos.pos_fname <> pos.pos_fname + || decl.pos.pos_cnum < pos.pos_cnum + && + (* not a function defined inside a function, e.g. not a callback *) + decl.posEnd.pos_cnum < pos.pos_cnum + in + decl_refs |> PosSet.exists refIsBelow in let shouldEmitWarning = (not insideReportedValue) @@ -236,8 +264,14 @@ let solveDeadForward ~ann_store ~config ~decl_store ~refs ~optional_args_state Issue.t list) : AnalysisResult.t = (* Compute liveness using forward propagation *) let debug = config.DceConfig.cli.debug in + let transitive = config.DceConfig.run.transitive in let live = Liveness.compute_forward ~debug ~decl_store ~refs ~ann_store in + (* Lazily compute refs_to only if needed for debug or transitive *) + let refs_to_opt = + if debug || not transitive then Some (RefsToLazy.compute refs) else None + in + (* Process each declaration based on computed liveness *) let deadDeclarations = ref [] in let inline_issues = ref [] in @@ -257,23 +291,26 @@ let solveDeadForward ~ann_store ~config ~decl_store ~refs ~optional_args_state (* Debug output with reason *) (if debug then - let refs_set = - match decl |> Decl.isValue with - | true -> References.find_value_refs refs pos - | false -> References.find_type_refs refs pos - in - let status = - match live_reason with - | None -> "Dead" - | Some reason -> - Printf.sprintf "Live (%s)" (Liveness.reason_to_string reason) - in - Log_.item "%s %s %s: %d references (%s)@." status - (decl.declKind |> Decl.Kind.toString) - (decl.path |> DcePath.toString) - (refs_set |> PosSet.cardinal) - (refs_set |> PosSet.elements |> List.map Pos.toString - |> String.concat ", ")); + match refs_to_opt with + | Some refs_to -> + let refs_set = + match decl |> Decl.isValue with + | true -> RefsToLazy.find_value_refs refs_to pos + | false -> RefsToLazy.find_type_refs refs_to pos + in + let status = + match live_reason with + | None -> "Dead" + | Some reason -> + Printf.sprintf "Live (%s)" (Liveness.reason_to_string reason) + in + Log_.item "%s %s %s: %d references (%s)@." status + (decl.declKind |> Decl.Kind.toString) + (decl.path |> DcePath.toString) + (refs_set |> PosSet.cardinal) + (refs_set |> PosSet.elements |> List.map Pos.toString + |> String.concat ", ") + | None -> ()); decl.resolvedDead <- Some is_dead; @@ -314,15 +351,13 @@ let solveDeadForward ~ann_store ~config ~decl_store ~refs ~optional_args_state let dead_issues = sortedDeadDeclarations |> List.concat_map (fun decl -> - reportDeclaration ~config - ~ref_store:(ReferenceStore.of_frozen refs) - reporting_ctx decl) + reportDeclaration ~config ~refs_to_opt reporting_ctx decl) in let all_issues = List.rev !inline_issues @ dead_issues in AnalysisResult.add_issues AnalysisResult.empty all_issues (** Reactive solver using reactive liveness collection. *) -let solveDeadReactive ~ann_store ~config ~decl_store ~ref_store +let solveDeadReactive ~ann_store ~config ~decl_store ~refs ~(live : (Lexing.position, unit) Reactive.t) ~optional_args_state ~checkOptionalArg: (checkOptionalArgFn : @@ -332,8 +367,14 @@ let solveDeadReactive ~ann_store ~config ~decl_store ~ref_store Decl.t -> Issue.t list) : AnalysisResult.t = let debug = config.DceConfig.cli.debug in + let transitive = config.DceConfig.run.transitive in let is_live pos = Reactive.get live pos <> None in + (* Lazily compute refs_to only if needed for debug or transitive *) + let refs_to_opt = + if debug || not transitive then Some (RefsToLazy.compute refs) else None + in + (* Process each declaration based on computed liveness *) let deadDeclarations = ref [] in let inline_issues = ref [] in @@ -352,14 +393,17 @@ let solveDeadReactive ~ann_store ~config ~decl_store ~ref_store (* Debug output *) (if debug then - let refs_set = ReferenceStore.find_value_refs ref_store pos in - let status = if is_live then "Live" else "Dead" in - Log_.item "%s %s %s: %d references (%s)@." status - (decl.declKind |> Decl.Kind.toString) - (decl.path |> DcePath.toString) - (refs_set |> PosSet.cardinal) - (refs_set |> PosSet.elements |> List.map Pos.toString - |> String.concat ", ")); + match refs_to_opt with + | Some refs_to -> + let refs_set = RefsToLazy.find_value_refs refs_to pos in + let status = if is_live then "Live" else "Dead" in + Log_.item "%s %s %s: %d references (%s)@." status + (decl.declKind |> Decl.Kind.toString) + (decl.path |> DcePath.toString) + (refs_set |> PosSet.cardinal) + (refs_set |> PosSet.elements |> List.map Pos.toString + |> String.concat ", ") + | None -> ()); decl.resolvedDead <- Some is_dead; @@ -400,7 +444,7 @@ let solveDeadReactive ~ann_store ~config ~decl_store ~ref_store let dead_issues = sortedDeadDeclarations |> List.concat_map (fun decl -> - reportDeclaration ~config ~ref_store reporting_ctx decl) + reportDeclaration ~config ~refs_to_opt reporting_ctx decl) in let all_issues = List.rev !inline_issues @ dead_issues in AnalysisResult.add_issues AnalysisResult.empty all_issues diff --git a/analysis/reanalyze/src/ReactiveMerge.ml b/analysis/reanalyze/src/ReactiveMerge.ml index 7b11e3933e..7eeab160ec 100644 --- a/analysis/reanalyze/src/ReactiveMerge.ml +++ b/analysis/reanalyze/src/ReactiveMerge.ml @@ -8,8 +8,6 @@ type t = { decls: (Lexing.position, Decl.t) Reactive.t; annotations: (Lexing.position, FileAnnotations.annotated_as) Reactive.t; - value_refs: (Lexing.position, PosSet.t) Reactive.t; - type_refs: (Lexing.position, PosSet.t) Reactive.t; value_refs_from: (Lexing.position, PosSet.t) Reactive.t; type_refs_from: (Lexing.position, PosSet.t) Reactive.t; cross_file_items: (string, CrossFileItems.t) Reactive.t; @@ -48,28 +46,6 @@ let create (source : (string, DceFileProcessing.file_data option) Reactive.t) : () in - (* Value refs: (posTo, PosSet) with PosSet.union merge *) - let value_refs = - Reactive.flatMap source - ~f:(fun _path file_data_opt -> - match file_data_opt with - | None -> [] - | Some file_data -> - References.builder_value_refs_to_list file_data.DceFileProcessing.refs) - ~merge:PosSet.union () - in - - (* Type refs: (posTo, PosSet) with PosSet.union merge *) - let type_refs = - Reactive.flatMap source - ~f:(fun _path file_data_opt -> - match file_data_opt with - | None -> [] - | Some file_data -> - References.builder_type_refs_to_list file_data.DceFileProcessing.refs) - ~merge:PosSet.union () - in - (* Value refs_from: (posFrom, PosSet of targets) with PosSet.union merge *) let value_refs_from = Reactive.flatMap source @@ -167,8 +143,6 @@ let create (source : (string, DceFileProcessing.file_data option) Reactive.t) : { decls; annotations; - value_refs; - type_refs; value_refs_from; type_refs_from; cross_file_items; @@ -193,25 +167,12 @@ let freeze_annotations (t : t) : FileAnnotations.t = FileAnnotations.create_from_hashtbl result (** Convert reactive refs to References.t for solver. - Includes type-label deps and exception refs from reactive computations. - Builds both refs_to and refs_from directions. *) + Includes type-label deps and exception refs from reactive computations. *) let freeze_refs (t : t) : References.t = - let value_refs_to = PosHash.create 256 in - let type_refs_to = PosHash.create 256 in let value_refs_from = PosHash.create 256 in let type_refs_from = PosHash.create 256 in - (* Helper to merge refs into refs_to hashtable *) - let merge_into_to tbl posTo posFromSet = - let existing = - match PosHash.find_opt tbl posTo with - | Some s -> s - | None -> PosSet.empty - in - PosHash.replace tbl posTo (PosSet.union existing posFromSet) - in - - (* Helper to add to refs_from hashtable (inverse direction) *) + (* Helper to add to refs_from hashtable *) let add_to_from tbl posFrom posTo = let existing = match PosHash.find_opt tbl posFrom with @@ -221,50 +182,42 @@ let freeze_refs (t : t) : References.t = PosHash.replace tbl posFrom (PosSet.add posTo existing) in - (* Merge and invert per-file value refs *) + (* Merge per-file value refs_from *) Reactive.iter - (fun posTo posFromSet -> - merge_into_to value_refs_to posTo posFromSet; + (fun posFrom posToSet -> PosSet.iter - (fun posFrom -> add_to_from value_refs_from posFrom posTo) - posFromSet) - t.value_refs; + (fun posTo -> add_to_from value_refs_from posFrom posTo) + posToSet) + t.value_refs_from; - (* Merge and invert per-file type refs *) + (* Merge per-file type refs_from *) Reactive.iter - (fun posTo posFromSet -> - merge_into_to type_refs_to posTo posFromSet; + (fun posFrom posToSet -> PosSet.iter - (fun posFrom -> add_to_from type_refs_from posFrom posTo) - posFromSet) - t.type_refs; + (fun posTo -> add_to_from type_refs_from posFrom posTo) + posToSet) + t.type_refs_from; - (* Add and invert type-label dependency refs from all sources *) - let add_type_refs reactive = + (* Add type-label dependency refs from all sources *) + let add_type_refs_from reactive = Reactive.iter - (fun posTo posFromSet -> - merge_into_to type_refs_to posTo posFromSet; + (fun posFrom posToSet -> PosSet.iter - (fun posFrom -> add_to_from type_refs_from posFrom posTo) - posFromSet) + (fun posTo -> add_to_from type_refs_from posFrom posTo) + posToSet) reactive in - add_type_refs t.type_deps.same_path_refs; - add_type_refs t.type_deps.cross_file_refs; - add_type_refs t.type_deps.impl_to_intf_refs_path2; - add_type_refs t.type_deps.intf_to_impl_refs; + add_type_refs_from t.type_deps.all_type_refs_from; - (* Add and invert exception refs (to value refs) *) + (* Add exception refs (to value refs_from) *) Reactive.iter - (fun posTo posFromSet -> - merge_into_to value_refs_to posTo posFromSet; + (fun posFrom posToSet -> PosSet.iter - (fun posFrom -> add_to_from value_refs_from posFrom posTo) - posFromSet) - t.exception_refs.resolved_refs; + (fun posTo -> add_to_from value_refs_from posFrom posTo) + posToSet) + t.exception_refs.resolved_refs_from; - References.create ~value_refs_to ~type_refs_to ~value_refs_from - ~type_refs_from + References.create ~value_refs_from ~type_refs_from (** Collect all cross-file items *) let collect_cross_file_items (t : t) : CrossFileItems.t = @@ -297,11 +250,11 @@ let freeze_file_deps (t : t) : FileDeps.t = (fun from_file to_files -> FileDeps.FileHash.replace deps from_file to_files) t.file_deps_map; - (* Add file deps from exception refs *) + (* Add file deps from exception refs - iterate value_refs_from *) Reactive.iter - (fun posTo posFromSet -> + (fun posFrom posToSet -> PosSet.iter - (fun posFrom -> + (fun posTo -> let from_file = posFrom.Lexing.pos_fname in let to_file = posTo.Lexing.pos_fname in if from_file <> to_file then @@ -312,6 +265,6 @@ let freeze_file_deps (t : t) : FileDeps.t = in FileDeps.FileHash.replace deps from_file (FileSet.add to_file existing)) - posFromSet) - t.exception_refs.resolved_refs; + posToSet) + t.exception_refs.resolved_refs_from; FileDeps.create ~files ~deps diff --git a/analysis/reanalyze/src/ReactiveMerge.mli b/analysis/reanalyze/src/ReactiveMerge.mli index 7fa54c81ef..181c37a695 100644 --- a/analysis/reanalyze/src/ReactiveMerge.mli +++ b/analysis/reanalyze/src/ReactiveMerge.mli @@ -27,14 +27,10 @@ type t = { decls: (Lexing.position, Decl.t) Reactive.t; annotations: (Lexing.position, FileAnnotations.annotated_as) Reactive.t; - value_refs: (Lexing.position, PosSet.t) Reactive.t; - (** Value refs in refs_to direction: target -> sources *) - type_refs: (Lexing.position, PosSet.t) Reactive.t; - (** Type refs in refs_to direction: target -> sources *) value_refs_from: (Lexing.position, PosSet.t) Reactive.t; - (** Value refs in refs_from direction: source -> targets *) + (** Value refs: source -> targets *) type_refs_from: (Lexing.position, PosSet.t) Reactive.t; - (** Type refs in refs_from direction: source -> targets *) + (** Type refs: source -> targets *) cross_file_items: (string, CrossFileItems.t) Reactive.t; file_deps_map: (string, FileSet.t) Reactive.t; files: (string, unit) Reactive.t; diff --git a/analysis/reanalyze/src/Reanalyze.ml b/analysis/reanalyze/src/Reanalyze.ml index 6898d55151..c6da3a1998 100644 --- a/analysis/reanalyze/src/Reanalyze.ml +++ b/analysis/reanalyze/src/Reanalyze.ml @@ -305,8 +305,10 @@ let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge = match reactive_merge with | Some merged -> (* Reactive mode: use stores directly *) - ReferenceStore.of_reactive ~value_refs:merged.value_refs - ~type_refs:merged.type_refs ~type_deps:merged.type_deps + ReferenceStore.of_reactive + ~value_refs_from:merged.value_refs_from + ~type_refs_from:merged.type_refs_from + ~type_deps:merged.type_deps ~exception_refs:merged.exception_refs | None -> (* Non-reactive mode: build refs imperatively *) @@ -368,8 +370,10 @@ let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge = | Some merged -> (* Reactive mode: use reactive liveness *) let live = ReactiveLiveness.create ~merged in - DeadCommon.solveDeadReactive ~ann_store ~decl_store ~ref_store - ~live ~optional_args_state:empty_optional_args_state + (* Freeze refs for debug/transitive support in solver *) + let refs = ReactiveMerge.freeze_refs merged in + DeadCommon.solveDeadReactive ~ann_store ~decl_store ~refs ~live + ~optional_args_state:empty_optional_args_state ~config:dce_config ~checkOptionalArg:(fun ~optional_args_state:_ ~ann_store:_ ~config:_ _ -> []) diff --git a/analysis/reanalyze/src/ReferenceStore.ml b/analysis/reanalyze/src/ReferenceStore.ml index b69365885f..86b6d4afff 100644 --- a/analysis/reanalyze/src/ReferenceStore.ml +++ b/analysis/reanalyze/src/ReferenceStore.ml @@ -9,64 +9,27 @@ type t = | Frozen of References.t | Reactive of { - value_refs: (Lexing.position, PosSet.t) Reactive.t; - type_refs: (Lexing.position, PosSet.t) Reactive.t; - (* Type deps sources *) - same_path_refs: (Lexing.position, PosSet.t) Reactive.t; - cross_file_refs: (Lexing.position, PosSet.t) Reactive.t; - impl_to_intf_refs_path2: (Lexing.position, PosSet.t) Reactive.t; - intf_to_impl_refs: (Lexing.position, PosSet.t) Reactive.t; - (* Exception refs source *) - exception_resolved_refs: (Lexing.position, PosSet.t) Reactive.t; + (* Per-file refs_from *) + value_refs_from: (Lexing.position, PosSet.t) Reactive.t; + type_refs_from: (Lexing.position, PosSet.t) Reactive.t; + (* Type deps refs_from *) + all_type_refs_from: (Lexing.position, PosSet.t) Reactive.t; + (* Exception refs_from *) + exception_value_refs_from: (Lexing.position, PosSet.t) Reactive.t; } let of_frozen refs = Frozen refs -let of_reactive ~value_refs ~type_refs ~type_deps ~exception_refs = +let of_reactive ~value_refs_from ~type_refs_from ~type_deps ~exception_refs = Reactive { - value_refs; - type_refs; - same_path_refs = type_deps.ReactiveTypeDeps.same_path_refs; - cross_file_refs = type_deps.ReactiveTypeDeps.cross_file_refs; - impl_to_intf_refs_path2 = - type_deps.ReactiveTypeDeps.impl_to_intf_refs_path2; - intf_to_impl_refs = type_deps.ReactiveTypeDeps.intf_to_impl_refs; - exception_resolved_refs = - exception_refs.ReactiveExceptionRefs.resolved_refs; + value_refs_from; + type_refs_from; + all_type_refs_from = type_deps.ReactiveTypeDeps.all_type_refs_from; + exception_value_refs_from = + exception_refs.ReactiveExceptionRefs.resolved_refs_from; } -(** Helper to get from reactive and default to empty *) -let get_or_empty reactive pos = - match Reactive.get reactive pos with - | Some s -> s - | None -> PosSet.empty - -let find_value_refs t pos = - match t with - | Frozen refs -> References.find_value_refs refs pos - | Reactive r -> - (* Combine: per-file value_refs + exception resolved_refs *) - let from_file = get_or_empty r.value_refs pos in - let from_exceptions = get_or_empty r.exception_resolved_refs pos in - PosSet.union from_file from_exceptions - -let find_type_refs t pos = - match t with - | Frozen refs -> References.find_type_refs refs pos - | Reactive r -> - (* Combine: per-file type_refs + all type_deps sources *) - let from_file = get_or_empty r.type_refs pos in - let from_same_path = get_or_empty r.same_path_refs pos in - let from_cross_file = get_or_empty r.cross_file_refs pos in - let from_impl_intf2 = get_or_empty r.impl_to_intf_refs_path2 pos in - let from_intf_impl = get_or_empty r.intf_to_impl_refs pos in - from_file - |> PosSet.union from_same_path - |> PosSet.union from_cross_file - |> PosSet.union from_impl_intf2 - |> PosSet.union from_intf_impl - (** Get underlying References.t for Frozen stores. Used for forward liveness. *) let get_refs_opt t = match t with diff --git a/analysis/reanalyze/src/ReferenceStore.mli b/analysis/reanalyze/src/ReferenceStore.mli index d0f93f6026..cfc266fad3 100644 --- a/analysis/reanalyze/src/ReferenceStore.mli +++ b/analysis/reanalyze/src/ReferenceStore.mli @@ -13,20 +13,12 @@ val of_frozen : References.t -> t (** Wrap a frozen [References.t] *) val of_reactive : - value_refs:(Lexing.position, PosSet.t) Reactive.t -> - type_refs:(Lexing.position, PosSet.t) Reactive.t -> + value_refs_from:(Lexing.position, PosSet.t) Reactive.t -> + type_refs_from:(Lexing.position, PosSet.t) Reactive.t -> type_deps:ReactiveTypeDeps.t -> exception_refs:ReactiveExceptionRefs.t -> t (** Wrap reactive collections directly (no copy) *) -(** {2 refs_to direction (for reporting)} *) - -val find_value_refs : t -> Lexing.position -> PosSet.t -(** Find who value-references this position *) - -val find_type_refs : t -> Lexing.position -> PosSet.t -(** Find who type-references this position *) - val get_refs_opt : t -> References.t option (** Get underlying References.t for Frozen stores. Returns None for Reactive. *) diff --git a/analysis/reanalyze/src/References.ml b/analysis/reanalyze/src/References.ml index 920a0930a2..fd324ed434 100644 --- a/analysis/reanalyze/src/References.ml +++ b/analysis/reanalyze/src/References.ml @@ -4,69 +4,35 @@ - [builder] - mutable, for AST processing - [t] - immutable, for solver (read-only access) - References are stored in BOTH directions: - - refs_to: posTo -> {posFrom1, posFrom2, ...} = who references posTo + References are stored in refs_from direction only: - refs_from: posFrom -> {posTo1, posTo2, ...} = what posFrom references - This allows gradual migration from backward to forward algorithms. *) + This is what the forward liveness algorithm needs. *) (* Helper to add to a set in a hashtable *) let addSet h k v = let set = try PosHash.find h k with Not_found -> PosSet.empty in PosHash.replace h k (PosSet.add v set) -(* Helper to find a set in a hashtable *) -let findSet h k = try PosHash.find h k with Not_found -> PosSet.empty - -(* Internal representation: four hashtables (two directions x two ref types) *) +(* Internal representation: two hashtables (refs_from for value and type) *) type refs_table = PosSet.t PosHash.t -type builder = { - (* refs_to direction: posTo -> {sources that reference it} *) - value_refs_to: refs_table; - type_refs_to: refs_table; - (* refs_from direction: posFrom -> {targets it references} *) - value_refs_from: refs_table; - type_refs_from: refs_table; -} - -type t = { - value_refs_to: refs_table; - type_refs_to: refs_table; - value_refs_from: refs_table; - type_refs_from: refs_table; -} +type builder = {value_refs_from: refs_table; type_refs_from: refs_table} + +type t = {value_refs_from: refs_table; type_refs_from: refs_table} (* ===== Builder API ===== *) let create_builder () : builder = - { - value_refs_to = PosHash.create 256; - type_refs_to = PosHash.create 256; - value_refs_from = PosHash.create 256; - type_refs_from = PosHash.create 256; - } + {value_refs_from = PosHash.create 256; type_refs_from = PosHash.create 256} -(* Store in both directions *) let add_value_ref (builder : builder) ~posTo ~posFrom = - addSet builder.value_refs_to posTo posFrom; addSet builder.value_refs_from posFrom posTo let add_type_ref (builder : builder) ~posTo ~posFrom = - addSet builder.type_refs_to posTo posFrom; addSet builder.type_refs_from posFrom posTo let merge_into_builder ~(from : builder) ~(into : builder) = - (* Merge refs_to direction *) - PosHash.iter - (fun pos refs -> - refs |> PosSet.iter (fun fromPos -> addSet into.value_refs_to pos fromPos)) - from.value_refs_to; - PosHash.iter - (fun pos refs -> - refs |> PosSet.iter (fun fromPos -> addSet into.type_refs_to pos fromPos)) - from.type_refs_to; - (* Merge refs_from direction *) PosHash.iter (fun pos refs -> refs |> PosSet.iter (fun toPos -> addSet into.value_refs_from pos toPos)) @@ -81,8 +47,6 @@ let merge_all (builders : builder list) : t = builders |> List.iter (fun builder -> merge_into_builder ~from:builder ~into:result); { - value_refs_to = result.value_refs_to; - type_refs_to = result.type_refs_to; value_refs_from = result.value_refs_from; type_refs_from = result.type_refs_from; } @@ -90,24 +54,12 @@ let merge_all (builders : builder list) : t = let freeze_builder (builder : builder) : t = (* Zero-copy freeze - builder should not be used after this *) { - value_refs_to = builder.value_refs_to; - type_refs_to = builder.type_refs_to; value_refs_from = builder.value_refs_from; type_refs_from = builder.type_refs_from; } (* ===== Builder extraction for reactive merge ===== *) -(* Extract refs_to direction (posTo -> {sources}) *) -let builder_value_refs_to_list (builder : builder) : - (Lexing.position * PosSet.t) list = - PosHash.fold (fun pos refs acc -> (pos, refs) :: acc) builder.value_refs_to [] - -let builder_type_refs_to_list (builder : builder) : - (Lexing.position * PosSet.t) list = - PosHash.fold (fun pos refs acc -> (pos, refs) :: acc) builder.type_refs_to [] - -(* Extract refs_from direction (posFrom -> {targets}) *) let builder_value_refs_from_list (builder : builder) : (Lexing.position * PosSet.t) list = PosHash.fold @@ -120,18 +72,13 @@ let builder_type_refs_from_list (builder : builder) : (fun pos refs acc -> (pos, refs) :: acc) builder.type_refs_from [] -let create ~value_refs_to ~type_refs_to ~value_refs_from ~type_refs_from : t = - {value_refs_to; type_refs_to; value_refs_from; type_refs_from} +let create ~value_refs_from ~type_refs_from : t = + {value_refs_from; type_refs_from} (* ===== Read-only API ===== *) -(* refs_to direction: find who references this position (for reporting) *) -let find_value_refs (t : t) pos = findSet t.value_refs_to pos -let find_type_refs (t : t) pos = findSet t.type_refs_to pos - -let value_refs_length (t : t) = PosHash.length t.value_refs_to -let type_refs_length (t : t) = PosHash.length t.type_refs_to - -(* refs_from direction: iterate over what positions reference (for liveness) *) let iter_value_refs_from (t : t) f = PosHash.iter f t.value_refs_from let iter_type_refs_from (t : t) f = PosHash.iter f t.type_refs_from + +let value_refs_from_length (t : t) = PosHash.length t.value_refs_from +let type_refs_from_length (t : t) = PosHash.length t.type_refs_from diff --git a/analysis/reanalyze/src/References.mli b/analysis/reanalyze/src/References.mli index 2ff2af1bad..84939aa2f1 100644 --- a/analysis/reanalyze/src/References.mli +++ b/analysis/reanalyze/src/References.mli @@ -4,11 +4,10 @@ - [builder] - mutable, for AST processing - [t] - immutable, for solver (read-only access) - References are stored in BOTH directions: - - refs_to: posTo -> {sources that reference it} + References are stored in refs_from direction: - refs_from: posFrom -> {targets it references} - This enables gradual migration from backward to forward algorithms. *) + This is what the forward liveness algorithm needs. *) (** {2 Types} *) @@ -24,11 +23,11 @@ val create_builder : unit -> builder val add_value_ref : builder -> posTo:Lexing.position -> posFrom:Lexing.position -> unit -(** Add a value reference. Stores in both directions. *) +(** Add a value reference. *) val add_type_ref : builder -> posTo:Lexing.position -> posFrom:Lexing.position -> unit -(** Add a type reference. Stores in both directions. *) +(** Add a type reference. *) val merge_into_builder : from:builder -> into:builder -> unit (** Merge one builder into another. *) @@ -41,43 +40,25 @@ val freeze_builder : builder -> t (** {2 Builder extraction for reactive merge} *) -val builder_value_refs_to_list : builder -> (Lexing.position * PosSet.t) list -(** Extract value refs in refs_to direction (posTo -> sources) *) - -val builder_type_refs_to_list : builder -> (Lexing.position * PosSet.t) list -(** Extract type refs in refs_to direction (posTo -> sources) *) - val builder_value_refs_from_list : builder -> (Lexing.position * PosSet.t) list -(** Extract value refs in refs_from direction (posFrom -> targets) *) +(** Extract value refs (posFrom -> targets) *) val builder_type_refs_from_list : builder -> (Lexing.position * PosSet.t) list -(** Extract type refs in refs_from direction (posFrom -> targets) *) +(** Extract type refs (posFrom -> targets) *) val create : - value_refs_to:PosSet.t PosHash.t -> - type_refs_to:PosSet.t PosHash.t -> - value_refs_from:PosSet.t PosHash.t -> - type_refs_from:PosSet.t PosHash.t -> - t -(** Create a References.t from hashtables (all four directions) *) - -(** {2 Read-only API - refs_to direction (for reporting)} *) - -val find_value_refs : t -> Lexing.position -> PosSet.t -(** Find who value-references this position *) - -val find_type_refs : t -> Lexing.position -> PosSet.t -(** Find who type-references this position *) + value_refs_from:PosSet.t PosHash.t -> type_refs_from:PosSet.t PosHash.t -> t +(** Create a References.t from hashtables *) -(** {2 Read-only API - refs_from direction (for liveness)} *) +(** {2 Read-only API - for liveness} *) val iter_value_refs_from : t -> (Lexing.position -> PosSet.t -> unit) -> unit -(** Iterate all value refs in refs_from direction *) +(** Iterate all value refs *) val iter_type_refs_from : t -> (Lexing.position -> PosSet.t -> unit) -> unit -(** Iterate all type refs in refs_from direction *) +(** Iterate all type refs *) (** {2 Length} *) -val value_refs_length : t -> int -val type_refs_length : t -> int +val value_refs_from_length : t -> int +val type_refs_from_length : t -> int From 020712e9e27f3d3df78e1d2b32570dbeb3fa6a7c Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 13:32:27 +0100 Subject: [PATCH 12/45] Implement incremental reactive fixpoint combinator - Add rank-based incremental fixpoint algorithm to Reactive module - BFS expansion when init/edges grow - Well-founded cascade contraction when init/edges shrink - Inverse index for efficient edge removal handling - Add comprehensive fixpoint tests (14 new test cases) - Cycles, diamond graphs, alternative support - Adding/removing base elements and edges - Delta emission verification - Use Lazy.t for ReactiveLiveness creation - Created on first force (after files processed) - Cached and reused across runs - Fix external refs detection in ReactiveLiveness - Use flatMap with synchronous decls.get lookup - Correctly identifies refs from non-declaration positions - Fix DceCommand.ml missing reactive_liveness parameter Performance: Run 1 ~11ms, Run 2+ ~1ms (incremental fixpoint) Signed-Off-By: Cristiano Calcagno --- analysis/reactive/src/Reactive.ml | 405 +++++++++++++++--- analysis/reactive/src/Reactive.mli | 34 +- analysis/reactive/test/ReactiveTest.ml | 462 +++++++++++++++++++-- analysis/reanalyze/src/ReactiveLiveness.ml | 43 +- analysis/reanalyze/src/Reanalyze.ml | 24 +- analysis/src/DceCommand.ml | 2 +- 6 files changed, 857 insertions(+), 113 deletions(-) diff --git a/analysis/reactive/src/Reactive.ml b/analysis/reactive/src/Reactive.ml index b77269496f..42d0255983 100644 --- a/analysis/reactive/src/Reactive.ml +++ b/analysis/reactive/src/Reactive.ml @@ -452,77 +452,370 @@ let union (left : ('k, 'v) t) (right : ('k, 'v) t) ?merge () : ('k, 'v) t = (** {1 Fixpoint} *) -(** Compute transitive closure via fixpoint. +(** Incremental Fixpoint Computation. + + This implements the incremental fixpoint algorithm using: + - BFS for expansion (when base or edges grow) + - Well-founded derivation for contraction (when base or edges shrink) + + The fixpoint combinator maintains the least fixpoint of a monotone operator: + + F(S) = base ∪ step(S) + + where step(S) = ⋃{successors(x) | x ∈ S} + + Key insight: The rank of an element is its BFS distance from base. + Cycle members have equal ranks, so they cannot provide well-founded + support to each other, ensuring unreachable cycles are correctly removed. *) + +module Fixpoint = struct + type 'k state = { + current: ('k, unit) Hashtbl.t; (* Current fixpoint set *) + rank: ('k, int) Hashtbl.t; (* BFS distance from base *) + inv_index: ('k, 'k list) Hashtbl.t; + (* Inverse step relation: target → sources *) + base: ('k, unit) Hashtbl.t; (* Current base set *) + edges: ('k, 'k list) Hashtbl.t; (* Current edges snapshot *) + } + + let create () = + { + current = Hashtbl.create 256; + rank = Hashtbl.create 256; + inv_index = Hashtbl.create 256; + base = Hashtbl.create 64; + edges = Hashtbl.create 256; + } + + (* Inverse index helpers *) + let add_to_inv_index state ~source ~target = + let sources = + match Hashtbl.find_opt state.inv_index target with + | Some s -> s + | None -> [] + in + if not (List.mem source sources) then + Hashtbl.replace state.inv_index target (source :: sources) + + let remove_from_inv_index state ~source ~target = + match Hashtbl.find_opt state.inv_index target with + | None -> () + | Some sources -> + let filtered = List.filter (fun s -> s <> source) sources in + if filtered = [] then Hashtbl.remove state.inv_index target + else Hashtbl.replace state.inv_index target filtered + + let iter_step_inv state x f = + match Hashtbl.find_opt state.inv_index x with + | None -> () + | Some sources -> List.iter f sources + + (* Get successors from edges *) + let get_successors state x = + match Hashtbl.find_opt state.edges x with + | None -> [] + | Some succs -> succs + + (* Expansion: BFS from frontier, returns list of newly added elements *) + let expand state ~frontier = + let added = ref [] in + let current_frontier = Hashtbl.create 64 in + let next_frontier = Hashtbl.create 64 in + + (* Initialize current frontier *) + List.iter (fun x -> Hashtbl.replace current_frontier x ()) frontier; + + let r = ref 0 in + + while Hashtbl.length current_frontier > 0 do + (* Add all frontier elements to current with rank r *) + Hashtbl.iter + (fun x () -> + if not (Hashtbl.mem state.current x) then ( + Hashtbl.replace state.current x (); + Hashtbl.replace state.rank x !r; + added := x :: !added)) + current_frontier; + + (* Compute next frontier: successors not yet in current *) + Hashtbl.clear next_frontier; + Hashtbl.iter + (fun x () -> + let successors = get_successors state x in + List.iter + (fun y -> + (* Update inverse index: record that x derives y *) + add_to_inv_index state ~source:x ~target:y; + (* Add to next frontier if not already in current *) + if not (Hashtbl.mem state.current y) then + Hashtbl.replace next_frontier y ()) + successors) + current_frontier; + + (* Swap frontiers *) + Hashtbl.clear current_frontier; + Hashtbl.iter + (fun x () -> Hashtbl.replace current_frontier x ()) + next_frontier; + incr r + done; + + !added + + (* Check if element has a well-founded deriver in the current set *) + let has_well_founded_deriver state x ~dying = + match Hashtbl.find_opt state.rank x with + | None -> false + | Some rx -> + let found = ref false in + iter_step_inv state x (fun y -> + if not !found then + let in_current = Hashtbl.mem state.current y in + let not_dying = not (Hashtbl.mem dying y) in + match Hashtbl.find_opt state.rank y with + | None -> () + | Some ry -> + if in_current && not_dying && ry < rx then found := true); + !found + + (* Contraction: remove elements that lost support, returns list of removed *) + let contract state ~worklist = + let dying = Hashtbl.create 64 in + let current_worklist = Hashtbl.create 64 in + + (* Initialize worklist *) + List.iter (fun x -> Hashtbl.replace current_worklist x ()) worklist; + + while Hashtbl.length current_worklist > 0 do + (* Pop an element from worklist *) + let x = + let result = ref None in + Hashtbl.iter + (fun k () -> if !result = None then result := Some k) + current_worklist; + match !result with + | None -> assert false (* worklist not empty *) + | Some k -> + Hashtbl.remove current_worklist k; + k + in + + (* Skip if already dying or in base *) + if Hashtbl.mem dying x || Hashtbl.mem state.base x then () + else + (* Check for well-founded deriver *) + let has_support = has_well_founded_deriver state x ~dying in + + if not has_support then ( + (* x dies: no well-founded support *) + Hashtbl.replace dying x (); + + (* Find dependents: elements z such that x derives z *) + let successors = get_successors state x in + List.iter + (fun z -> + if Hashtbl.mem state.current z && not (Hashtbl.mem dying z) then + Hashtbl.replace current_worklist z ()) + successors) + done; + + (* Remove dying elements from current and rank *) + let removed = ref [] in + Hashtbl.iter + (fun x () -> + Hashtbl.remove state.current x; + Hashtbl.remove state.rank x; + removed := x :: !removed) + dying; + + !removed + + (* Apply a delta from init (base) collection *) + let apply_init_delta state delta = + match delta with + | Set (k, ()) -> + let was_in_base = Hashtbl.mem state.base k in + Hashtbl.replace state.base k (); + if was_in_base then ([], []) (* Already in base, no change *) + else + (* New base element: expand from it *) + let added = expand state ~frontier:[k] in + (added, []) + | Remove k -> + if not (Hashtbl.mem state.base k) then ([], []) + (* Not in base, no change *) + else ( + Hashtbl.remove state.base k; + (* Start contraction if k was in current *) + if Hashtbl.mem state.current k then + let removed = contract state ~worklist:[k] in + ([], removed) + else ([], [])) + + (* Compute edge diff between old and new successors *) + let compute_edge_diff old_succs new_succs = + let old_set = Hashtbl.create (List.length old_succs) in + List.iter (fun x -> Hashtbl.replace old_set x ()) old_succs; + let new_set = Hashtbl.create (List.length new_succs) in + List.iter (fun x -> Hashtbl.replace new_set x ()) new_succs; + + let removed = + List.filter (fun x -> not (Hashtbl.mem new_set x)) old_succs + in + let added = List.filter (fun x -> not (Hashtbl.mem old_set x)) new_succs in + (removed, added) + + (* Apply a delta from edges collection *) + let apply_edges_delta state delta = + match delta with + | Set (source, new_succs) -> + let old_succs = + match Hashtbl.find_opt state.edges source with + | None -> [] + | Some s -> s + in + Hashtbl.replace state.edges source new_succs; + + let removed_targets, added_targets = + compute_edge_diff old_succs new_succs + in + + (* Process removed edges *) + let contraction_worklist = ref [] in + List.iter + (fun target -> + remove_from_inv_index state ~source ~target; + if + Hashtbl.mem state.current source && Hashtbl.mem state.current target + then contraction_worklist := target :: !contraction_worklist) + removed_targets; + + let all_removed = + if !contraction_worklist <> [] then + contract state ~worklist:!contraction_worklist + else [] + in + + (* Process added edges *) + let expansion_frontier = ref [] in + List.iter + (fun target -> + add_to_inv_index state ~source ~target; + if + Hashtbl.mem state.current source + && not (Hashtbl.mem state.current target) + then expansion_frontier := target :: !expansion_frontier) + added_targets; + + (* Check if any removed element can be re-derived via remaining edges *) + let removed_set = Hashtbl.create (List.length all_removed) in + List.iter (fun x -> Hashtbl.replace removed_set x ()) all_removed; + + if Hashtbl.length removed_set > 0 then + Hashtbl.iter + (fun y () -> + iter_step_inv state y (fun x -> + if Hashtbl.mem state.current x then + expansion_frontier := y :: !expansion_frontier)) + removed_set; + + let all_added = + if !expansion_frontier <> [] then + expand state ~frontier:!expansion_frontier + else [] + in + + (* Compute net changes *) + let net_removed = + List.filter (fun x -> not (Hashtbl.mem state.current x)) all_removed + in + let net_added = + List.filter (fun x -> not (Hashtbl.mem removed_set x)) all_added + in + + (net_added, net_removed) + | Remove source -> + let old_succs = + match Hashtbl.find_opt state.edges source with + | None -> [] + | Some s -> s + in + Hashtbl.remove state.edges source; + + (* All edges from source are removed *) + let contraction_worklist = ref [] in + List.iter + (fun target -> + remove_from_inv_index state ~source ~target; + if + Hashtbl.mem state.current source && Hashtbl.mem state.current target + then contraction_worklist := target :: !contraction_worklist) + old_succs; + + let removed = + if !contraction_worklist <> [] then + contract state ~worklist:!contraction_worklist + else [] + in + + ([], removed) +end + +(** Compute transitive closure via incremental fixpoint. Starting from keys in [init], follows edges to discover all reachable keys. - Current implementation: recomputes full fixpoint on any change. - Future: incremental updates. *) + When [init] or [edges] changes, the fixpoint updates incrementally: + - Expansion: BFS from new base elements or newly reachable successors + - Contraction: Well-founded cascade removal when elements lose support *) let fixpoint ~(init : ('k, unit) t) ~(edges : ('k, 'k list) t) () : ('k, unit) t = - (* Current fixpoint result *) - let result : ('k, unit) Hashtbl.t = Hashtbl.create 256 in + let state = Fixpoint.create () in let subscribers : (('k, unit) delta -> unit) list ref = ref [] in let emit delta = List.iter (fun h -> h delta) !subscribers in - (* Recompute the entire fixpoint from scratch *) - let recompute () = - (* Collect old keys to detect removals *) - let old_keys = - Hashtbl.fold (fun k () acc -> k :: acc) result [] - |> List.fold_left - (fun set k -> - Hashtbl.replace set k (); - set) - (Hashtbl.create 256) - in + let emit_changes (added, removed) = + List.iter (fun k -> emit (Set (k, ()))) added; + List.iter (fun k -> emit (Remove k)) removed + in - (* Clear and recompute *) - Hashtbl.clear result; - - (* Worklist algorithm *) - let worklist = Queue.create () in - - (* Add initial keys *) - init.iter (fun k () -> - if not (Hashtbl.mem result k) then ( - Hashtbl.replace result k (); - Queue.push k worklist)); - - (* Propagate through edges *) - while not (Queue.is_empty worklist) do - let k = Queue.pop worklist in - match edges.get k with - | None -> () - | Some successors -> - List.iter - (fun succ -> - if not (Hashtbl.mem result succ) then ( - Hashtbl.replace result succ (); - Queue.push succ worklist)) - successors - done; + (* Handle init deltas *) + let handle_init_delta delta = + let changes = Fixpoint.apply_init_delta state delta in + emit_changes changes + in - (* Emit deltas: additions and removals *) - Hashtbl.iter - (fun k () -> if not (Hashtbl.mem old_keys k) then emit (Set (k, ()))) - result; - Hashtbl.iter - (fun k () -> if not (Hashtbl.mem result k) then emit (Remove k)) - old_keys + (* Handle edges deltas *) + let handle_edges_delta delta = + let changes = Fixpoint.apply_edges_delta state delta in + emit_changes changes in - (* Subscribe to changes in init and edges *) - init.subscribe (fun _ -> recompute ()); - edges.subscribe (fun _ -> recompute ()); + (* Subscribe to changes *) + init.subscribe handle_init_delta; + edges.subscribe handle_edges_delta; + + (* Initialize from existing data *) + (* First, load all edges so expansion works correctly *) + edges.iter (fun k succs -> Hashtbl.replace state.edges k succs); + + (* Build inverse index for existing edges *) + Hashtbl.iter + (fun source succs -> + List.iter + (fun target -> Fixpoint.add_to_inv_index state ~source ~target) + succs) + state.edges; - (* Initial computation *) - recompute (); + (* Then process init elements *) + init.iter (fun k () -> + Hashtbl.replace state.base k (); + ignore (Fixpoint.expand state ~frontier:[k])); { subscribe = (fun handler -> subscribers := handler :: !subscribers); - iter = (fun f -> Hashtbl.iter f result); - get = (fun k -> Hashtbl.find_opt result k); - length = (fun () -> Hashtbl.length result); + iter = (fun f -> Hashtbl.iter f state.current); + get = (fun k -> Hashtbl.find_opt state.current k); + length = (fun () -> Hashtbl.length state.current); } diff --git a/analysis/reactive/src/Reactive.mli b/analysis/reactive/src/Reactive.mli index 319c1791aa..2431ab7f9a 100644 --- a/analysis/reactive/src/Reactive.mli +++ b/analysis/reactive/src/Reactive.mli @@ -142,22 +142,46 @@ val union : val fixpoint : init:('k, unit) t -> edges:('k, 'k list) t -> unit -> ('k, unit) t -(** [fixpoint ~init ~edges ()] computes transitive closure. +(** [fixpoint ~init ~edges ()] computes transitive closure incrementally. Starting from keys in [init], follows edges to discover all reachable keys. - - [init]: reactive collection of starting keys + - [init]: reactive collection of starting keys (base) - [edges]: reactive collection mapping each key to its successor keys - Returns: reactive collection of all reachable keys - When [init] or [edges] changes, the fixpoint recomputes. + {b Incremental Updates:} - {b Note}: Current implementation recomputes full fixpoint on any change. - Future versions will update incrementally. + When [init] or [edges] changes, the fixpoint updates efficiently: + + - {b Expansion}: When base grows or new edges are added, BFS from the + new frontier discovers newly reachable elements. O(new reachable). + + - {b Contraction}: When base shrinks or edges are removed, elements that + lost support are removed using well-founded derivation. Cycle members + have equal BFS ranks and cannot support each other, so unreachable + cycles are correctly removed. O(affected elements). + + {b Algorithm:} + + Each element has a rank (BFS distance from base). For contraction, + an element survives only if it has a "well-founded deriver" - an + element with strictly lower rank that derives it. This ensures: + - Direct base elements always survive (rank 0) + - Elements derived from survivors survive + - Cycles lose all members when disconnected from base {2 Example: Reachability} {[ let roots = ... (* keys that are initially reachable *) let graph = ... (* key -> successor keys *) let reachable = Reactive.fixpoint ~init:roots ~edges:graph () + ]} + + {2 Example: Dead code elimination} + {[ + let live_roots = ... (* @live annotations, external refs *) + let references = ... (* decl -> referenced decls *) + let live = Reactive.fixpoint ~init:live_roots ~edges:references () + (* Dead = all_decls - live *) ]} *) diff --git a/analysis/reactive/test/ReactiveTest.ml b/analysis/reactive/test/ReactiveTest.ml index 01af42e7c2..a48ce396cc 100644 --- a/analysis/reactive/test/ReactiveTest.ml +++ b/analysis/reactive/test/ReactiveTest.ml @@ -749,37 +749,30 @@ let test_union_existing_data () = (* Only in right *) Printf.printf "PASSED\n\n" -(* Test fixpoint *) -let test_fixpoint () = - Printf.printf "Test: fixpoint\n"; - - (* Create mutable sources *) - let init_tbl = Hashtbl.create 16 in - let init_subscribers = ref [] in - let emit_init delta = - Hashtbl.iter (fun _ h -> h delta) (Hashtbl.create 1); - List.iter (fun h -> h delta) !init_subscribers +(* Helper to create mutable reactive collections for testing *) +let create_mutable_collection () = + let tbl = Hashtbl.create 16 in + let subscribers = ref [] in + let emit delta = + Reactive.apply_delta tbl delta; + List.iter (fun h -> h delta) !subscribers in - let init : (int, unit) Reactive.t = + let collection : ('k, 'v) Reactive.t = { - subscribe = (fun h -> init_subscribers := h :: !init_subscribers); - iter = (fun f -> Hashtbl.iter f init_tbl); - get = (fun k -> Hashtbl.find_opt init_tbl k); - length = (fun () -> Hashtbl.length init_tbl); + subscribe = (fun h -> subscribers := h :: !subscribers); + iter = (fun f -> Hashtbl.iter f tbl); + get = (fun k -> Hashtbl.find_opt tbl k); + length = (fun () -> Hashtbl.length tbl); } in + (collection, emit, tbl) - let edges_tbl : (int, int list) Hashtbl.t = Hashtbl.create 16 in - let edges_subscribers = ref [] in - let emit_edges delta = List.iter (fun h -> h delta) !edges_subscribers in - let edges : (int, int list) Reactive.t = - { - subscribe = (fun h -> edges_subscribers := h :: !edges_subscribers); - iter = (fun f -> Hashtbl.iter f edges_tbl); - get = (fun k -> Hashtbl.find_opt edges_tbl k); - length = (fun () -> Hashtbl.length edges_tbl); - } - in +(* Test fixpoint basic *) +let test_fixpoint () = + Printf.printf "Test: fixpoint\n"; + + let init, emit_init, _init_tbl = create_mutable_collection () in + let edges, emit_edges, edges_tbl = create_mutable_collection () in (* Set up graph: 1 -> [2, 3], 2 -> [4], 3 -> [4] *) Hashtbl.replace edges_tbl 1 [2; 3]; @@ -794,7 +787,6 @@ let test_fixpoint () = assert (Reactive.length reachable = 0); (* Add root 1 *) - Hashtbl.replace init_tbl 1 (); emit_init (Set (1, ())); Printf.printf "After adding root 1: length=%d\n" (Reactive.length reachable); assert (Reactive.length reachable = 4); @@ -806,9 +798,7 @@ let test_fixpoint () = assert (Reactive.get reachable 5 = None); (* Add another root 5 with edge 5 -> [6] *) - Hashtbl.replace edges_tbl 5 [6]; emit_edges (Set (5, [6])); - Hashtbl.replace init_tbl 5 (); emit_init (Set (5, ())); Printf.printf "After adding root 5: length=%d\n" (Reactive.length reachable); assert (Reactive.length reachable = 6); @@ -816,7 +806,6 @@ let test_fixpoint () = (* 1, 2, 3, 4, 5, 6 *) (* Remove root 1 *) - Hashtbl.remove init_tbl 1; emit_init (Remove 1); Printf.printf "After removing root 1: length=%d\n" (Reactive.length reachable); assert (Reactive.length reachable = 2); @@ -827,6 +816,404 @@ let test_fixpoint () = Printf.printf "PASSED\n\n" +(* Test: Basic Expansion *) +let test_fixpoint_basic_expansion () = + Printf.printf "=== Test: fixpoint basic expansion ===\n"; + + let init, emit_init, _ = create_mutable_collection () in + let edges, _, edges_tbl = create_mutable_collection () in + + (* Graph: a -> b -> c *) + Hashtbl.replace edges_tbl "a" ["b"]; + Hashtbl.replace edges_tbl "b" ["c"]; + + let fp = Reactive.fixpoint ~init ~edges () in + + emit_init (Set ("a", ())); + + assert (Reactive.length fp = 3); + assert (Reactive.get fp "a" = Some ()); + assert (Reactive.get fp "b" = Some ()); + assert (Reactive.get fp "c" = Some ()); + assert (Reactive.get fp "d" = None); + + Printf.printf "PASSED\n\n" + +(* Test: Multiple Roots *) +let test_fixpoint_multiple_roots () = + Printf.printf "=== Test: fixpoint multiple roots ===\n"; + + let init, emit_init, _ = create_mutable_collection () in + let edges, _, edges_tbl = create_mutable_collection () in + + (* Graph: a -> b, c -> d (disconnected components) *) + Hashtbl.replace edges_tbl "a" ["b"]; + Hashtbl.replace edges_tbl "c" ["d"]; + + let fp = Reactive.fixpoint ~init ~edges () in + + emit_init (Set ("a", ())); + emit_init (Set ("c", ())); + + assert (Reactive.length fp = 4); + assert (Reactive.get fp "a" = Some ()); + assert (Reactive.get fp "b" = Some ()); + assert (Reactive.get fp "c" = Some ()); + assert (Reactive.get fp "d" = Some ()); + + Printf.printf "PASSED\n\n" + +(* Test: Diamond Graph *) +let test_fixpoint_diamond () = + Printf.printf "=== Test: fixpoint diamond ===\n"; + + let init, emit_init, _ = create_mutable_collection () in + let edges, _, edges_tbl = create_mutable_collection () in + + (* Graph: a -> b, a -> c, b -> d, c -> d *) + Hashtbl.replace edges_tbl "a" ["b"; "c"]; + Hashtbl.replace edges_tbl "b" ["d"]; + Hashtbl.replace edges_tbl "c" ["d"]; + + let fp = Reactive.fixpoint ~init ~edges () in + + emit_init (Set ("a", ())); + + assert (Reactive.length fp = 4); + + Printf.printf "PASSED\n\n" + +(* Test: Cycle *) +let test_fixpoint_cycle () = + Printf.printf "=== Test: fixpoint cycle ===\n"; + + let init, emit_init, _ = create_mutable_collection () in + let edges, _, edges_tbl = create_mutable_collection () in + + (* Graph: a -> b -> c -> b (cycle from root) *) + Hashtbl.replace edges_tbl "a" ["b"]; + Hashtbl.replace edges_tbl "b" ["c"]; + Hashtbl.replace edges_tbl "c" ["b"]; + + let fp = Reactive.fixpoint ~init ~edges () in + + emit_init (Set ("a", ())); + + assert (Reactive.length fp = 3); + assert (Reactive.get fp "a" = Some ()); + assert (Reactive.get fp "b" = Some ()); + assert (Reactive.get fp "c" = Some ()); + + Printf.printf "PASSED\n\n" + +(* Test: Add Base Element *) +let test_fixpoint_add_base () = + Printf.printf "=== Test: fixpoint add base ===\n"; + + let init, emit_init, _ = create_mutable_collection () in + let edges, _, edges_tbl = create_mutable_collection () in + + (* Graph: a -> b, c -> d *) + Hashtbl.replace edges_tbl "a" ["b"]; + Hashtbl.replace edges_tbl "c" ["d"]; + + let fp = Reactive.fixpoint ~init ~edges () in + + emit_init (Set ("a", ())); + assert (Reactive.length fp = 2); + + (* a, b *) + + (* Track changes via subscription *) + let added = ref [] in + let removed = ref [] in + fp.subscribe (function + | Set (k, ()) -> added := k :: !added + | Remove k -> removed := k :: !removed); + + emit_init (Set ("c", ())); + + Printf.printf "Added: [%s]\n" (String.concat ", " !added); + assert (List.length !added = 2); + (* c, d *) + assert (List.mem "c" !added); + assert (List.mem "d" !added); + assert (!removed = []); + assert (Reactive.length fp = 4); + + Printf.printf "PASSED\n\n" + +(* Test: Remove Base Element *) +let test_fixpoint_remove_base () = + Printf.printf "=== Test: fixpoint remove base ===\n"; + + let init, emit_init, _ = create_mutable_collection () in + let edges, _, edges_tbl = create_mutable_collection () in + + (* Graph: a -> b -> c *) + Hashtbl.replace edges_tbl "a" ["b"]; + Hashtbl.replace edges_tbl "b" ["c"]; + + let fp = Reactive.fixpoint ~init ~edges () in + + emit_init (Set ("a", ())); + assert (Reactive.length fp = 3); + + let removed = ref [] in + fp.subscribe (function + | Remove k -> removed := k :: !removed + | _ -> ()); + + emit_init (Remove "a"); + + Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); + assert (List.length !removed = 3); + assert (Reactive.length fp = 0); + + Printf.printf "PASSED\n\n" + +(* Test: Add Edge *) +let test_fixpoint_add_edge () = + Printf.printf "=== Test: fixpoint add edge ===\n"; + + let init, emit_init, _ = create_mutable_collection () in + let edges, emit_edges, _ = create_mutable_collection () in + + let fp = Reactive.fixpoint ~init ~edges () in + + emit_init (Set ("a", ())); + assert (Reactive.length fp = 1); + + (* just a *) + let added = ref [] in + fp.subscribe (function + | Set (k, ()) -> added := k :: !added + | _ -> ()); + + (* Add edge a -> b *) + emit_edges (Set ("a", ["b"])); + + Printf.printf "Added: [%s]\n" (String.concat ", " !added); + assert (List.mem "b" !added); + assert (Reactive.length fp = 2); + + Printf.printf "PASSED\n\n" + +(* Test: Remove Edge *) +let test_fixpoint_remove_edge () = + Printf.printf "=== Test: fixpoint remove edge ===\n"; + + let init, emit_init, _ = create_mutable_collection () in + let edges, emit_edges, edges_tbl = create_mutable_collection () in + + (* Graph: a -> b -> c *) + Hashtbl.replace edges_tbl "a" ["b"]; + Hashtbl.replace edges_tbl "b" ["c"]; + + let fp = Reactive.fixpoint ~init ~edges () in + + emit_init (Set ("a", ())); + assert (Reactive.length fp = 3); + + let removed = ref [] in + fp.subscribe (function + | Remove k -> removed := k :: !removed + | _ -> ()); + + (* Remove edge a -> b *) + emit_edges (Set ("a", [])); + + Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); + assert (List.length !removed = 2); + (* b, c *) + assert (Reactive.length fp = 1); + + (* just a *) + Printf.printf "PASSED\n\n" + +(* Test: Cycle Removal (Well-Founded Derivation) *) +let test_fixpoint_cycle_removal () = + Printf.printf "=== Test: fixpoint cycle removal (well-founded) ===\n"; + + let init, emit_init, _ = create_mutable_collection () in + let edges, emit_edges, edges_tbl = create_mutable_collection () in + + (* Graph: a -> b -> c -> b (b-c cycle reachable from a) *) + Hashtbl.replace edges_tbl "a" ["b"]; + Hashtbl.replace edges_tbl "b" ["c"]; + Hashtbl.replace edges_tbl "c" ["b"]; + + let fp = Reactive.fixpoint ~init ~edges () in + + emit_init (Set ("a", ())); + assert (Reactive.length fp = 3); + + let removed = ref [] in + fp.subscribe (function + | Remove k -> removed := k :: !removed + | _ -> ()); + + (* Remove edge a -> b *) + emit_edges (Set ("a", [])); + + Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); + (* Both b and c should be removed - cycle has no well-founded support *) + assert (List.length !removed = 2); + assert (List.mem "b" !removed); + assert (List.mem "c" !removed); + assert (Reactive.length fp = 1); + + (* just a *) + Printf.printf "PASSED\n\n" + +(* Test: Alternative Support Keeps Element Alive *) +let test_fixpoint_alternative_support () = + Printf.printf "=== Test: fixpoint alternative support ===\n"; + + let init, emit_init, _ = create_mutable_collection () in + let edges, emit_edges, edges_tbl = create_mutable_collection () in + + (* Graph: a -> b, a -> c -> b + If we remove a -> b, b should survive via a -> c -> b *) + Hashtbl.replace edges_tbl "a" ["b"; "c"]; + Hashtbl.replace edges_tbl "c" ["b"]; + + let fp = Reactive.fixpoint ~init ~edges () in + + emit_init (Set ("a", ())); + assert (Reactive.length fp = 3); + + let removed = ref [] in + fp.subscribe (function + | Remove k -> removed := k :: !removed + | _ -> ()); + + (* Remove direct edge a -> b (but keep a -> c) *) + emit_edges (Set ("a", ["c"])); + + Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); + (* b should NOT be removed - still reachable via c *) + assert (!removed = []); + assert (Reactive.length fp = 3); + + Printf.printf "PASSED\n\n" + +(* Test: Empty Base *) +let test_fixpoint_empty_base () = + Printf.printf "=== Test: fixpoint empty base ===\n"; + + let init, _, _ = create_mutable_collection () in + let edges, _, edges_tbl = create_mutable_collection () in + + Hashtbl.replace edges_tbl "a" ["b"]; + + let fp = Reactive.fixpoint ~init ~edges () in + + assert (Reactive.length fp = 0); + + Printf.printf "PASSED\n\n" + +(* Test: Self Loop *) +let test_fixpoint_self_loop () = + Printf.printf "=== Test: fixpoint self loop ===\n"; + + let init, emit_init, _ = create_mutable_collection () in + let edges, _, edges_tbl = create_mutable_collection () in + + (* Graph: a -> a (self loop) *) + Hashtbl.replace edges_tbl "a" ["a"]; + + let fp = Reactive.fixpoint ~init ~edges () in + + emit_init (Set ("a", ())); + + assert (Reactive.length fp = 1); + assert (Reactive.get fp "a" = Some ()); + + Printf.printf "PASSED\n\n" + +(* Test: Delta emissions for incremental updates *) +let test_fixpoint_deltas () = + Printf.printf "=== Test: fixpoint delta emissions ===\n"; + + let init, emit_init, _ = create_mutable_collection () in + let edges, emit_edges, edges_tbl = create_mutable_collection () in + + Hashtbl.replace edges_tbl 1 [2; 3]; + Hashtbl.replace edges_tbl 2 [4]; + + let fp = Reactive.fixpoint ~init ~edges () in + + let all_deltas = ref [] in + fp.subscribe (fun d -> all_deltas := d :: !all_deltas); + + (* Add root *) + emit_init (Set (1, ())); + Printf.printf "After add root: %d deltas\n" (List.length !all_deltas); + assert (List.length !all_deltas = 4); + + (* 1, 2, 3, 4 *) + all_deltas := []; + + (* Add edge 3 -> 5 *) + emit_edges (Set (3, [5])); + Printf.printf "After add edge 3->5: %d deltas\n" (List.length !all_deltas); + assert (List.length !all_deltas = 1); + + (* 5 added *) + all_deltas := []; + + (* Remove root (should remove all) *) + emit_init (Remove 1); + Printf.printf "After remove root: %d deltas\n" (List.length !all_deltas); + assert (List.length !all_deltas = 5); + + (* 1, 2, 3, 4, 5 removed *) + Printf.printf "PASSED\n\n" + +(* Test: Pre-existing data in init and edges *) +let test_fixpoint_existing_data () = + Printf.printf "=== Test: fixpoint with existing data ===\n"; + + (* Create with pre-existing data *) + let init_tbl = Hashtbl.create 16 in + Hashtbl.replace init_tbl "root" (); + let init_subs = ref [] in + let init : (string, unit) Reactive.t = + { + subscribe = (fun h -> init_subs := h :: !init_subs); + iter = (fun f -> Hashtbl.iter f init_tbl); + get = (fun k -> Hashtbl.find_opt init_tbl k); + length = (fun () -> Hashtbl.length init_tbl); + } + in + + let edges_tbl = Hashtbl.create 16 in + Hashtbl.replace edges_tbl "root" ["a"; "b"]; + Hashtbl.replace edges_tbl "a" ["c"]; + let edges_subs = ref [] in + let edges : (string, string list) Reactive.t = + { + subscribe = (fun h -> edges_subs := h :: !edges_subs); + iter = (fun f -> Hashtbl.iter f edges_tbl); + get = (fun k -> Hashtbl.find_opt edges_tbl k); + length = (fun () -> Hashtbl.length edges_tbl); + } + in + + (* Create fixpoint - should immediately have all reachable *) + let fp = Reactive.fixpoint ~init ~edges () in + + Printf.printf "Fixpoint length: %d (expected 4)\n" (Reactive.length fp); + assert (Reactive.length fp = 4); + (* root, a, b, c *) + assert (Reactive.get fp "root" = Some ()); + assert (Reactive.get fp "a" = Some ()); + assert (Reactive.get fp "b" = Some ()); + assert (Reactive.get fp "c" = Some ()); + + Printf.printf "PASSED\n\n" + let () = Printf.printf "\n====== Reactive Collection Tests ======\n\n"; test_flatmap_basic (); @@ -841,4 +1228,19 @@ let () = test_union_with_merge (); test_union_existing_data (); test_fixpoint (); + (* Incremental fixpoint tests *) + test_fixpoint_basic_expansion (); + test_fixpoint_multiple_roots (); + test_fixpoint_diamond (); + test_fixpoint_cycle (); + test_fixpoint_add_base (); + test_fixpoint_remove_base (); + test_fixpoint_add_edge (); + test_fixpoint_remove_edge (); + test_fixpoint_cycle_removal (); + test_fixpoint_alternative_support (); + test_fixpoint_empty_base (); + test_fixpoint_self_loop (); + test_fixpoint_deltas (); + test_fixpoint_existing_data (); Printf.printf "All tests passed!\n" diff --git a/analysis/reanalyze/src/ReactiveLiveness.ml b/analysis/reanalyze/src/ReactiveLiveness.ml index 150511bf9c..4c1af10665 100644 --- a/analysis/reanalyze/src/ReactiveLiveness.ml +++ b/analysis/reanalyze/src/ReactiveLiveness.ml @@ -42,30 +42,45 @@ let create ~(merged : ReactiveMerge.t) : (Lexing.position, unit) Reactive.t = (* Compute externally referenced positions reactively. A position is externally referenced if any reference to it comes from - a position that is NOT a declaration position. + a position that is NOT a declaration position (exact match). - We use join to check if posFrom is a decl position. *) + This matches the non-reactive algorithm which uses DeclarationStore.find_opt. + + We use flatMap and check decls synchronously within the function. + This works correctly regardless of delta arrival order because the check + happens at evaluation time when decls has current data. *) + (* Compute externally referenced positions reactively. + A position is externally referenced if any reference to it comes from + a position that is NOT a declaration position (exact match). + + This matches the non-reactive algorithm which uses DeclarationStore.find_opt. + + We use flatMap and check decls synchronously within the function. + This works correctly regardless of delta arrival order because the check + happens at evaluation time when decls has current data. *) let external_value_refs : (Lexing.position, unit) Reactive.t = - Reactive.join value_refs_from decls - ~key_of:(fun posFrom _targets -> posFrom) - ~f:(fun _posFrom targets decl_opt -> - match decl_opt with - | Some _ -> [] (* posFrom is a decl, not external *) + Reactive.flatMap value_refs_from + ~f:(fun posFrom targets -> + match decls.get posFrom with + | Some _ -> + (* posFrom IS a decl position, refs are internal *) + [] | None -> - (* posFrom is not a decl, so all targets are externally referenced *) + (* posFrom is NOT a decl position, targets are externally referenced *) PosSet.elements targets |> List.map (fun posTo -> (posTo, ()))) ~merge:(fun () () -> ()) () in let external_type_refs : (Lexing.position, unit) Reactive.t = - Reactive.join type_refs_from decls - ~key_of:(fun posFrom _targets -> posFrom) - ~f:(fun _posFrom targets decl_opt -> - match decl_opt with - | Some _ -> [] (* posFrom is a decl, not external *) + Reactive.flatMap type_refs_from + ~f:(fun posFrom targets -> + match decls.get posFrom with + | Some _ -> + (* posFrom IS a decl position, refs are internal *) + [] | None -> - (* posFrom is not a decl, so all targets are externally referenced *) + (* posFrom is NOT a decl position, targets are externally referenced *) PosSet.elements targets |> List.map (fun posTo -> (posTo, ()))) ~merge:(fun () () -> ()) () diff --git a/analysis/reanalyze/src/Reanalyze.ml b/analysis/reanalyze/src/Reanalyze.ml index c6da3a1998..ac08e68bcf 100644 --- a/analysis/reanalyze/src/Reanalyze.ml +++ b/analysis/reanalyze/src/Reanalyze.ml @@ -244,7 +244,8 @@ let shuffle_list lst = done; Array.to_list arr -let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge = +let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge + ~reactive_liveness = (* Map: process each file -> list of file_data *) let {dce_data_list; exception_results} = processCmtFiles ~config:dce_config ~cmtRoot ~reactive_collection @@ -366,10 +367,10 @@ let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge = Timing.time_phase `Solving (fun () -> let empty_optional_args_state = OptionalArgsState.create () in let analysis_result_core = - match reactive_merge with - | Some merged -> - (* Reactive mode: use reactive liveness *) - let live = ReactiveLiveness.create ~merged in + match (reactive_merge, reactive_liveness) with + | Some merged, Some live_lazy -> + (* Reactive mode: use lazy reactive liveness (created on first force) *) + let live = Lazy.force live_lazy in (* Freeze refs for debug/transitive support in solver *) let refs = ReactiveMerge.freeze_refs merged in DeadCommon.solveDeadReactive ~ann_store ~decl_store ~refs ~live @@ -377,7 +378,7 @@ let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge = ~config:dce_config ~checkOptionalArg:(fun ~optional_args_state:_ ~ann_store:_ ~config:_ _ -> []) - | None -> + | _ -> DeadCommon.solveDead ~ann_store ~decl_store ~ref_store ~optional_args_state:empty_optional_args_state ~config:dce_config @@ -448,11 +449,20 @@ let runAnalysisAndReport ~cmtRoot = Some (ReactiveMerge.create file_data_collection) | None -> None in + (* Lazy reactive liveness - created on first force (after files processed) *) + let reactive_liveness = + match reactive_merge with + | Some merged -> Some (lazy (ReactiveLiveness.create ~merged)) + | None -> None + in for run = 1 to numRuns do Timing.reset (); + (* Clear stats at start of each run to avoid accumulation *) + if run > 1 then Log_.Stats.clear (); if numRuns > 1 && !Cli.timing then Printf.eprintf "\n=== Run %d/%d ===\n%!" run numRuns; - runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge; + runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge + ~reactive_liveness; if run = numRuns then ( (* Only report on last run *) Log_.Stats.report ~config:dce_config; diff --git a/analysis/src/DceCommand.ml b/analysis/src/DceCommand.ml index 66ddb6f06f..eb2e1f98c8 100644 --- a/analysis/src/DceCommand.ml +++ b/analysis/src/DceCommand.ml @@ -2,6 +2,6 @@ let command () = Reanalyze.RunConfig.dce (); let dce_config = Reanalyze.DceConfig.current () in Reanalyze.runAnalysis ~dce_config ~cmtRoot:None ~reactive_collection:None - ~reactive_merge:None; + ~reactive_merge:None ~reactive_liveness:None; let issues = !Reanalyze.Log_.Stats.issues in Printf.printf "issues:%d\n" (List.length issues) From 211422c4f08a36fc76e09c0a06f4fca8c3625ee1 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 14:18:31 +0100 Subject: [PATCH 13/45] Fix external refs tracking with explicit join dependency - Change external_value_refs and external_type_refs from flatMap to join in ReactiveLiveness.ml, making the dependency on decls explicit - Update ReactiveLiveness to return a record with live, edges, roots fields for better debugging and introspection - Add trace_edges debug flag in Reactive.Fixpoint for future debugging - Document order-dependence issue in BUG-reactive-order-dependence.md The reactive pipeline has a delta ordering issue where refs arriving before decls causes spurious external refs that aren't properly cleaned up. Using Lazy.t as workaround until properly fixed. --- analysis/reactive/src/Reactive.ml | 6 + .../BUG-reactive-order-dependence.md | 143 ++++++++++++++++++ analysis/reanalyze/src/ReactiveLiveness.ml | 39 +++-- analysis/reanalyze/src/ReactiveLiveness.mli | 14 +- analysis/reanalyze/src/Reanalyze.ml | 12 +- 5 files changed, 188 insertions(+), 26 deletions(-) create mode 100644 analysis/reanalyze/BUG-reactive-order-dependence.md diff --git a/analysis/reactive/src/Reactive.ml b/analysis/reactive/src/Reactive.ml index 42d0255983..62958a6bfd 100644 --- a/analysis/reactive/src/Reactive.ml +++ b/analysis/reactive/src/Reactive.ml @@ -469,6 +469,9 @@ let union (left : ('k, 'v) t) (right : ('k, 'v) t) ?merge () : ('k, 'v) t = support to each other, ensuring unreachable cycles are correctly removed. *) module Fixpoint = struct + (* DEBUG: flag for verbose tracing *) + let trace_edges = false + type 'k state = { current: ('k, unit) Hashtbl.t; (* Current fixpoint set *) rank: ('k, int) Hashtbl.t; (* BFS distance from base *) @@ -669,6 +672,9 @@ module Fixpoint = struct let apply_edges_delta state delta = match delta with | Set (source, new_succs) -> + if trace_edges && new_succs <> [] then + Printf.eprintf "EDGE: Set source with %d successors\n%!" + (List.length new_succs); let old_succs = match Hashtbl.find_opt state.edges source with | None -> [] diff --git a/analysis/reanalyze/BUG-reactive-order-dependence.md b/analysis/reanalyze/BUG-reactive-order-dependence.md new file mode 100644 index 0000000000..5510b1664f --- /dev/null +++ b/analysis/reanalyze/BUG-reactive-order-dependence.md @@ -0,0 +1,143 @@ +# Bug Report: Reactive Pipeline Order-Dependence + +## Summary + +The reactive dead code analysis pipeline produces incorrect results when `ReactiveLiveness` is created before files are processed. This manifests as 2 extra false-positive dead value warnings (382 vs 380 issues). + +## Current Workaround + +Using `Lazy.t` in `Reanalyze.ml` to defer `ReactiveLiveness.create` until after all files are processed: + +```ocaml +let reactive_liveness = + match reactive_merge with + | Some merged -> Some (lazy (ReactiveLiveness.create ~merged)) + | None -> None +in +``` + +This avoids the issue but prevents true incremental updates when files change. + +## Symptoms + +When `ReactiveLiveness.create ~merged` is called **before** files are processed: + +| Metric | Non-Reactive | Reactive | +|--------|-------------|----------| +| Dead Values | 233 | 235 (+2) | +| Total Issues | 380 | 382 (+2) | +| Roots | ~129 | 327 (+198 spurious) | +| Live Positions | 629 | 635 (+6 spurious) | + +### Specific Incorrectly Dead Declarations + +1. `DeadTest.res:63:6` - `let y = 55` inside module MM +2. `Unison.res:19:16` - `group` function + +Both are correctly marked live in non-reactive mode. + +## Root Cause Analysis + +### The Reference Chain + +For `DeadTest.res:63:6` (`y`): +- Line 64: `let x = y` (x references y) +- Line 68: `Js.log(MM.x)` (x is externally referenced) +- Expected: y is live because x→y edge exists and x is live + +### What Goes Wrong + +The edge `64:6 → [63:6]` exists in `state.edges`, and `64:6` IS in `current`, but `63:6` is NOT marked live. + +**Delta ordering issue:** + +1. When `value_refs_from` emits before `decls`, the join marks targets as "externally referenced" +2. When `decls` emits later, the join should emit `Remove` deltas to correct the spurious external refs +3. But something in the Remove propagation through `union → fixpoint` breaks + +### Evidence from Debug Tracing + +``` +FIXPOINT stats: init_deltas=1850 edges_deltas=9147 total_added=1393 current=635 base=327 edges=711 +``` + +- `total_added=1393` but `current=635` → 758 elements were added then removed +- This suggests Remove deltas ARE being processed, but not correctly + +## Affected Code Paths + +1. **`ReactiveLiveness.ml`** - `external_value_refs` and `external_type_refs` joins +2. **`Reactive.ml`** - `join` combinator's Remove delta handling +3. **`Reactive.ml`** - `union` combinator's Remove delta propagation +4. **`Reactive.ml`** - `Fixpoint.apply_init_delta` when Remove arrives + +## Reproduction Steps + +1. In `Reanalyze.ml`, change: + ```ocaml + | Some merged -> Some (lazy (ReactiveLiveness.create ~merged)) + ``` + to: + ```ocaml + | Some merged -> Some (ReactiveLiveness.create ~merged) + ``` + +2. Update usage to not force lazy: + ```ocaml + | Some merged, Some liveness_result -> + let live = liveness_result.ReactiveLiveness.live in + ``` + +3. Run: + ```bash + cd tests/analysis_tests/tests-reanalyze/deadcode + dune exec rescript-editor-analysis -- reanalyze -config -ci -reactive + ``` + +4. Compare with non-reactive: + ```bash + dune exec rescript-editor-analysis -- reanalyze -config -ci + ``` + +## Debug Infrastructure + +A `trace_edges` flag exists in `Reactive.ml` inside the `Fixpoint` module: + +```ocaml +module Fixpoint = struct + let trace_edges = false (* Set to true to debug *) + ... +end +``` + +When enabled, it prints: +- `EDGE: Set source with N successors` when edge deltas arrive + +## Potential Fixes + +### Option 1: Fix Remove propagation in join/union + +Debug why Remove deltas from the join don't correctly propagate through the union to the fixpoint. The join's `handle_right_delta` should trigger reprocessing that emits Removes. + +### Option 2: Ensure edges before init + +Modify the fixpoint to process all edges before processing any init deltas. This would require buffering or ordering guarantees. + +### Option 3: Two-phase subscription + +Subscribe to edges first, wait for them to stabilize, then subscribe to init. This is complex and may not be feasible in a streaming model. + +### Option 4: Barrier/synchronization point + +Introduce a barrier that ensures all file data has flowed before the fixpoint starts computing. This is essentially what `Lazy.t` does. + +## Files Involved + +- `analysis/reanalyze/src/ReactiveLiveness.ml` - Creates the reactive liveness pipeline +- `analysis/reanalyze/src/Reanalyze.ml` - Creates and uses ReactiveLiveness +- `analysis/reactive/src/Reactive.ml` - Core reactive combinators (join, union, fixpoint) + +## Priority + +**Medium** - The Lazy.t workaround is effective and has minimal performance impact (creation happens once per session). However, this blocks true incremental updates for file changes. + diff --git a/analysis/reanalyze/src/ReactiveLiveness.ml b/analysis/reanalyze/src/ReactiveLiveness.ml index 4c1af10665..88cc422d83 100644 --- a/analysis/reanalyze/src/ReactiveLiveness.ml +++ b/analysis/reanalyze/src/ReactiveLiveness.ml @@ -6,8 +6,14 @@ Uses pure reactive combinators - no internal hashtables. *) +type t = { + live: (Lexing.position, unit) Reactive.t; + edges: (Lexing.position, Lexing.position list) Reactive.t; + roots: (Lexing.position, unit) Reactive.t; +} + (** Compute reactive liveness from ReactiveMerge.t *) -let create ~(merged : ReactiveMerge.t) : (Lexing.position, unit) Reactive.t = +let create ~(merged : ReactiveMerge.t) : t = let decls = merged.decls in let annotations = merged.annotations in @@ -46,22 +52,13 @@ let create ~(merged : ReactiveMerge.t) : (Lexing.position, unit) Reactive.t = This matches the non-reactive algorithm which uses DeclarationStore.find_opt. - We use flatMap and check decls synchronously within the function. - This works correctly regardless of delta arrival order because the check - happens at evaluation time when decls has current data. *) - (* Compute externally referenced positions reactively. - A position is externally referenced if any reference to it comes from - a position that is NOT a declaration position (exact match). - - This matches the non-reactive algorithm which uses DeclarationStore.find_opt. - - We use flatMap and check decls synchronously within the function. - This works correctly regardless of delta arrival order because the check - happens at evaluation time when decls has current data. *) + We use join to explicitly track the dependency on decls. When a decl at + position P arrives, any ref with posFrom=P will be reprocessed. *) let external_value_refs : (Lexing.position, unit) Reactive.t = - Reactive.flatMap value_refs_from - ~f:(fun posFrom targets -> - match decls.get posFrom with + Reactive.join value_refs_from decls + ~key_of:(fun posFrom _targets -> posFrom) + ~f:(fun _posFrom targets decl_opt -> + match decl_opt with | Some _ -> (* posFrom IS a decl position, refs are internal *) [] @@ -73,9 +70,10 @@ let create ~(merged : ReactiveMerge.t) : (Lexing.position, unit) Reactive.t = in let external_type_refs : (Lexing.position, unit) Reactive.t = - Reactive.flatMap type_refs_from - ~f:(fun posFrom targets -> - match decls.get posFrom with + Reactive.join type_refs_from decls + ~key_of:(fun posFrom _targets -> posFrom) + ~f:(fun _posFrom targets decl_opt -> + match decl_opt with | Some _ -> (* posFrom IS a decl position, refs are internal *) [] @@ -113,4 +111,5 @@ let create ~(merged : ReactiveMerge.t) : (Lexing.position, unit) Reactive.t = in (* Step 4: Compute fixpoint - all reachable positions from roots *) - Reactive.fixpoint ~init:all_roots ~edges () + let live = Reactive.fixpoint ~init:all_roots ~edges () in + {live; edges; roots = all_roots} diff --git a/analysis/reanalyze/src/ReactiveLiveness.mli b/analysis/reanalyze/src/ReactiveLiveness.mli index a523e6500b..9e95ed0707 100644 --- a/analysis/reanalyze/src/ReactiveLiveness.mli +++ b/analysis/reanalyze/src/ReactiveLiveness.mli @@ -2,8 +2,18 @@ Computes the set of live declarations incrementally. *) -val create : merged:ReactiveMerge.t -> (Lexing.position, unit) Reactive.t +type t = { + live: (Lexing.position, unit) Reactive.t; + edges: (Lexing.position, Lexing.position list) Reactive.t; + roots: (Lexing.position, unit) Reactive.t; +} + +val create : merged:ReactiveMerge.t -> t (** [create ~merged] computes reactive liveness from merged DCE data. - Returns a reactive collection where presence indicates the position is live. + Returns a record containing: + - live: positions that are live (via fixpoint) + - edges: declaration → referenced positions + - roots: initial live positions (annotated + externally referenced) + Updates automatically when any input changes. *) diff --git a/analysis/reanalyze/src/Reanalyze.ml b/analysis/reanalyze/src/Reanalyze.ml index ac08e68bcf..3487143de6 100644 --- a/analysis/reanalyze/src/Reanalyze.ml +++ b/analysis/reanalyze/src/Reanalyze.ml @@ -368,9 +368,10 @@ let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge let empty_optional_args_state = OptionalArgsState.create () in let analysis_result_core = match (reactive_merge, reactive_liveness) with - | Some merged, Some live_lazy -> - (* Reactive mode: use lazy reactive liveness (created on first force) *) - let live = Lazy.force live_lazy in + | Some merged, Some liveness_lazy -> + (* Force lazy liveness (after files processed) *) + let liveness_result = Lazy.force liveness_lazy in + let live = liveness_result.ReactiveLiveness.live in (* Freeze refs for debug/transitive support in solver *) let refs = ReactiveMerge.freeze_refs merged in DeadCommon.solveDeadReactive ~ann_store ~decl_store ~refs ~live @@ -449,7 +450,10 @@ let runAnalysisAndReport ~cmtRoot = Some (ReactiveMerge.create file_data_collection) | None -> None in - (* Lazy reactive liveness - created on first force (after files processed) *) + (* Lazy reactive liveness - created on first force (after files processed). + Note: The reactive pipeline has an order-dependence issue where deltas + must be processed in a specific order. Using Lazy defers creation until + after all files are processed, ensuring correct results. *) let reactive_liveness = match reactive_merge with | Some merged -> Some (lazy (ReactiveLiveness.create ~merged)) From 2517c32dfd6928e6ab037d89235cc0e0f50ca8df Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 16:12:42 +0100 Subject: [PATCH 14/45] Fix reactive fixpoint re-derivation on removals --- analysis/reactive/src/Reactive.ml | 92 +++- analysis/reactive/test/ReactiveTest.ml | 487 ++++++++++++++++++ .../BUG-reactive-order-dependence.md | 143 ----- analysis/reanalyze/src/Reanalyze.ml | 12 +- .../deadcode-minimal/.gitignore | 5 + .../deadcode-minimal/README.md | 36 ++ .../deadcode-minimal/package.json | 11 + .../deadcode-minimal/rescript.json | 13 + .../deadcode-minimal/src/DeadTest.res | 9 + 9 files changed, 643 insertions(+), 165 deletions(-) delete mode 100644 analysis/reanalyze/BUG-reactive-order-dependence.md create mode 100644 tests/analysis_tests/tests-reanalyze/deadcode-minimal/.gitignore create mode 100644 tests/analysis_tests/tests-reanalyze/deadcode-minimal/README.md create mode 100644 tests/analysis_tests/tests-reanalyze/deadcode-minimal/package.json create mode 100644 tests/analysis_tests/tests-reanalyze/deadcode-minimal/rescript.json create mode 100644 tests/analysis_tests/tests-reanalyze/deadcode-minimal/src/DeadTest.res diff --git a/analysis/reactive/src/Reactive.ml b/analysis/reactive/src/Reactive.ml index 62958a6bfd..5b09214d0c 100644 --- a/analysis/reactive/src/Reactive.ml +++ b/analysis/reactive/src/Reactive.ml @@ -469,9 +469,6 @@ let union (left : ('k, 'v) t) (right : ('k, 'v) t) ?merge () : ('k, 'v) t = support to each other, ensuring unreachable cycles are correctly removed. *) module Fixpoint = struct - (* DEBUG: flag for verbose tracing *) - let trace_edges = false - type 'k state = { current: ('k, unit) Hashtbl.t; (* Current fixpoint set *) rank: ('k, int) Hashtbl.t; (* BFS distance from base *) @@ -648,12 +645,48 @@ module Fixpoint = struct if not (Hashtbl.mem state.base k) then ([], []) (* Not in base, no change *) else ( + (* Mirror the verified algorithm's contraction+re-derivation pattern: + removing from base can invalidate the previously-shortest witness rank + for reachable nodes, so contraction alone can remove nodes incorrectly. + We contract first, then attempt to re-derive removed nodes via surviving + predecessors (using the inverse index), then expand. *) Hashtbl.remove state.base k; - (* Start contraction if k was in current *) - if Hashtbl.mem state.current k then - let removed = contract state ~worklist:[k] in - ([], removed) - else ([], [])) + + let contraction_worklist = + if Hashtbl.mem state.current k then [k] else [] + in + let all_removed = + if contraction_worklist <> [] then + contract state ~worklist:contraction_worklist + else [] + in + + let expansion_frontier = ref [] in + let removed_set = Hashtbl.create (List.length all_removed) in + List.iter (fun x -> Hashtbl.replace removed_set x ()) all_removed; + + if Hashtbl.length removed_set > 0 then + Hashtbl.iter + (fun y () -> + iter_step_inv state y (fun x -> + if Hashtbl.mem state.current x then + expansion_frontier := y :: !expansion_frontier)) + removed_set; + + let all_added = + if !expansion_frontier <> [] then + expand state ~frontier:!expansion_frontier + else [] + in + + let net_removed = + List.filter (fun x -> not (Hashtbl.mem state.current x)) all_removed + in + let net_added = + List.filter (fun x -> not (Hashtbl.mem removed_set x)) all_added + in + + (net_added, net_removed)) (* Compute edge diff between old and new successors *) let compute_edge_diff old_succs new_succs = @@ -672,9 +705,6 @@ module Fixpoint = struct let apply_edges_delta state delta = match delta with | Set (source, new_succs) -> - if trace_edges && new_succs <> [] then - Printf.eprintf "EDGE: Set source with %d successors\n%!" - (List.length new_succs); let old_succs = match Hashtbl.find_opt state.edges source with | None -> [] @@ -758,13 +788,45 @@ module Fixpoint = struct then contraction_worklist := target :: !contraction_worklist) old_succs; - let removed = + let all_removed = if !contraction_worklist <> [] then contract state ~worklist:!contraction_worklist else [] in - ([], removed) + (* Check if any removed element can be re-derived via remaining edges. + This mirrors the reference implementation's "step 7" re-derivation pass: + removing an edge can invalidate the previously-shortest witness rank for a + node while preserving reachability via a longer path. Contraction alone + can incorrectly remove such nodes because their stored rank is stale/too low. *) + let expansion_frontier = ref [] in + + let removed_set = Hashtbl.create (List.length all_removed) in + List.iter (fun x -> Hashtbl.replace removed_set x ()) all_removed; + + if Hashtbl.length removed_set > 0 then + Hashtbl.iter + (fun y () -> + iter_step_inv state y (fun x -> + if Hashtbl.mem state.current x then + expansion_frontier := y :: !expansion_frontier)) + removed_set; + + let all_added = + if !expansion_frontier <> [] then + expand state ~frontier:!expansion_frontier + else [] + in + + (* Compute net changes *) + let net_removed = + List.filter (fun x -> not (Hashtbl.mem state.current x)) all_removed + in + let net_added = + List.filter (fun x -> not (Hashtbl.mem removed_set x)) all_added + in + + (net_added, net_removed) end (** Compute transitive closure via incremental fixpoint. @@ -815,9 +877,11 @@ let fixpoint ~(init : ('k, unit) t) ~(edges : ('k, 'k list) t) () : ('k, unit) t state.edges; (* Then process init elements *) + let initial_frontier = ref [] in init.iter (fun k () -> Hashtbl.replace state.base k (); - ignore (Fixpoint.expand state ~frontier:[k])); + initial_frontier := k :: !initial_frontier); + ignore (Fixpoint.expand state ~frontier:!initial_frontier); { subscribe = (fun handler -> subscribers := handler :: !subscribers); diff --git a/analysis/reactive/test/ReactiveTest.ml b/analysis/reactive/test/ReactiveTest.ml index a48ce396cc..076a04fdcb 100644 --- a/analysis/reactive/test/ReactiveTest.ml +++ b/analysis/reactive/test/ReactiveTest.ml @@ -1171,6 +1171,486 @@ let test_fixpoint_deltas () = (* 1, 2, 3, 4, 5 removed *) Printf.printf "PASSED\n\n" +(* Test: Remove from init but still reachable via edges + This test reproduces a real bug found in the dead code analysis: + - A reference arrives before its declaration exists + - The reference target is incorrectly marked as "externally referenced" (a root) + - When the declaration arrives, the target is removed from roots + - But it should remain live because it's reachable from other live nodes + + Graph: root (true root) -> a -> b + Scenario: + 1. Initially, b is spuriously in init (before we know it has a declaration) + 2. Later, b is removed from init (when declaration is discovered) + 3. Bug: b incorrectly removed from fixpoint + 4. Correct: b should stay live (reachable via root -> a -> b) *) +let test_fixpoint_remove_spurious_root () = + Printf.printf + "=== Test: fixpoint remove spurious root (still reachable) ===\n"; + + let init, emit_init, _ = create_mutable_collection () in + let edges, emit_edges, _ = create_mutable_collection () in + + let fp = Reactive.fixpoint ~init ~edges () in + + (* Track all deltas *) + let added = ref [] in + let removed = ref [] in + fp.subscribe (function + | Set (k, ()) -> added := k :: !added + | Remove k -> removed := k :: !removed); + + (* Step 1: "b" is spuriously marked as a root + (in the real bug, this happens when a reference arrives before its declaration) *) + emit_init (Set ("b", ())); + Printf.printf "After spurious root b: fp=[%s]\n" + (let items = ref [] in + fp.iter (fun k _ -> items := k :: !items); + String.concat ", " (List.sort String.compare !items)); + assert (Reactive.get fp "b" = Some ()); + + (* Step 2: The real root "root" is added *) + emit_init (Set ("root", ())); + Printf.printf "After true root: fp=[%s]\n" + (let items = ref [] in + fp.iter (fun k _ -> items := k :: !items); + String.concat ", " (List.sort String.compare !items)); + + (* Step 3: Edge root -> a is added *) + emit_edges (Set ("root", ["a"])); + Printf.printf "After edge root->a: fp=[%s]\n" + (let items = ref [] in + fp.iter (fun k _ -> items := k :: !items); + String.concat ", " (List.sort String.compare !items)); + assert (Reactive.get fp "a" = Some ()); + + (* Step 4: Edge a -> b is added *) + emit_edges (Set ("a", ["b"])); + Printf.printf "After edge a->b: fp=[%s]\n" + (let items = ref [] in + fp.iter (fun k _ -> items := k :: !items); + String.concat ", " (List.sort String.compare !items)); + + (* At this point: root, a, b are all in fixpoint *) + assert (Reactive.length fp = 3); + + (* Clear tracked changes *) + added := []; + removed := []; + + (* Step 5: The spurious root "b" is REMOVED from init + (in real bug, this happens when declaration for b is discovered, + showing b is NOT externally referenced - just referenced by a) + + BUG: b gets removed from fixpoint + CORRECT: b should stay because it's still reachable via root -> a -> b *) + emit_init (Remove "b"); + + Printf.printf "After removing b from init: fp=[%s]\n" + (let items = ref [] in + fp.iter (fun k _ -> items := k :: !items); + String.concat ", " (List.sort String.compare !items)); + Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); + + (* b should NOT be removed - still reachable via a *) + assert (not (List.mem "b" !removed)); + assert (Reactive.get fp "b" = Some ()); + assert (Reactive.length fp = 3); + + Printf.printf "PASSED\n\n" + +(* Test: Remove entire edge entry but target still reachable via other source + + This tests the `Remove source` case in apply_edges_delta. + When an entire edge entry is removed, targets may still be reachable + via edges from OTHER sources. + + Graph: a -> b, c -> b (b has two derivations) + Scenario: + 1. a and c are roots + 2. Both derive b + 3. Remove the entire edge entry for "a" (Remove "a" from edges) + 4. b should stay (still reachable via c -> b) *) +let test_fixpoint_remove_edge_entry_alternative_source () = + Printf.printf + "=== Test: fixpoint remove edge entry (alternative source) ===\n"; + + let init, emit_init, _ = create_mutable_collection () in + let edges, emit_edges, edges_tbl = create_mutable_collection () in + + (* Set up initial edges: a -> b, c -> b *) + Hashtbl.replace edges_tbl "a" ["b"]; + Hashtbl.replace edges_tbl "c" ["b"]; + + let fp = Reactive.fixpoint ~init ~edges () in + + (* Track changes *) + let removed = ref [] in + fp.subscribe (function + | Remove k -> removed := k :: !removed + | _ -> ()); + + (* Add roots a and c *) + emit_init (Set ("a", ())); + emit_init (Set ("c", ())); + + Printf.printf "Initial: fp=[%s]\n" + (let items = ref [] in + fp.iter (fun k _ -> items := k :: !items); + String.concat ", " (List.sort String.compare !items)); + + (* Should have a, b, c *) + assert (Reactive.length fp = 3); + assert (Reactive.get fp "a" = Some ()); + assert (Reactive.get fp "b" = Some ()); + assert (Reactive.get fp "c" = Some ()); + + removed := []; + + (* Remove entire edge entry for "a" *) + emit_edges (Remove "a"); + + Printf.printf "After Remove edge entry 'a': fp=[%s]\n" + (let items = ref [] in + fp.iter (fun k _ -> items := k :: !items); + String.concat ", " (List.sort String.compare !items)); + Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); + + (* b should NOT be removed - still reachable via c -> b *) + assert (not (List.mem "b" !removed)); + assert (Reactive.get fp "b" = Some ()); + assert (Reactive.length fp = 3); + + Printf.printf "PASSED\n\n" + +(* Test: Remove edge entry - target reachable via higher-ranked source + + This is the subtle case where re-derivation check matters. + When contraction removes an element, but a surviving source with + HIGHER rank still points to it, well-founded check fails but + re-derivation should save it. + + Graph: root -> a -> b -> c, a -> c (c reachable via b and directly from a) + Key: c is first reached via a (rank 1), not via b (rank 2) + When we remove a->c edge, c still has b->c, but rank[b] >= rank[c] + so well-founded check fails. But c should be re-derived from b. +*) +let test_fixpoint_remove_edge_rederivation () = + Printf.printf "=== Test: fixpoint remove edge (re-derivation needed) ===\n"; + + let init, emit_init, _ = create_mutable_collection () in + let edges, emit_edges, _ = create_mutable_collection () in + + let fp = Reactive.fixpoint ~init ~edges () in + + (* Track changes *) + let removed = ref [] in + let added = ref [] in + fp.subscribe (function + | Remove k -> removed := k :: !removed + | Set (k, ()) -> added := k :: !added); + + (* Add root *) + emit_init (Set ("root", ())); + + (* Build graph: root -> a -> b -> c, a -> c *) + emit_edges (Set ("root", ["a"])); + emit_edges (Set ("a", ["b"; "c"])); + (* a reaches both b and c *) + emit_edges (Set ("b", ["c"])); + + (* b also reaches c *) + Printf.printf "Initial: fp=[%s]\n" + (let items = ref [] in + fp.iter (fun k _ -> items := k :: !items); + String.concat ", " (List.sort String.compare !items)); + + (* Should have root, a, b, c *) + assert (Reactive.length fp = 4); + + (* Check ranks: root=0, a=1, b=2, c=2 (reached from a at level 2) *) + (* Actually c could be rank 2 (from a) or rank 3 (from b) - depends on BFS order *) + removed := []; + added := []; + + (* Remove the direct edge a -> c *) + emit_edges (Set ("a", ["b"])); + + (* a now only reaches b *) + Printf.printf "After removing a->c: fp=[%s]\n" + (let items = ref [] in + fp.iter (fun k _ -> items := k :: !items); + String.concat ", " (List.sort String.compare !items)); + Printf.printf "Removed: [%s], Added: [%s]\n" + (String.concat ", " !removed) + (String.concat ", " !added); + + (* c should still be in fixpoint - reachable via root -> a -> b -> c *) + assert (Reactive.get fp "c" = Some ()); + assert (Reactive.length fp = 4); + + Printf.printf "PASSED\n\n" + +(* Test: Remove edge ENTRY (not Set) - re-derivation case + + This specifically tests the `Remove source` case which uses emit_edges (Remove ...) + rather than emit_edges (Set (..., [])). + + Graph: a -> c, b -> c (c reachable from both) + BFS: a (0), b (0), c (1) + Key: c has rank 1, both a and b have rank 0 + + When we remove the entire edge entry for "a" via Remove (not Set), + c loses derivation from a but should survive via b -> c. + + With equal ranks, well-founded check should still find b as support. +*) +let test_fixpoint_remove_edge_entry_rederivation () = + Printf.printf "=== Test: fixpoint Remove edge entry (re-derivation) ===\n"; + + let init, emit_init, _ = create_mutable_collection () in + let edges, emit_edges, edges_tbl = create_mutable_collection () in + + (* Set up edges before creating fixpoint *) + Hashtbl.replace edges_tbl "a" ["c"]; + Hashtbl.replace edges_tbl "b" ["c"]; + + let fp = Reactive.fixpoint ~init ~edges () in + + (* Track changes *) + let removed = ref [] in + fp.subscribe (function + | Remove k -> removed := k :: !removed + | _ -> ()); + + (* Add roots a and b *) + emit_init (Set ("a", ())); + emit_init (Set ("b", ())); + + Printf.printf "Initial: fp=[%s]\n" + (let items = ref [] in + fp.iter (fun k _ -> items := k :: !items); + String.concat ", " (List.sort String.compare !items)); + + assert (Reactive.length fp = 3); + + removed := []; + + (* Remove entire edge entry for "a" using Remove delta *) + emit_edges (Remove "a"); + + Printf.printf "After Remove 'a' entry: fp=[%s]\n" + (let items = ref [] in + fp.iter (fun k _ -> items := k :: !items); + String.concat ", " (List.sort String.compare !items)); + Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); + + (* c should survive - b -> c still exists *) + assert (not (List.mem "c" !removed)); + assert (Reactive.get fp "c" = Some ()); + assert (Reactive.length fp = 3); + + Printf.printf "PASSED\n\n" + +(* Test: Remove edge entry - surviving predecessor has HIGHER rank + + This is the critical case where re-derivation is needed. + When well-founded check fails (rank[predecessor] >= rank[target]), + the target dies. But if the predecessor is surviving, target + should be re-derived with new rank. + + Graph: root -> a -> c, root -> b, b -> c + BFS order matters here: + - Level 0: root + - Level 1: a, b (both from root) + - Level 2: c (from a, since a is processed first) + + c has rank 2, b has rank 1 + inv_index[c] = [a, b] + + When we remove "a" entry: + - c goes into contraction + - inv_index[c] = [b] + - rank[b] = 1 < rank[c] = 2, so b provides support! + + Hmm, this still finds support because b has lower rank. + + Let me try: root -> a -> b -> c, a -> c + - Level 0: root + - Level 1: a + - Level 2: b, c (both from a, same level) + + c has rank 2, b has rank 2 + When we remove a->c edge (not entry), c goes into contraction + inv_index[c] = [b] (after a removed), rank[b] = rank[c] = 2 + NO support (2 < 2 is false), c dies + + But b -> c exists! c should be re-derived with rank 3. + + This is the Set case, not Remove. But the logic should be similar. +*) +let test_fixpoint_remove_edge_entry_higher_rank_support () = + Printf.printf "=== Test: fixpoint edge removal (higher rank support) ===\n"; + + let init, emit_init, _ = create_mutable_collection () in + let edges, emit_edges, _ = create_mutable_collection () in + + let fp = Reactive.fixpoint ~init ~edges () in + + (* Track changes *) + let removed = ref [] in + let added = ref [] in + fp.subscribe (function + | Remove k -> removed := k :: !removed + | Set (k, ()) -> added := k :: !added); + + (* Add root *) + emit_init (Set ("root", ())); + + (* Build graph: root -> a -> b -> c, a -> c *) + emit_edges (Set ("root", ["a"])); + emit_edges (Set ("a", ["b"; "c"])); + (* a reaches both b and c at same level *) + emit_edges (Set ("b", ["c"])); + + (* b also reaches c *) + Printf.printf "Initial: fp=[%s]\n" + (let items = ref [] in + fp.iter (fun k _ -> items := k :: !items); + String.concat ", " (List.sort String.compare !items)); + + assert (Reactive.length fp = 4); + assert (Reactive.get fp "c" = Some ()); + + removed := []; + added := []; + + (* Remove direct edge a -> c, keeping a -> b *) + emit_edges (Set ("a", ["b"])); + + Printf.printf "After removing a->c: fp=[%s]\n" + (let items = ref [] in + fp.iter (fun k _ -> items := k :: !items); + String.concat ", " (List.sort String.compare !items)); + Printf.printf "Removed: [%s], Added: [%s]\n" + (String.concat ", " !removed) + (String.concat ", " !added); + + (* c should still be in fixpoint via root -> a -> b -> c *) + (* The re-derivation check should save c even though rank[b] >= rank[c] *) + assert (Reactive.get fp "c" = Some ()); + assert (Reactive.length fp = 4); + + Printf.printf "PASSED\n\n" + +(* Test: Remove edge ENTRY (Remove source) where re-derivation is required. + + This is the classic counterexample: two paths to y, remove the shorter one. + Without the re-derivation step (reference implementation step 7), contraction + can incorrectly remove y because its stored rank is too low. + + Graph: + r -> a -> y (short path) + r -> b -> c -> x -> y (long path) + + Scenario: + 1) init = {r} + 2) y is reachable (via a->y) + 3) Remove edge entry for a (emit_edges (Remove "a")), which deletes the short path + 4) y must remain reachable via x->y + + Expected: y stays in fixpoint + Bug: y is removed if `apply_edges_delta` Remove-case lacks re-derivation. *) +let test_fixpoint_remove_edge_entry_needs_rederivation () = + Printf.printf + "=== Test: fixpoint Remove edge entry (needs re-derivation) ===\n"; + + let init, emit_init, _ = create_mutable_collection () in + let edges, emit_edges, edges_tbl = create_mutable_collection () in + + (* Pre-populate edges so fixpoint initializes with them *) + Hashtbl.replace edges_tbl "r" ["a"; "b"]; + Hashtbl.replace edges_tbl "a" ["y"]; + Hashtbl.replace edges_tbl "b" ["c"]; + Hashtbl.replace edges_tbl "c" ["x"]; + Hashtbl.replace edges_tbl "x" ["y"]; + + let fp = Reactive.fixpoint ~init ~edges () in + + (* Make r live *) + emit_init (Set ("r", ())); + + (* Sanity: y initially reachable via short path *) + assert (Reactive.get fp "y" = Some ()); + assert (Reactive.get fp "x" = Some ()); + + let removed = ref [] in + fp.subscribe (function + | Remove k -> removed := k :: !removed + | _ -> ()); + + (* Remove the entire edge entry for a (removes a->y) *) + emit_edges (Remove "a"); + + Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); + + (* Correct: y is still reachable via r->b->c->x->y *) + assert (Reactive.get fp "y" = Some ()); + + Printf.printf "PASSED\n\n" + +(* Test: Remove BASE element where re-derivation is required. + + Same shape as the verified counterexample, but triggered by removing a base element. + Verified algorithm applies the re-derivation step after contraction for removed-from-base too. + + Two roots r1 and r2. + Paths to y: + r1 -> a -> y (short path, determines initial rank(y)) + r2 -> b -> c -> x -> y (long path, still reachable after removing r1) + + Scenario: + 1) init = {r1, r2} + 2) y is reachable (via r1->a->y) + 3) Remove r1 from init (emit_init (Remove "r1")), which deletes the short witness + 4) y must remain reachable via r2->...->y *) +let test_fixpoint_remove_base_needs_rederivation () = + Printf.printf + "=== Test: fixpoint Remove base element (needs re-derivation) ===\n"; + + let init, emit_init, _ = create_mutable_collection () in + let edges, _emit_edges, edges_tbl = create_mutable_collection () in + + (* Pre-populate edges so fixpoint initializes with them *) + Hashtbl.replace edges_tbl "r1" ["a"]; + Hashtbl.replace edges_tbl "a" ["y"]; + Hashtbl.replace edges_tbl "r2" ["b"]; + Hashtbl.replace edges_tbl "b" ["c"]; + Hashtbl.replace edges_tbl "c" ["x"]; + Hashtbl.replace edges_tbl "x" ["y"]; + + let fp = Reactive.fixpoint ~init ~edges () in + + emit_init (Set ("r1", ())); + emit_init (Set ("r2", ())); + + (* Sanity: y initially reachable *) + assert (Reactive.get fp "y" = Some ()); + assert (Reactive.get fp "x" = Some ()); + + let removed = ref [] in + fp.subscribe (function + | Remove k -> removed := k :: !removed + | _ -> ()); + + (* Remove r1 from base: y should remain via r2 path *) + emit_init (Remove "r1"); + + Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); + + assert (Reactive.get fp "y" = Some ()); + Printf.printf "PASSED\n\n" + (* Test: Pre-existing data in init and edges *) let test_fixpoint_existing_data () = Printf.printf "=== Test: fixpoint with existing data ===\n"; @@ -1243,4 +1723,11 @@ let () = test_fixpoint_self_loop (); test_fixpoint_deltas (); test_fixpoint_existing_data (); + test_fixpoint_remove_spurious_root (); + test_fixpoint_remove_edge_entry_alternative_source (); + test_fixpoint_remove_edge_rederivation (); + test_fixpoint_remove_edge_entry_rederivation (); + test_fixpoint_remove_edge_entry_higher_rank_support (); + test_fixpoint_remove_edge_entry_needs_rederivation (); + test_fixpoint_remove_base_needs_rederivation (); Printf.printf "All tests passed!\n" diff --git a/analysis/reanalyze/BUG-reactive-order-dependence.md b/analysis/reanalyze/BUG-reactive-order-dependence.md deleted file mode 100644 index 5510b1664f..0000000000 --- a/analysis/reanalyze/BUG-reactive-order-dependence.md +++ /dev/null @@ -1,143 +0,0 @@ -# Bug Report: Reactive Pipeline Order-Dependence - -## Summary - -The reactive dead code analysis pipeline produces incorrect results when `ReactiveLiveness` is created before files are processed. This manifests as 2 extra false-positive dead value warnings (382 vs 380 issues). - -## Current Workaround - -Using `Lazy.t` in `Reanalyze.ml` to defer `ReactiveLiveness.create` until after all files are processed: - -```ocaml -let reactive_liveness = - match reactive_merge with - | Some merged -> Some (lazy (ReactiveLiveness.create ~merged)) - | None -> None -in -``` - -This avoids the issue but prevents true incremental updates when files change. - -## Symptoms - -When `ReactiveLiveness.create ~merged` is called **before** files are processed: - -| Metric | Non-Reactive | Reactive | -|--------|-------------|----------| -| Dead Values | 233 | 235 (+2) | -| Total Issues | 380 | 382 (+2) | -| Roots | ~129 | 327 (+198 spurious) | -| Live Positions | 629 | 635 (+6 spurious) | - -### Specific Incorrectly Dead Declarations - -1. `DeadTest.res:63:6` - `let y = 55` inside module MM -2. `Unison.res:19:16` - `group` function - -Both are correctly marked live in non-reactive mode. - -## Root Cause Analysis - -### The Reference Chain - -For `DeadTest.res:63:6` (`y`): -- Line 64: `let x = y` (x references y) -- Line 68: `Js.log(MM.x)` (x is externally referenced) -- Expected: y is live because x→y edge exists and x is live - -### What Goes Wrong - -The edge `64:6 → [63:6]` exists in `state.edges`, and `64:6` IS in `current`, but `63:6` is NOT marked live. - -**Delta ordering issue:** - -1. When `value_refs_from` emits before `decls`, the join marks targets as "externally referenced" -2. When `decls` emits later, the join should emit `Remove` deltas to correct the spurious external refs -3. But something in the Remove propagation through `union → fixpoint` breaks - -### Evidence from Debug Tracing - -``` -FIXPOINT stats: init_deltas=1850 edges_deltas=9147 total_added=1393 current=635 base=327 edges=711 -``` - -- `total_added=1393` but `current=635` → 758 elements were added then removed -- This suggests Remove deltas ARE being processed, but not correctly - -## Affected Code Paths - -1. **`ReactiveLiveness.ml`** - `external_value_refs` and `external_type_refs` joins -2. **`Reactive.ml`** - `join` combinator's Remove delta handling -3. **`Reactive.ml`** - `union` combinator's Remove delta propagation -4. **`Reactive.ml`** - `Fixpoint.apply_init_delta` when Remove arrives - -## Reproduction Steps - -1. In `Reanalyze.ml`, change: - ```ocaml - | Some merged -> Some (lazy (ReactiveLiveness.create ~merged)) - ``` - to: - ```ocaml - | Some merged -> Some (ReactiveLiveness.create ~merged) - ``` - -2. Update usage to not force lazy: - ```ocaml - | Some merged, Some liveness_result -> - let live = liveness_result.ReactiveLiveness.live in - ``` - -3. Run: - ```bash - cd tests/analysis_tests/tests-reanalyze/deadcode - dune exec rescript-editor-analysis -- reanalyze -config -ci -reactive - ``` - -4. Compare with non-reactive: - ```bash - dune exec rescript-editor-analysis -- reanalyze -config -ci - ``` - -## Debug Infrastructure - -A `trace_edges` flag exists in `Reactive.ml` inside the `Fixpoint` module: - -```ocaml -module Fixpoint = struct - let trace_edges = false (* Set to true to debug *) - ... -end -``` - -When enabled, it prints: -- `EDGE: Set source with N successors` when edge deltas arrive - -## Potential Fixes - -### Option 1: Fix Remove propagation in join/union - -Debug why Remove deltas from the join don't correctly propagate through the union to the fixpoint. The join's `handle_right_delta` should trigger reprocessing that emits Removes. - -### Option 2: Ensure edges before init - -Modify the fixpoint to process all edges before processing any init deltas. This would require buffering or ordering guarantees. - -### Option 3: Two-phase subscription - -Subscribe to edges first, wait for them to stabilize, then subscribe to init. This is complex and may not be feasible in a streaming model. - -### Option 4: Barrier/synchronization point - -Introduce a barrier that ensures all file data has flowed before the fixpoint starts computing. This is essentially what `Lazy.t` does. - -## Files Involved - -- `analysis/reanalyze/src/ReactiveLiveness.ml` - Creates the reactive liveness pipeline -- `analysis/reanalyze/src/Reanalyze.ml` - Creates and uses ReactiveLiveness -- `analysis/reactive/src/Reactive.ml` - Core reactive combinators (join, union, fixpoint) - -## Priority - -**Medium** - The Lazy.t workaround is effective and has minimal performance impact (creation happens once per session). However, this blocks true incremental updates for file changes. - diff --git a/analysis/reanalyze/src/Reanalyze.ml b/analysis/reanalyze/src/Reanalyze.ml index 3487143de6..d10a976ce7 100644 --- a/analysis/reanalyze/src/Reanalyze.ml +++ b/analysis/reanalyze/src/Reanalyze.ml @@ -368,9 +368,7 @@ let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge let empty_optional_args_state = OptionalArgsState.create () in let analysis_result_core = match (reactive_merge, reactive_liveness) with - | Some merged, Some liveness_lazy -> - (* Force lazy liveness (after files processed) *) - let liveness_result = Lazy.force liveness_lazy in + | Some merged, Some liveness_result -> let live = liveness_result.ReactiveLiveness.live in (* Freeze refs for debug/transitive support in solver *) let refs = ReactiveMerge.freeze_refs merged in @@ -450,13 +448,11 @@ let runAnalysisAndReport ~cmtRoot = Some (ReactiveMerge.create file_data_collection) | None -> None in - (* Lazy reactive liveness - created on first force (after files processed). - Note: The reactive pipeline has an order-dependence issue where deltas - must be processed in a specific order. Using Lazy defers creation until - after all files are processed, ensuring correct results. *) + (* Create reactive liveness. This is created before files are processed, + so it receives deltas as files are processed incrementally. *) let reactive_liveness = match reactive_merge with - | Some merged -> Some (lazy (ReactiveLiveness.create ~merged)) + | Some merged -> Some (ReactiveLiveness.create ~merged) | None -> None in for run = 1 to numRuns do diff --git a/tests/analysis_tests/tests-reanalyze/deadcode-minimal/.gitignore b/tests/analysis_tests/tests-reanalyze/deadcode-minimal/.gitignore new file mode 100644 index 0000000000..931e87a662 --- /dev/null +++ b/tests/analysis_tests/tests-reanalyze/deadcode-minimal/.gitignore @@ -0,0 +1,5 @@ +lib/ +node_modules/ +.bsb.lock +.DS_Store + diff --git a/tests/analysis_tests/tests-reanalyze/deadcode-minimal/README.md b/tests/analysis_tests/tests-reanalyze/deadcode-minimal/README.md new file mode 100644 index 0000000000..56e193426c --- /dev/null +++ b/tests/analysis_tests/tests-reanalyze/deadcode-minimal/README.md @@ -0,0 +1,36 @@ +# Deadcode Minimal + +This is a small end-to-end regression test for reactive deadcode analysis. + +Historically this directory was used to minimize an incremental fixpoint bug (re-derivation +after removals). The underlying issue has since been fixed; this test remains to prevent +regressions. + +## Test File (src/DeadTest.res) + +```rescript +module MM: { + let x: int + let y: int +} = { + let y = 55 // BUG: incorrectly marked dead in reactive mode + let x = y // live (externally referenced) +} + +let _ = Js.log(MM.x) // external reference to x +``` + +## Running + +```bash +# Build +../../../../cli/rescript.js build + +# Non-reactive (correct): 1 issue (signature y) +dune exec rescript-editor-analysis -- reanalyze -config -ci + +# Reactive +dune exec rescript-editor-analysis -- reanalyze -config -ci -reactive +``` + +Reactive and non-reactive should report the same results. diff --git a/tests/analysis_tests/tests-reanalyze/deadcode-minimal/package.json b/tests/analysis_tests/tests-reanalyze/deadcode-minimal/package.json new file mode 100644 index 0000000000..4e24ba92b7 --- /dev/null +++ b/tests/analysis_tests/tests-reanalyze/deadcode-minimal/package.json @@ -0,0 +1,11 @@ +{ + "name": "@tests/deadcode-minimal", + "private": true, + "scripts": { + "build": "rescript build", + "clean": "rescript clean" + }, + "dependencies": { + "rescript": "workspace:^" + } +} diff --git a/tests/analysis_tests/tests-reanalyze/deadcode-minimal/rescript.json b/tests/analysis_tests/tests-reanalyze/deadcode-minimal/rescript.json new file mode 100644 index 0000000000..9eaeb9bcd3 --- /dev/null +++ b/tests/analysis_tests/tests-reanalyze/deadcode-minimal/rescript.json @@ -0,0 +1,13 @@ +{ + "reanalyze": { + "analysis": ["dce"], + "transitive": true + }, + "name": "@tests/deadcode-minimal", + "sources": ["src"], + "package-specs": { + "module": "esmodule", + "in-source": false + }, + "suffix": ".res.js" +} diff --git a/tests/analysis_tests/tests-reanalyze/deadcode-minimal/src/DeadTest.res b/tests/analysis_tests/tests-reanalyze/deadcode-minimal/src/DeadTest.res new file mode 100644 index 0000000000..1722b49fa5 --- /dev/null +++ b/tests/analysis_tests/tests-reanalyze/deadcode-minimal/src/DeadTest.res @@ -0,0 +1,9 @@ +module MM: { + let x: int + let y: int +} = { + let y = 55 + let x = y +} + +let _ = Js.log(MM.x) From 9defed657b02ad46cd788e264f75a0727dbbc0a6 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 16:33:59 +0100 Subject: [PATCH 15/45] DCE: forward-model debug output (no refs_to) --- analysis/reanalyze/src/DeadCommon.ml | 78 +- analysis/reanalyze/src/Reanalyze.ml | 3 +- .../deadcode/expected/deadcode.txt | 1282 ++++++++--------- 3 files changed, 681 insertions(+), 682 deletions(-) diff --git a/analysis/reanalyze/src/DeadCommon.ml b/analysis/reanalyze/src/DeadCommon.ml index bcf9a73342..b00cea42e2 100644 --- a/analysis/reanalyze/src/DeadCommon.ml +++ b/analysis/reanalyze/src/DeadCommon.ml @@ -179,8 +179,9 @@ let isInsideReportedValue (ctx : ReportingContext.t) decl = insideReportedValue (** Report a dead declaration. Returns list of issues (dead module first, then dead value). - [refs_to_opt] is only needed when [config.run.transitive] is false. - Caller is responsible for logging. *) + [refs_to_opt] is only needed when [config.run.transitive] is false, since the + non-transitive mode suppresses some warnings when there are references "below" + the declaration (requires inverse refs). Caller is responsible for logging. *) let reportDeclaration ~config ~refs_to_opt (ctx : ReportingContext.t) decl : Issue.t list = let insideReportedValue = decl |> isInsideReportedValue ctx in @@ -267,9 +268,9 @@ let solveDeadForward ~ann_store ~config ~decl_store ~refs ~optional_args_state let transitive = config.DceConfig.run.transitive in let live = Liveness.compute_forward ~debug ~decl_store ~refs ~ann_store in - (* Lazily compute refs_to only if needed for debug or transitive *) + (* Inverse refs are only needed for non-transitive reporting (hasRefBelow). *) let refs_to_opt = - if debug || not transitive then Some (RefsToLazy.compute refs) else None + if not transitive then Some (RefsToLazy.compute refs) else None in (* Process each declaration based on computed liveness *) @@ -289,28 +290,18 @@ let solveDeadForward ~ann_store ~config ~decl_store ~refs ~optional_args_state let is_live = Option.is_some live_reason in let is_dead = not is_live in - (* Debug output with reason *) + (* Debug output (forward model): + show reachability + why (root/propagated), without inverse refs. *) (if debug then - match refs_to_opt with - | Some refs_to -> - let refs_set = - match decl |> Decl.isValue with - | true -> RefsToLazy.find_value_refs refs_to pos - | false -> RefsToLazy.find_type_refs refs_to pos - in - let status = - match live_reason with - | None -> "Dead" - | Some reason -> - Printf.sprintf "Live (%s)" (Liveness.reason_to_string reason) - in - Log_.item "%s %s %s: %d references (%s)@." status - (decl.declKind |> Decl.Kind.toString) - (decl.path |> DcePath.toString) - (refs_set |> PosSet.cardinal) - (refs_set |> PosSet.elements |> List.map Pos.toString - |> String.concat ", ") - | None -> ()); + let status = + match live_reason with + | None -> "Dead" + | Some reason -> + Printf.sprintf "Live (%s)" (Liveness.reason_to_string reason) + in + Log_.item "%s %s %s@." status + (decl.declKind |> Decl.Kind.toString) + (decl.path |> DcePath.toString)); decl.resolvedDead <- Some is_dead; @@ -358,7 +349,8 @@ let solveDeadForward ~ann_store ~config ~decl_store ~refs ~optional_args_state (** Reactive solver using reactive liveness collection. *) let solveDeadReactive ~ann_store ~config ~decl_store ~refs - ~(live : (Lexing.position, unit) Reactive.t) ~optional_args_state + ~(live : (Lexing.position, unit) Reactive.t) + ~(roots : (Lexing.position, unit) Reactive.t) ~optional_args_state ~checkOptionalArg: (checkOptionalArgFn : optional_args_state:OptionalArgsState.t -> @@ -370,9 +362,9 @@ let solveDeadReactive ~ann_store ~config ~decl_store ~refs let transitive = config.DceConfig.run.transitive in let is_live pos = Reactive.get live pos <> None in - (* Lazily compute refs_to only if needed for debug or transitive *) + (* Inverse refs are only needed for non-transitive reporting (hasRefBelow). *) let refs_to_opt = - if debug || not transitive then Some (RefsToLazy.compute refs) else None + if not transitive then Some (RefsToLazy.compute refs) else None in (* Process each declaration based on computed liveness *) @@ -391,19 +383,25 @@ let solveDeadReactive ~ann_store ~config ~decl_store ~refs let is_live = is_live pos in let is_dead = not is_live in - (* Debug output *) + (* Debug output (forward model): derive root/propagated from [roots]. *) (if debug then - match refs_to_opt with - | Some refs_to -> - let refs_set = RefsToLazy.find_value_refs refs_to pos in - let status = if is_live then "Live" else "Dead" in - Log_.item "%s %s %s: %d references (%s)@." status - (decl.declKind |> Decl.Kind.toString) - (decl.path |> DcePath.toString) - (refs_set |> PosSet.cardinal) - (refs_set |> PosSet.elements |> List.map Pos.toString - |> String.concat ", ") - | None -> ()); + let live_reason : Liveness.live_reason option = + if not is_live then None + else if Reactive.get roots pos <> None then + if AnnotationStore.is_annotated_gentype_or_live ann_store pos + then Some Liveness.Annotated + else Some Liveness.ExternalRef + else Some Liveness.Propagated + in + let status = + match live_reason with + | None -> "Dead" + | Some reason -> + Printf.sprintf "Live (%s)" (Liveness.reason_to_string reason) + in + Log_.item "%s %s %s@." status + (decl.declKind |> Decl.Kind.toString) + (decl.path |> DcePath.toString)); decl.resolvedDead <- Some is_dead; diff --git a/analysis/reanalyze/src/Reanalyze.ml b/analysis/reanalyze/src/Reanalyze.ml index d10a976ce7..d831fd958d 100644 --- a/analysis/reanalyze/src/Reanalyze.ml +++ b/analysis/reanalyze/src/Reanalyze.ml @@ -370,10 +370,11 @@ let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge match (reactive_merge, reactive_liveness) with | Some merged, Some liveness_result -> let live = liveness_result.ReactiveLiveness.live in + let roots = liveness_result.ReactiveLiveness.roots in (* Freeze refs for debug/transitive support in solver *) let refs = ReactiveMerge.freeze_refs merged in DeadCommon.solveDeadReactive ~ann_store ~decl_store ~refs ~live - ~optional_args_state:empty_optional_args_state + ~roots ~optional_args_state:empty_optional_args_state ~config:dce_config ~checkOptionalArg:(fun ~optional_args_state:_ ~ann_store:_ ~config:_ _ -> []) diff --git a/tests/analysis_tests/tests-reanalyze/deadcode/expected/deadcode.txt b/tests/analysis_tests/tests-reanalyze/deadcode/expected/deadcode.txt index 9a4c1c2d2e..7c0b83e43f 100644 --- a/tests/analysis_tests/tests-reanalyze/deadcode/expected/deadcode.txt +++ b/tests/analysis_tests/tests-reanalyze/deadcode/expected/deadcode.txt @@ -2158,647 +2158,647 @@ Forward Liveness Analysis 45 declarations marked live via propagation - Dead VariantCase +AutoAnnotate.variant.R: 0 references () - Dead RecordLabel +AutoAnnotate.record.variant: 0 references () - Dead RecordLabel +AutoAnnotate.r2.r2: 0 references () - Dead RecordLabel +AutoAnnotate.r3.r3: 0 references () - Dead RecordLabel +AutoAnnotate.r4.r4: 0 references () - Dead VariantCase +AutoAnnotate.annotatedVariant.R2: 0 references () - Dead VariantCase +AutoAnnotate.annotatedVariant.R4: 0 references () - Dead Value +BucklescriptAnnotations.+bar: 0 references () - Dead Value +BucklescriptAnnotations.+f: 1 references (BucklescriptAnnotations.res:22:4) - Live (annotated) Value +ComponentAsProp.+make: 0 references () - Live (external ref) RecordLabel +ComponentAsProp.props.title: 1 references (_none_:1:-1) - Live (external ref) RecordLabel +ComponentAsProp.props.description: 1 references (_none_:1:-1) - Live (external ref) RecordLabel +ComponentAsProp.props.button: 1 references (_none_:1:-1) - Live (external ref) Value +CreateErrorHandler1.Error1.+notification: 1 references (ErrorHandler.resi:3:2) - Live (external ref) Value +CreateErrorHandler2.Error2.+notification: 1 references (ErrorHandler.resi:3:2) - Live (external ref) Value +DeadCodeImplementation.M.+x: 1 references (DeadCodeInterface.res:2:2) - Live (external ref) Exception +DeadExn.Etoplevel: 1 references (DeadExn.res:8:16) - Live (external ref) Exception +DeadExn.Inside.Einside: 1 references (DeadExn.res:10:14) - Dead Exception +DeadExn.DeadE: 0 references () - Dead Value +DeadExn.+eToplevel: 0 references () - Live (external ref) Value +DeadExn.+eInside: 1 references (DeadExn.res:12:7) - Live (propagated) VariantCase +DeadRT.moduleAccessPath.Root: 1 references (DeadRT.resi:2:2) - Live (external ref) VariantCase +DeadRT.moduleAccessPath.Kaboom: 2 references (DeadRT.res:11:16, DeadRT.resi:3:2) - Dead Value +DeadRT.+emitModuleAccessPath: 0 references () - Live (external ref) VariantCase DeadRT.moduleAccessPath.Root: 2 references (DeadRT.res:2:2, DeadTest.res:98:16) - Live (propagated) VariantCase DeadRT.moduleAccessPath.Kaboom: 1 references (DeadRT.res:3:2) - Dead Value +DeadTest.+fortytwo: 0 references () - Live (annotated) Value +DeadTest.+fortyTwoButExported: 0 references () - Live (external ref) Value +DeadTest.+thisIsUsedOnce: 1 references (DeadTest.res:8:7) - Live (external ref) Value +DeadTest.+thisIsUsedTwice: 2 references (DeadTest.res:11:7, DeadTest.res:12:7) - Dead Value +DeadTest.+thisIsMarkedDead: 0 references () - Live (propagated) Value +DeadTest.+thisIsKeptAlive: 1 references (DeadTest.res:20:4) - Live (annotated) Value +DeadTest.+thisIsMarkedLive: 0 references () - Dead Value +DeadTest.Inner.+thisIsAlsoMarkedDead: 0 references () - Dead Value +DeadTest.M.+thisSignatureItemIsDead: 0 references () - Dead Value +DeadTest.M.+thisSignatureItemIsDead: 1 references (DeadTest.res:28:2) - Live (propagated) VariantCase +DeadTest.VariantUsedOnlyInImplementation.t.A: 1 references (DeadTest.res:38:11) - Live (external ref) Value +DeadTest.VariantUsedOnlyInImplementation.+a: 1 references (DeadTest.res:42:17) - Live (external ref) VariantCase +DeadTest.VariantUsedOnlyInImplementation.t.A: 2 references (DeadTest.res:35:11, DeadTest.res:39:10) - Live (propagated) Value +DeadTest.VariantUsedOnlyInImplementation.+a: 1 references (DeadTest.res:36:2) - Dead Value +DeadTest.+_: 0 references () - Dead Value +DeadTest.+_: 0 references () - Live (external ref) RecordLabel +DeadTest.record.xxx: 1 references (DeadTest.res:52:13) - Live (external ref) RecordLabel +DeadTest.record.yyy: 1 references (DeadTest.res:53:9) - Dead Value +DeadTest.+_: 0 references () - Dead Value +DeadTest.+_: 0 references () - Dead Value +DeadTest.UnderscoreInside.+_: 0 references () - Live (external ref) Value +DeadTest.MM.+x: 1 references (DeadTest.res:69:9) - Dead Value +DeadTest.MM.+y: 0 references () - Live (propagated) Value +DeadTest.MM.+y: 2 references (DeadTest.res:61:2, DeadTest.res:64:6) - Live (propagated) Value +DeadTest.MM.+x: 1 references (DeadTest.res:60:2) - Dead Value +DeadTest.MM.+valueOnlyInImplementation: 0 references () - Dead Value +DeadTest.+unusedRec: 1 references (DeadTest.res:75:8) - Dead Value +DeadTest.+split_map: 1 references (DeadTest.res:77:8) - Dead Value +DeadTest.+rec1: 1 references (DeadTest.res:83:4) - Dead Value +DeadTest.+rec2: 1 references (DeadTest.res:82:8) - Dead Value +DeadTest.+recWithCallback: 1 references (DeadTest.res:86:6) - Dead Value +DeadTest.+cb: 1 references (DeadTest.res:85:8) - Dead Value +DeadTest.+foo: 1 references (DeadTest.res:94:4) - Dead Value +DeadTest.+cb: 1 references (DeadTest.res:90:8) - Dead Value +DeadTest.+bar: 1 references (DeadTest.res:91:6) - Dead Value +DeadTest.+withDefaultValue: 0 references () - Dead Value +DeadTest.+zzz: 0 references () - Dead Value +DeadTest.+a1: 0 references () - Dead Value +DeadTest.+a2: 0 references () - Dead Value +DeadTest.+a3: 0 references () - Dead Value +DeadTest.+second: 0 references () - Dead Value +DeadTest.+deadRef: 0 references () - Live (external ref) Value +DeadTest.+make: 1 references (DeadTest.res:119:16) - Live (external ref) RecordLabel +DeadTest.props.s: 1 references (_none_:1:-1) - Dead Value +DeadTest.+theSideEffectIsLogging: 0 references () - Dead Value +DeadTest.+stringLengthNoSideEffects: 0 references () - Live (annotated) Value +DeadTest.GloobLive.+globallyLive1: 0 references () - Live (annotated) Value +DeadTest.GloobLive.+globallyLive2: 0 references () - Live (annotated) Value +DeadTest.GloobLive.+globallyLive3: 0 references () - Live (external ref) VariantCase +DeadTest.WithInclude.t.A: 2 references (DeadTest.res:137:13, DeadTest.res:142:7) - Live (propagated) VariantCase +DeadTest.WithInclude.t.A: 1 references (DeadTest.res:134:11) - Dead Value +DeadTest.+funWithInnerVars: 0 references () - Dead Value +DeadTest.+x: 1 references (DeadTest.res:145:4) - Dead Value +DeadTest.+y: 1 references (DeadTest.res:145:4) - Dead RecordLabel +DeadTest.rc.a: 0 references () - Live (external ref) Value +DeadTest.+deadIncorrect: 1 references (DeadTest.res:156:8) - Dead Value +DeadTest.+_: 0 references () - Live (external ref) VariantCase +DeadTest.inlineRecord.IR: 1 references (DeadTest.res:163:20) - Dead RecordLabel +DeadTest.inlineRecord.IR.a: 0 references () - Live (external ref) RecordLabel +DeadTest.inlineRecord.IR.b: 1 references (DeadTest.res:163:35) - Live (external ref) RecordLabel +DeadTest.inlineRecord.IR.c: 1 references (DeadTest.res:163:7) - Dead RecordLabel +DeadTest.inlineRecord.IR.d: 0 references () - Live (annotated) RecordLabel +DeadTest.inlineRecord.IR.e: 0 references () - Live (external ref) Value +DeadTest.+ira: 1 references (DeadTest.res:163:27) - Dead Value +DeadTest.+_: 0 references () - Dead VariantCase +DeadTest.inlineRecord2.IR2: 0 references () - Dead RecordLabel +DeadTest.inlineRecord2.IR2.a: 0 references () - Dead RecordLabel +DeadTest.inlineRecord2.IR2.b: 0 references () - Dead VariantCase +DeadTest.inlineRecord3.IR3: 0 references () - Dead RecordLabel +DeadTest.inlineRecord3.IR3.a: 0 references () - Dead RecordLabel +DeadTest.inlineRecord3.IR3.b: 0 references () - Dead Value +DeadTestBlacklist.+x: 0 references () - Dead Value +DeadTestWithInterface.Ext_buffer.+x: 0 references () - Dead Value +DeadTestWithInterface.Ext_buffer.+x: 1 references (DeadTestWithInterface.res:2:2) - Live (external ref) VariantCase +DeadTypeTest.t.A: 2 references (DeadTypeTest.res:4:8, DeadTypeTest.resi:2:2) - Dead VariantCase +DeadTypeTest.t.B: 1 references (DeadTypeTest.resi:3:2) - Dead Value +DeadTypeTest.+a: 1 references (DeadTypeTest.resi:4:0) - Live (external ref) VariantCase +DeadTypeTest.deadType.OnlyInImplementation: 2 references (DeadTypeTest.res:12:8, DeadTypeTest.resi:7:2) - Live (propagated) VariantCase +DeadTypeTest.deadType.OnlyInInterface: 1 references (DeadTypeTest.resi:8:2) - Live (external ref) VariantCase +DeadTypeTest.deadType.InBoth: 2 references (DeadTypeTest.res:13:8, DeadTypeTest.resi:9:2) - Dead VariantCase +DeadTypeTest.deadType.InNeither: 1 references (DeadTypeTest.resi:10:2) - Dead Value +DeadTypeTest.+_: 0 references () - Dead Value +DeadTypeTest.+_: 0 references () - Live (annotated) RecordLabel +DeadTypeTest.record.x: 0 references () - Live (annotated) RecordLabel +DeadTypeTest.record.y: 0 references () - Live (annotated) RecordLabel +DeadTypeTest.record.z: 0 references () - Live (propagated) VariantCase DeadTypeTest.t.A: 1 references (DeadTypeTest.res:2:2) - Dead VariantCase DeadTypeTest.t.B: 1 references (DeadTypeTest.res:3:2) - Dead Value DeadTypeTest.+a: 0 references () - Live (propagated) VariantCase DeadTypeTest.deadType.OnlyInImplementation: 1 references (DeadTypeTest.res:7:2) - Live (external ref) VariantCase DeadTypeTest.deadType.OnlyInInterface: 2 references (DeadTest.res:44:8, DeadTypeTest.res:8:2) - Live (external ref) VariantCase DeadTypeTest.deadType.InBoth: 2 references (DeadTest.res:45:8, DeadTypeTest.res:9:2) - Dead VariantCase DeadTypeTest.deadType.InNeither: 1 references (DeadTypeTest.res:10:2) - Live (propagated) Value +DeadValueTest.+valueAlive: 1 references (DeadValueTest.resi:1:0) - Dead Value +DeadValueTest.+valueDead: 1 references (DeadValueTest.resi:2:0) - Dead Value +DeadValueTest.+valueOnlyInImplementation: 0 references () - Dead Value +DeadValueTest.+subList: 1 references (DeadValueTest.res:10:8) - Dead Value +DeadValueTest.+tail: 1 references (DeadValueTest.res:6:8) - Live (external ref) Value DeadValueTest.+valueAlive: 1 references (DeadTest.res:73:16) - Dead Value DeadValueTest.+valueDead: 0 references () - Live (annotated) Value +Docstrings.+flat: 0 references () - Live (annotated) Value +Docstrings.+signMessage: 0 references () - Live (annotated) Value +Docstrings.+one: 0 references () - Live (annotated) Value +Docstrings.+two: 0 references () - Live (annotated) Value +Docstrings.+tree: 0 references () - Live (annotated) Value +Docstrings.+oneU: 0 references () - Live (annotated) Value +Docstrings.+twoU: 0 references () - Live (annotated) Value +Docstrings.+treeU: 0 references () - Live (annotated) Value +Docstrings.+useParam: 0 references () - Live (annotated) Value +Docstrings.+useParamU: 0 references () - Live (annotated) Value +Docstrings.+unnamed1: 0 references () - Live (annotated) Value +Docstrings.+unnamed1U: 0 references () - Live (annotated) Value +Docstrings.+unnamed2: 0 references () - Live (annotated) Value +Docstrings.+unnamed2U: 0 references () - Live (annotated) Value +Docstrings.+grouped: 0 references () - Live (annotated) Value +Docstrings.+unitArgWithoutConversion: 0 references () - Live (annotated) Value +Docstrings.+unitArgWithoutConversionU: 0 references () - Live (external ref) VariantCase +Docstrings.t.A: 2 references (Docstrings.res:64:34, Docstrings.res:67:39) - Dead VariantCase +Docstrings.t.B: 0 references () - Live (annotated) Value +Docstrings.+unitArgWithConversion: 0 references () - Live (annotated) Value +Docstrings.+unitArgWithConversionU: 0 references () - Live (external ref) Value +DynamicallyLoadedComponent.+make: 1 references (DeadTest.res:110:17) - Live (external ref) RecordLabel +DynamicallyLoadedComponent.props.s: 1 references (_none_:1:-1) - Live (external ref) Value +EmptyArray.Z.+make: 1 references (EmptyArray.res:10:9) - Live (propagated) Value +ErrorHandler.Make.+notify: 1 references (ErrorHandler.resi:7:2) - Dead Value +ErrorHandler.+x: 1 references (ErrorHandler.resi:10:0) - Live (external ref) Value ErrorHandler.Make.+notify: 1 references (CreateErrorHandler1.res:8:0) - Dead Value ErrorHandler.+x: 0 references () - Dead Value +EverythingLiveHere.+x: 0 references () - Dead Value +EverythingLiveHere.+y: 0 references () - Dead Value +EverythingLiveHere.+z: 0 references () - Live (external ref) Value +FirstClassModules.M.+y: 1 references (FirstClassModules.res:20:2) - Live (external ref) Value +FirstClassModules.M.InnerModule2.+k: 1 references (FirstClassModules.res:10:4) - Live (external ref) Value +FirstClassModules.M.InnerModule3.+k3: 1 references (FirstClassModules.res:14:4) - Live (external ref) Value +FirstClassModules.M.Z.+u: 1 references (FirstClassModules.res:37:4) - Live (external ref) Value +FirstClassModules.M.+x: 1 references (FirstClassModules.res:2:2) - Live (annotated) Value +FirstClassModules.+firstClassModule: 0 references () - Live (annotated) Value +FirstClassModules.+testConvert: 0 references () - Live (external ref) Value +FirstClassModules.SomeFunctor.+ww: 1 references (FirstClassModules.res:57:2) - Live (annotated) Value +FirstClassModules.+someFunctorAsFunction: 0 references () - Dead RecordLabel +FirstClassModulesInterface.record.x: 1 references (FirstClassModulesInterface.resi:3:2) - Dead RecordLabel +FirstClassModulesInterface.record.y: 1 references (FirstClassModulesInterface.resi:4:2) - Dead Value +FirstClassModulesInterface.+r: 1 references (FirstClassModulesInterface.resi:7:0) - Dead RecordLabel FirstClassModulesInterface.record.x: 1 references (FirstClassModulesInterface.res:2:2) - Dead RecordLabel FirstClassModulesInterface.record.y: 1 references (FirstClassModulesInterface.res:3:2) - Dead Value FirstClassModulesInterface.+r: 0 references () - Live (external ref) RecordLabel +Hooks.vehicle.name: 5 references (Hooks.res:10:29, Hooks.res:29:66, Hooks.res:33:68, Hooks.res:47:2, Hooks.res:47:14) - Live (propagated) Value +Hooks.+make: 1 references (Hooks.res:25:4) - Live (external ref) RecordLabel +Hooks.props.vehicle: 1 references (_none_:1:-1) - Live (annotated) Value +Hooks.+default: 0 references () - Live (annotated) Value +Hooks.Inner.+make: 0 references () - Live (external ref) RecordLabel +Hooks.Inner.props.vehicle: 1 references (_none_:1:-1) - Live (annotated) Value +Hooks.Inner.Inner2.+make: 0 references () - Live (external ref) RecordLabel +Hooks.Inner.Inner2.props.vehicle: 1 references (_none_:1:-1) - Live (annotated) Value +Hooks.NoProps.+make: 0 references () - Live (annotated) Value +Hooks.+functionWithRenamedArgs: 0 references () - Dead RecordLabel +Hooks.r.x: 0 references () - Live (annotated) Value +Hooks.RenderPropRequiresConversion.+make: 0 references () - Live (external ref) RecordLabel +Hooks.RenderPropRequiresConversion.props.renderVehicle: 1 references (_none_:1:-1) - Live (external ref) Value +Hooks.RenderPropRequiresConversion.+car: 1 references (Hooks.res:65:30) - Live (propagated) Value +ImmutableArray.+fromArray: 1 references (ImmutableArray.resi:9:0) - Dead Value +ImmutableArray.+toArray: 1 references (ImmutableArray.resi:12:0) - Dead Value +ImmutableArray.+length: 1 references (ImmutableArray.resi:14:0) - Dead Value +ImmutableArray.+size: 1 references (ImmutableArray.resi:17:0) - Live (propagated) Value +ImmutableArray.+get: 2 references (ImmutableArray.resi:6:2, ImmutableArray.resi:19:0) - Dead Value +ImmutableArray.+getExn: 1 references (ImmutableArray.resi:21:0) - Dead Value +ImmutableArray.+getUnsafe: 1 references (ImmutableArray.resi:23:0) - Dead Value +ImmutableArray.+getUndefined: 1 references (ImmutableArray.resi:25:0) - Dead Value +ImmutableArray.+shuffle: 1 references (ImmutableArray.resi:27:0) - Dead Value +ImmutableArray.+reverse: 1 references (ImmutableArray.resi:29:0) - Dead Value +ImmutableArray.+makeUninitialized: 1 references (ImmutableArray.resi:31:0) - Dead Value +ImmutableArray.+makeUninitializedUnsafe: 1 references (ImmutableArray.resi:33:0) - Dead Value +ImmutableArray.+make: 1 references (ImmutableArray.resi:35:0) - Dead Value +ImmutableArray.+range: 1 references (ImmutableArray.resi:37:0) - Dead Value +ImmutableArray.+rangeBy: 1 references (ImmutableArray.resi:39:0) - Dead Value +ImmutableArray.+makeByU: 1 references (ImmutableArray.resi:41:0) - Dead Value +ImmutableArray.+makeBy: 1 references (ImmutableArray.resi:42:0) - Dead Value +ImmutableArray.+makeByAndShuffleU: 1 references (ImmutableArray.resi:44:0) - Dead Value +ImmutableArray.+makeByAndShuffle: 1 references (ImmutableArray.resi:45:0) - Dead Value +ImmutableArray.+zip: 1 references (ImmutableArray.resi:47:0) - Dead Value +ImmutableArray.+zipByU: 1 references (ImmutableArray.resi:49:0) - Dead Value +ImmutableArray.+zipBy: 1 references (ImmutableArray.resi:50:0) - Dead Value +ImmutableArray.+unzip: 1 references (ImmutableArray.resi:52:0) - Dead Value +ImmutableArray.+concat: 1 references (ImmutableArray.resi:54:0) - Dead Value +ImmutableArray.+concatMany: 1 references (ImmutableArray.resi:56:0) - Dead Value +ImmutableArray.+slice: 1 references (ImmutableArray.resi:58:0) - Dead Value +ImmutableArray.+sliceToEnd: 1 references (ImmutableArray.resi:60:0) - Dead Value +ImmutableArray.+copy: 1 references (ImmutableArray.resi:62:0) - Dead Value +ImmutableArray.+forEachU: 1 references (ImmutableArray.resi:64:0) - Dead Value +ImmutableArray.+forEach: 1 references (ImmutableArray.resi:65:0) - Dead Value +ImmutableArray.+mapU: 1 references (ImmutableArray.resi:67:0) - Dead Value +ImmutableArray.+map: 1 references (ImmutableArray.resi:68:0) - Dead Value +ImmutableArray.+keepWithIndexU: 1 references (ImmutableArray.resi:70:0) - Dead Value +ImmutableArray.+keepWithIndex: 1 references (ImmutableArray.resi:71:0) - Dead Value +ImmutableArray.+keepMapU: 1 references (ImmutableArray.resi:73:0) - Dead Value +ImmutableArray.+keepMap: 1 references (ImmutableArray.resi:74:0) - Dead Value +ImmutableArray.+forEachWithIndexU: 1 references (ImmutableArray.resi:76:0) - Dead Value +ImmutableArray.+forEachWithIndex: 1 references (ImmutableArray.resi:77:0) - Dead Value +ImmutableArray.+mapWithIndexU: 1 references (ImmutableArray.resi:79:0) - Dead Value +ImmutableArray.+mapWithIndex: 1 references (ImmutableArray.resi:80:0) - Dead Value +ImmutableArray.+partitionU: 1 references (ImmutableArray.resi:82:0) - Dead Value +ImmutableArray.+partition: 1 references (ImmutableArray.resi:83:0) - Dead Value +ImmutableArray.+reduceU: 1 references (ImmutableArray.resi:85:0) - Dead Value +ImmutableArray.+reduce: 1 references (ImmutableArray.resi:86:0) - Dead Value +ImmutableArray.+reduceReverseU: 1 references (ImmutableArray.resi:88:0) - Dead Value +ImmutableArray.+reduceReverse: 1 references (ImmutableArray.resi:89:0) - Dead Value +ImmutableArray.+reduceReverse2U: 1 references (ImmutableArray.resi:91:0) - Dead Value +ImmutableArray.+reduceReverse2: 1 references (ImmutableArray.resi:92:0) - Dead Value +ImmutableArray.+someU: 1 references (ImmutableArray.resi:94:0) - Dead Value +ImmutableArray.+some: 1 references (ImmutableArray.resi:95:0) - Dead Value +ImmutableArray.+everyU: 1 references (ImmutableArray.resi:97:0) - Dead Value +ImmutableArray.+every: 1 references (ImmutableArray.resi:98:0) - Dead Value +ImmutableArray.+every2U: 1 references (ImmutableArray.resi:100:0) - Dead Value +ImmutableArray.+every2: 1 references (ImmutableArray.resi:101:0) - Dead Value +ImmutableArray.+some2U: 1 references (ImmutableArray.resi:103:0) - Dead Value +ImmutableArray.+some2: 1 references (ImmutableArray.resi:104:0) - Dead Value +ImmutableArray.+cmpU: 1 references (ImmutableArray.resi:106:0) - Dead Value +ImmutableArray.+cmp: 1 references (ImmutableArray.resi:107:0) - Dead Value +ImmutableArray.+eqU: 1 references (ImmutableArray.resi:109:0) - Dead Value +ImmutableArray.+eq: 1 references (ImmutableArray.resi:110:0) - Live (propagated) Value ImmutableArray.Array.+get: 1 references (TestImmutableArray.res:2:4) - Live (external ref) Value ImmutableArray.+fromArray: 1 references (DeadTest.res:1:15) - Dead Value ImmutableArray.+toArray: 0 references () - Dead Value ImmutableArray.+length: 0 references () - Dead Value ImmutableArray.+size: 0 references () - Dead Value ImmutableArray.+get: 0 references () - Dead Value ImmutableArray.+getExn: 0 references () - Dead Value ImmutableArray.+getUnsafe: 0 references () - Dead Value ImmutableArray.+getUndefined: 0 references () - Dead Value ImmutableArray.+shuffle: 0 references () - Dead Value ImmutableArray.+reverse: 0 references () - Dead Value ImmutableArray.+makeUninitialized: 0 references () - Dead Value ImmutableArray.+makeUninitializedUnsafe: 0 references () - Dead Value ImmutableArray.+make: 0 references () - Dead Value ImmutableArray.+range: 0 references () - Dead Value ImmutableArray.+rangeBy: 0 references () - Dead Value ImmutableArray.+makeByU: 0 references () - Dead Value ImmutableArray.+makeBy: 0 references () - Dead Value ImmutableArray.+makeByAndShuffleU: 0 references () - Dead Value ImmutableArray.+makeByAndShuffle: 0 references () - Dead Value ImmutableArray.+zip: 0 references () - Dead Value ImmutableArray.+zipByU: 0 references () - Dead Value ImmutableArray.+zipBy: 0 references () - Dead Value ImmutableArray.+unzip: 0 references () - Dead Value ImmutableArray.+concat: 0 references () - Dead Value ImmutableArray.+concatMany: 0 references () - Dead Value ImmutableArray.+slice: 0 references () - Dead Value ImmutableArray.+sliceToEnd: 0 references () - Dead Value ImmutableArray.+copy: 0 references () - Dead Value ImmutableArray.+forEachU: 0 references () - Dead Value ImmutableArray.+forEach: 0 references () - Dead Value ImmutableArray.+mapU: 0 references () - Dead Value ImmutableArray.+map: 0 references () - Dead Value ImmutableArray.+keepWithIndexU: 0 references () - Dead Value ImmutableArray.+keepWithIndex: 0 references () - Dead Value ImmutableArray.+keepMapU: 0 references () - Dead Value ImmutableArray.+keepMap: 0 references () - Dead Value ImmutableArray.+forEachWithIndexU: 0 references () - Dead Value ImmutableArray.+forEachWithIndex: 0 references () - Dead Value ImmutableArray.+mapWithIndexU: 0 references () - Dead Value ImmutableArray.+mapWithIndex: 0 references () - Dead Value ImmutableArray.+partitionU: 0 references () - Dead Value ImmutableArray.+partition: 0 references () - Dead Value ImmutableArray.+reduceU: 0 references () - Dead Value ImmutableArray.+reduce: 0 references () - Dead Value ImmutableArray.+reduceReverseU: 0 references () - Dead Value ImmutableArray.+reduceReverse: 0 references () - Dead Value ImmutableArray.+reduceReverse2U: 0 references () - Dead Value ImmutableArray.+reduceReverse2: 0 references () - Dead Value ImmutableArray.+someU: 0 references () - Dead Value ImmutableArray.+some: 0 references () - Dead Value ImmutableArray.+everyU: 0 references () - Dead Value ImmutableArray.+every: 0 references () - Dead Value ImmutableArray.+every2U: 0 references () - Dead Value ImmutableArray.+every2: 0 references () - Dead Value ImmutableArray.+some2U: 0 references () - Dead Value ImmutableArray.+some2: 0 references () - Dead Value ImmutableArray.+cmpU: 0 references () - Dead Value ImmutableArray.+cmp: 0 references () - Dead Value ImmutableArray.+eqU: 0 references () - Dead Value ImmutableArray.+eq: 0 references () - Dead RecordLabel +ImportHookDefault.person.name: 0 references () - Dead RecordLabel +ImportHookDefault.person.age: 0 references () - Live (annotated) Value +ImportHookDefault.+make: 1 references (Hooks.res:17:5) - Live (annotated) RecordLabel +ImportHookDefault.props.person: 0 references () - Live (annotated) RecordLabel +ImportHookDefault.props.children: 0 references () - Live (annotated) RecordLabel +ImportHookDefault.props.renderMe: 0 references () - Dead RecordLabel +ImportHooks.person.name: 0 references () - Dead RecordLabel +ImportHooks.person.age: 0 references () - Live (annotated) Value +ImportHooks.+make: 1 references (Hooks.res:14:5) - Live (annotated) RecordLabel +ImportHooks.props.person: 0 references () - Live (annotated) RecordLabel +ImportHooks.props.children: 0 references () - Live (annotated) RecordLabel +ImportHooks.props.renderMe: 0 references () - Live (annotated) Value +ImportHooks.+foo: 0 references () - Live (annotated) Value +ImportIndex.+make: 0 references () - Live (annotated) RecordLabel +ImportIndex.props.method: 0 references () - Live (annotated) Value +ImportJsValue.+round: 1 references (ImportJsValue.res:27:4) - Dead RecordLabel +ImportJsValue.point.x: 0 references () - Dead RecordLabel +ImportJsValue.point.y: 0 references () - Live (annotated) Value +ImportJsValue.+area: 1 references (ImportJsValue.res:30:4) - Live (annotated) Value +ImportJsValue.+returnMixedArray: 0 references () - Live (annotated) Value +ImportJsValue.+roundedNumber: 0 references () - Live (annotated) Value +ImportJsValue.+areaValue: 0 references () - Live (propagated) Value +ImportJsValue.AbsoluteValue.+getAbs: 1 references (ImportJsValue.res:50:4) - Live (propagated) Value +ImportJsValue.AbsoluteValue.+getAbs: 1 references (ImportJsValue.res:40:6) - Live (annotated) Value +ImportJsValue.+useGetProp: 0 references () - Live (annotated) Value +ImportJsValue.+useGetAbs: 0 references () - Live (annotated) Value +ImportJsValue.+useColor: 0 references () - Live (annotated) Value +ImportJsValue.+higherOrder: 1 references (ImportJsValue.res:64:4) - Live (annotated) Value +ImportJsValue.+returnedFromHigherOrder: 0 references () - Dead VariantCase +ImportJsValue.variant.I: 0 references () - Dead VariantCase +ImportJsValue.variant.S: 0 references () - Live (annotated) Value +ImportJsValue.+convertVariant: 0 references () - Live (annotated) Value +ImportJsValue.+polymorphic: 0 references () - Live (annotated) Value +ImportJsValue.+default: 0 references () - Dead RecordLabel +ImportMyBanner.message.text: 0 references () - Live (annotated) Value +ImportMyBanner.+make: 1 references (ImportMyBanner.res:12:4) - Dead Value +ImportMyBanner.+make: 0 references () - Live (propagated) VariantCase +InnerModuleTypes.I.t.Foo: 1 references (InnerModuleTypes.resi:2:11) - Live (external ref) VariantCase InnerModuleTypes.I.t.Foo: 2 references (InnerModuleTypes.res:2:11, TestInnedModuleTypes.res:1:8) - Live (external ref) Value +JsxV4.C.+make: 1 references (JsxV4.res:7:9) - Live (annotated) Value +LetPrivate.local_1.+x: 1 references (LetPrivate.res:7:4) - Live (annotated) Value +LetPrivate.+y: 0 references () - Dead RecordLabel +ModuleAliases.Outer.Inner.innerT.inner: 0 references () - Dead RecordLabel +ModuleAliases.Outer2.Inner2.InnerNested.t.nested: 0 references () - Live (annotated) Value +ModuleAliases.+testNested: 0 references () - Live (annotated) Value +ModuleAliases.+testInner: 0 references () - Live (annotated) Value +ModuleAliases.+testInner2: 0 references () - Dead RecordLabel +ModuleAliases2.record.x: 0 references () - Dead RecordLabel +ModuleAliases2.record.y: 0 references () - Dead RecordLabel +ModuleAliases2.Outer.outer.outer: 0 references () - Dead RecordLabel +ModuleAliases2.Outer.Inner.inner.inner: 0 references () - Dead Value +ModuleAliases2.+q: 0 references () - Dead Value +ModuleExceptionBug.Dep.+customDouble: 0 references () - Dead Exception +ModuleExceptionBug.MyOtherException: 0 references () - Live (external ref) Value +ModuleExceptionBug.+ddjdj: 1 references (ModuleExceptionBug.res:8:7) - Live (annotated) Value +NestedModules.+notNested: 0 references () - Live (annotated) Value +NestedModules.Universe.+theAnswer: 0 references () - Dead Value +NestedModules.Universe.+notExported: 0 references () - Dead Value +NestedModules.Universe.Nested2.+x: 0 references () - Live (annotated) Value +NestedModules.Universe.Nested2.+nested2Value: 0 references () - Dead Value +NestedModules.Universe.Nested2.+y: 0 references () - Dead Value +NestedModules.Universe.Nested2.Nested3.+x: 0 references () - Dead Value +NestedModules.Universe.Nested2.Nested3.+y: 0 references () - Dead Value +NestedModules.Universe.Nested2.Nested3.+z: 0 references () - Dead Value +NestedModules.Universe.Nested2.Nested3.+w: 0 references () - Live (annotated) Value +NestedModules.Universe.Nested2.Nested3.+nested3Value: 0 references () - Live (annotated) Value +NestedModules.Universe.Nested2.Nested3.+nested3Function: 0 references () - Live (annotated) Value +NestedModules.Universe.Nested2.+nested2Function: 0 references () - Dead VariantCase +NestedModules.Universe.variant.A: 0 references () - Dead VariantCase +NestedModules.Universe.variant.B: 0 references () - Live (annotated) Value +NestedModules.Universe.+someString: 0 references () - Live (propagated) Value +NestedModulesInSignature.Universe.+theAnswer: 1 references (NestedModulesInSignature.resi:2:2) - Live (annotated) Value NestedModulesInSignature.Universe.+theAnswer: 0 references () - Dead Value +Newsyntax.+x: 0 references () - Dead Value +Newsyntax.+y: 0 references () - Dead RecordLabel +Newsyntax.record.xxx: 0 references () - Dead RecordLabel +Newsyntax.record.yyy: 0 references () - Dead VariantCase +Newsyntax.variant.A: 0 references () - Dead VariantCase +Newsyntax.variant.B: 0 references () - Dead VariantCase +Newsyntax.variant.C: 0 references () - Dead RecordLabel +Newsyntax.record2.xx: 0 references () - Dead RecordLabel +Newsyntax.record2.yy: 0 references () - Live (propagated) Value +Newton.+-: 4 references (Newton.res:9:8, Newton.res:16:8, Newton.res:25:4, Newton.res:27:4) - Live (propagated) Value +Newton.++: 1 references (Newton.res:25:4) - Live (propagated) Value +Newton.+*: 2 references (Newton.res:25:4, Newton.res:27:4) - Live (propagated) Value +Newton.+/: 1 references (Newton.res:16:8) - Live (propagated) Value +Newton.+newton: 1 references (Newton.res:29:4) - Live (propagated) Value +Newton.+current: 3 references (Newton.res:8:6, Newton.res:14:10, Newton.res:15:8) - Live (propagated) Value +Newton.+iterateMore: 1 references (Newton.res:14:10) - Live (propagated) Value +Newton.+delta: 1 references (Newton.res:8:6) - Live (propagated) Value +Newton.+loop: 2 references (Newton.res:6:4, Newton.res:14:10) - Live (propagated) Value +Newton.+previous: 2 references (Newton.res:14:10, Newton.res:16:8) - Live (propagated) Value +Newton.+next: 1 references (Newton.res:14:10) - Live (external ref) Value +Newton.+f: 2 references (Newton.res:29:4, Newton.res:31:16) - Live (propagated) Value +Newton.+fPrimed: 1 references (Newton.res:29:4) - Live (external ref) Value +Newton.+result: 2 references (Newton.res:31:8, Newton.res:31:18) - Dead VariantCase +Opaque.opaqueFromRecords.A: 0 references () - Live (annotated) Value +Opaque.+noConversion: 0 references () - Live (annotated) Value +Opaque.+testConvertNestedRecordFromOtherFile: 0 references () - Live (external ref) Value +OptArg.+foo: 2 references (OptArg.res:5:7, OptArg.resi:1:0) - Live (external ref) Value +OptArg.+bar: 2 references (OptArg.res:7:7, OptArg.resi:2:0) - Live (external ref) Value +OptArg.+threeArgs: 2 references (OptArg.res:11:7, OptArg.res:12:7) - Live (external ref) Value +OptArg.+twoArgs: 1 references (OptArg.res:16:10) - Live (propagated) Value +OptArg.+oneArg: 1 references (OptArg.res:20:4) - Live (external ref) Value +OptArg.+wrapOneArg: 1 references (OptArg.res:22:7) - Live (propagated) Value +OptArg.+fourArgs: 1 references (OptArg.res:26:4) - Live (external ref) Value +OptArg.+wrapfourArgs: 2 references (OptArg.res:28:7, OptArg.res:29:7) - Dead Value OptArg.+foo: 0 references () - Live (external ref) Value OptArg.+bar: 1 references (TestOptArg.res:1:7) - Live (propagated) Value +OptionalArgsLiveDead.+formatDate: 2 references (OptionalArgsLiveDead.res:3:4, OptionalArgsLiveDead.res:5:4) - Dead Value +OptionalArgsLiveDead.+deadCaller: 0 references () - Live (external ref) Value +OptionalArgsLiveDead.+liveCaller: 1 references (OptionalArgsLiveDead.res:7:8) - Live (external ref) RecordLabel +Records.coord.x: 1 references (Records.res:14:19) - Live (external ref) RecordLabel +Records.coord.y: 1 references (Records.res:14:19) - Live (external ref) RecordLabel +Records.coord.z: 1 references (Records.res:14:19) - Live (annotated) Value +Records.+origin: 0 references () - Live (annotated) Value +Records.+computeArea: 0 references () - Live (annotated) Value +Records.+coord2d: 0 references () - Dead RecordLabel +Records.person.name: 0 references () - Dead RecordLabel +Records.person.age: 0 references () - Live (external ref) RecordLabel +Records.person.address: 1 references (Records.res:51:42) - Dead RecordLabel +Records.business.name: 0 references () - Live (external ref) RecordLabel +Records.business.owner: 1 references (Records.res:51:6) - Live (external ref) RecordLabel +Records.business.address: 2 references (Records.res:40:2, Records.res:50:6) - Live (propagated) Value +Records.+getOpt: 3 references (Records.res:39:4, Records.res:46:4, Records.res:96:4) - Live (annotated) Value +Records.+findAddress: 0 references () - Live (annotated) Value +Records.+someBusiness: 0 references () - Live (annotated) Value +Records.+findAllAddresses: 0 references () - Dead RecordLabel +Records.payload.num: 0 references () - Live (external ref) RecordLabel +Records.payload.payload: 3 references (Records.res:65:18, Records.res:74:24, Records.res:83:31) - Live (annotated) Value +Records.+getPayload: 0 references () - Live (external ref) RecordLabel +Records.record.v: 1 references (Records.res:85:5) - Dead RecordLabel +Records.record.w: 0 references () - Live (annotated) Value +Records.+getPayloadRecord: 0 references () - Live (annotated) Value +Records.+recordValue: 1 references (Records.res:80:4) - Live (annotated) Value +Records.+payloadValue: 0 references () - Live (annotated) Value +Records.+getPayloadRecordPlusOne: 0 references () - Dead RecordLabel +Records.business2.name: 0 references () - Dead RecordLabel +Records.business2.owner: 0 references () - Live (external ref) RecordLabel +Records.business2.address2: 1 references (Records.res:97:2) - Live (annotated) Value +Records.+findAddress2: 0 references () - Live (annotated) Value +Records.+someBusiness2: 0 references () - Live (annotated) Value +Records.+computeArea3: 0 references () - Live (annotated) Value +Records.+computeArea4: 0 references () - Live (external ref) RecordLabel +Records.myRec.type_: 1 references (Records.res:127:30) - Live (annotated) Value +Records.+testMyRec: 0 references () - Live (annotated) Value +Records.+testMyRec2: 0 references () - Live (annotated) Value +Records.+testMyObj: 0 references () - Live (annotated) Value +Records.+testMyObj2: 0 references () - Live (external ref) RecordLabel +Records.myRecBsAs.type_: 1 references (Records.res:145:38) - Live (annotated) Value +Records.+testMyRecBsAs: 0 references () - Live (annotated) Value +Records.+testMyRecBsAs2: 0 references () - Live (annotated) Value +References.+create: 0 references () - Live (annotated) Value +References.+access: 0 references () - Live (annotated) Value +References.+update: 0 references () - Live (propagated) Value +References.R.+get: 1 references (References.res:31:4) - Live (propagated) Value +References.R.+make: 1 references (References.res:34:4) - Live (propagated) Value +References.R.+set: 1 references (References.res:37:4) - Live (propagated) Value +References.R.+get: 1 references (References.res:17:2) - Live (propagated) Value +References.R.+make: 1 references (References.res:18:2) - Live (propagated) Value +References.R.+set: 1 references (References.res:19:2) - Live (annotated) Value +References.+get: 0 references () - Live (annotated) Value +References.+make: 0 references () - Live (annotated) Value +References.+set: 0 references () - Dead RecordLabel +References.requiresConversion.x: 0 references () - Live (annotated) Value +References.+destroysRefIdentity: 0 references () - Live (annotated) Value +References.+preserveRefIdentity: 0 references () - Dead RecordLabel +RepeatedLabel.userData.a: 0 references () - Dead RecordLabel +RepeatedLabel.userData.b: 0 references () - Live (external ref) RecordLabel +RepeatedLabel.tabState.a: 1 references (RepeatedLabel.res:12:16) - Live (external ref) RecordLabel +RepeatedLabel.tabState.b: 1 references (RepeatedLabel.res:12:16) - Dead RecordLabel +RepeatedLabel.tabState.f: 0 references () - Live (external ref) Value +RepeatedLabel.+userData: 1 references (RepeatedLabel.res:14:7) - Live (annotated) Value +Shadow.+test: 0 references () - Live (annotated) Value +Shadow.+test: 0 references () - Live (annotated) Value +Shadow.M.+test: 0 references () - Dead Value +Shadow.M.+test: 0 references () - Live (annotated) Value +TestEmitInnerModules.Inner.+x: 0 references () - Live (annotated) Value +TestEmitInnerModules.Inner.+y: 0 references () - Live (annotated) Value +TestEmitInnerModules.Outer.Medium.Inner.+y: 0 references () - Live (annotated) Value +TestFirstClassModules.+convert: 0 references () - Live (annotated) Value +TestFirstClassModules.+convertInterface: 0 references () - Live (annotated) Value +TestFirstClassModules.+convertRecord: 0 references () - Live (annotated) Value +TestFirstClassModules.+convertFirstClassModuleWithTypeEquations: 0 references () - Live (annotated) Value +TestImmutableArray.+testImmutableArrayGet: 0 references () - Dead Value +TestImmutableArray.+testBeltArrayGet: 0 references () - Dead Value +TestImmutableArray.+testBeltArraySet: 0 references () - Live (annotated) Value +TestImport.+innerStuffContents: 1 references (TestImport.res:13:4) - Live (annotated) Value +TestImport.+innerStuffContentsAsEmptyObject: 0 references () - Dead Value +TestImport.+innerStuffContents: 0 references () - Live (annotated) Value +TestImport.+valueStartingWithUpperCaseLetter: 0 references () - Live (annotated) Value +TestImport.+defaultValue: 0 references () - Dead RecordLabel +TestImport.message.text: 0 references () - Live (annotated) Value +TestImport.+make: 1 references (TestImport.res:27:4) - Dead Value +TestImport.+make: 0 references () - Live (annotated) Value +TestImport.+defaultValue2: 0 references () - Dead Value +TestInnedModuleTypes.+_: 0 references () - Live (annotated) Value +TestModuleAliases.+testInner1: 0 references () - Live (annotated) Value +TestModuleAliases.+testInner1Expanded: 0 references () - Live (annotated) Value +TestModuleAliases.+testInner2: 0 references () - Live (annotated) Value +TestModuleAliases.+testInner2Expanded: 0 references () - Live (propagated) Value +TestOptArg.+foo: 1 references (TestOptArg.res:5:4) - Live (external ref) Value +TestOptArg.+bar: 1 references (TestOptArg.res:7:7) - Live (external ref) Value +TestOptArg.+notSuppressesOptArgs: 1 references (TestOptArg.res:11:8) - Live (annotated) Value +TestOptArg.+liveSuppressesOptArgs: 1 references (TestOptArg.res:16:8) - Dead RecordLabel +TestPromise.fromPayload.x: 0 references () - Live (external ref) RecordLabel +TestPromise.fromPayload.s: 1 references (TestPromise.res:14:32) - Dead RecordLabel +TestPromise.toPayload.result: 0 references () - Live (annotated) Value +TestPromise.+convert: 0 references () - Dead Value +ToSuppress.+toSuppress: 0 references () - Live (annotated) Value +TransitiveType1.+convert: 0 references () - Live (annotated) Value +TransitiveType1.+convertAlias: 0 references () - Dead Value +TransitiveType2.+convertT2: 0 references () - Dead RecordLabel +TransitiveType3.t3.i: 0 references () - Dead RecordLabel +TransitiveType3.t3.s: 0 references () - Live (annotated) Value +TransitiveType3.+convertT3: 0 references () - Live (annotated) Value +Tuples.+testTuple: 0 references () - Live (annotated) Value +Tuples.+origin: 0 references () - Live (annotated) Value +Tuples.+computeArea: 0 references () - Live (annotated) Value +Tuples.+computeAreaWithIdent: 0 references () - Live (annotated) Value +Tuples.+computeAreaNoConverters: 0 references () - Live (annotated) Value +Tuples.+coord2d: 0 references () - Live (external ref) RecordLabel +Tuples.person.name: 1 references (Tuples.res:43:49) - Live (external ref) RecordLabel +Tuples.person.age: 1 references (Tuples.res:49:84) - Live (annotated) Value +Tuples.+getFirstName: 0 references () - Live (annotated) Value +Tuples.+marry: 0 references () - Live (annotated) Value +Tuples.+changeSecondAge: 0 references () - Dead Value +TypeParams1.+exportSomething: 0 references () - Dead RecordLabel +TypeParams2.item.id: 0 references () - Dead Value +TypeParams2.+exportSomething: 0 references () - Live (annotated) Value +TypeParams3.+test: 0 references () - Live (annotated) Value +TypeParams3.+test2: 0 references () - Live (annotated) Value +Types.+someIntList: 0 references () - Live (annotated) Value +Types.+map: 0 references () - Dead VariantCase +Types.typeWithVars.A: 0 references () - Dead VariantCase +Types.typeWithVars.B: 0 references () - Live (annotated) Value +Types.+swap: 1 references (Types.res:23:8) - Live (external ref) RecordLabel +Types.selfRecursive.self: 1 references (Types.res:42:30) - Live (external ref) RecordLabel +Types.mutuallyRecursiveA.b: 1 references (Types.res:49:34) - Dead RecordLabel +Types.mutuallyRecursiveB.a: 0 references () - Live (annotated) Value +Types.+selfRecursiveConverter: 0 references () - Live (annotated) Value +Types.+mutuallyRecursiveConverter: 0 references () - Live (annotated) Value +Types.+testFunctionOnOptionsAsArgument: 0 references () - Dead VariantCase +Types.opaqueVariant.A: 0 references () - Dead VariantCase +Types.opaqueVariant.B: 0 references () - Live (annotated) Value +Types.+jsStringT: 0 references () - Live (annotated) Value +Types.+jsString2T: 0 references () - Live (annotated) Value +Types.+jsonStringify: 0 references () - Dead RecordLabel +Types.record.i: 0 references () - Dead RecordLabel +Types.record.s: 0 references () - Live (annotated) Value +Types.+testConvertNull: 0 references () - Live (annotated) Value +Types.+testMarshalFields: 0 references () - Live (annotated) Value +Types.+setMatch: 0 references () - Dead RecordLabel +Types.someRecord.id: 0 references () - Live (annotated) Value +Types.+testInstantiateTypeParameter: 0 references () - Live (annotated) Value +Types.+currentTime: 0 references () - Live (annotated) Value +Types.+i64Const: 0 references () - Live (annotated) Value +Types.+optFunction: 0 references () - Dead Value +Types.ObjectId.+x: 0 references () - Dead VariantCase +Unboxed.v1.A: 0 references () - Dead VariantCase +Unboxed.v2.A: 0 references () - Live (annotated) Value +Unboxed.+testV1: 0 references () - Dead RecordLabel +Unboxed.r1.x: 0 references () - Dead VariantCase +Unboxed.r2.B: 0 references () - Dead RecordLabel +Unboxed.r2.B.g: 0 references () - Live (annotated) Value +Unboxed.+r2Test: 0 references () - Live (annotated) Value +Uncurried.+uncurried0: 0 references () - Live (annotated) Value +Uncurried.+uncurried1: 0 references () - Live (annotated) Value +Uncurried.+uncurried2: 0 references () - Live (annotated) Value +Uncurried.+uncurried3: 0 references () - Live (annotated) Value +Uncurried.+curried3: 0 references () - Live (annotated) Value +Uncurried.+callback: 0 references () - Live (external ref) RecordLabel +Uncurried.auth.login: 1 references (Uncurried.res:35:24) - Live (external ref) RecordLabel +Uncurried.authU.loginU: 1 references (Uncurried.res:38:25) - Live (annotated) Value +Uncurried.+callback2: 0 references () - Live (annotated) Value +Uncurried.+callback2U: 0 references () - Live (annotated) Value +Uncurried.+sumU: 0 references () - Live (annotated) Value +Uncurried.+sumU2: 0 references () - Live (annotated) Value +Uncurried.+sumCurried: 0 references () - Live (annotated) Value +Uncurried.+sumLblCurried: 0 references () - Live (external ref) VariantCase +Unison.break.IfNeed: 1 references (Unison.res:17:20) - Live (external ref) VariantCase +Unison.break.Never: 1 references (Unison.res:38:38) - Live (external ref) VariantCase +Unison.break.Always: 1 references (Unison.res:39:38) - Live (external ref) RecordLabel +Unison.t.break: 1 references (Unison.res:28:9) - Live (external ref) RecordLabel +Unison.t.doc: 2 references (Unison.res:23:9, Unison.res:28:9) - Live (external ref) VariantCase +Unison.stack.Empty: 3 references (Unison.res:37:20, Unison.res:38:53, Unison.res:39:52) - Live (external ref) VariantCase +Unison.stack.Cons: 2 references (Unison.res:38:20, Unison.res:39:20) - Live (external ref) Value +Unison.+group: 2 references (Unison.res:38:25, Unison.res:39:25) - Live (propagated) Value +Unison.+fits: 2 references (Unison.res:19:8, Unison.res:26:8) - Live (external ref) Value +Unison.+toString: 4 references (Unison.res:26:8, Unison.res:37:0, Unison.res:38:0, Unison.res:39:0) - Live (annotated) Value +UseImportJsValue.+useGetProp: 0 references () - Live (annotated) Value +UseImportJsValue.+useTypeImportedInOtherModule: 0 references () - Live (annotated) Value +Variants.+isWeekend: 0 references () - Live (annotated) Value +Variants.+monday: 0 references () - Live (annotated) Value +Variants.+saturday: 0 references () - Live (annotated) Value +Variants.+sunday: 0 references () - Live (annotated) Value +Variants.+onlySunday: 0 references () - Live (annotated) Value +Variants.+swap: 0 references () - Live (annotated) Value +Variants.+testConvert: 0 references () - Live (annotated) Value +Variants.+fortytwoOK: 0 references () - Live (annotated) Value +Variants.+fortytwoBAD: 0 references () - Live (annotated) Value +Variants.+testConvert2: 0 references () - Live (annotated) Value +Variants.+testConvert3: 0 references () - Live (annotated) Value +Variants.+testConvert2to3: 0 references () - Live (annotated) Value +Variants.+id1: 0 references () - Live (annotated) Value +Variants.+id2: 0 references () - Dead VariantCase +Variants.type_.Type: 0 references () - Live (annotated) Value +Variants.+polyWithOpt: 0 references () - Dead VariantCase +Variants.result1.Ok: 0 references () - Dead VariantCase +Variants.result1.Error: 0 references () - Live (annotated) Value +Variants.+restResult1: 0 references () - Live (annotated) Value +Variants.+restResult2: 0 references () - Live (annotated) Value +Variants.+restResult3: 0 references () - Live (external ref) RecordLabel +VariantsWithPayload.payload.x: 2 references (VariantsWithPayload.res:26:57, VariantsWithPayload.res:44:55) - Live (external ref) RecordLabel +VariantsWithPayload.payload.y: 2 references (VariantsWithPayload.res:26:74, VariantsWithPayload.res:44:72) - Live (annotated) Value +VariantsWithPayload.+testWithPayload: 0 references () - Live (annotated) Value +VariantsWithPayload.+printVariantWithPayload: 0 references () - Live (annotated) Value +VariantsWithPayload.+testManyPayloads: 0 references () - Live (annotated) Value +VariantsWithPayload.+printManyPayloads: 0 references () - Dead VariantCase +VariantsWithPayload.simpleVariant.A: 0 references () - Dead VariantCase +VariantsWithPayload.simpleVariant.B: 0 references () - Dead VariantCase +VariantsWithPayload.simpleVariant.C: 0 references () - Live (annotated) Value +VariantsWithPayload.+testSimpleVariant: 0 references () - Dead VariantCase +VariantsWithPayload.variantWithPayloads.A: 0 references () - Dead VariantCase +VariantsWithPayload.variantWithPayloads.B: 0 references () - Dead VariantCase +VariantsWithPayload.variantWithPayloads.C: 0 references () - Dead VariantCase +VariantsWithPayload.variantWithPayloads.D: 0 references () - Dead VariantCase +VariantsWithPayload.variantWithPayloads.E: 0 references () - Live (annotated) Value +VariantsWithPayload.+testVariantWithPayloads: 0 references () - Live (annotated) Value +VariantsWithPayload.+printVariantWithPayloads: 0 references () - Dead VariantCase +VariantsWithPayload.variant1Int.R: 0 references () - Live (annotated) Value +VariantsWithPayload.+testVariant1Int: 0 references () - Dead VariantCase +VariantsWithPayload.variant1Object.R: 0 references () - Live (annotated) Value +VariantsWithPayload.+testVariant1Object: 0 references () + Dead VariantCase +AutoAnnotate.variant.R + Dead RecordLabel +AutoAnnotate.record.variant + Dead RecordLabel +AutoAnnotate.r2.r2 + Dead RecordLabel +AutoAnnotate.r3.r3 + Dead RecordLabel +AutoAnnotate.r4.r4 + Dead VariantCase +AutoAnnotate.annotatedVariant.R2 + Dead VariantCase +AutoAnnotate.annotatedVariant.R4 + Dead Value +BucklescriptAnnotations.+bar + Dead Value +BucklescriptAnnotations.+f + Live (annotated) Value +ComponentAsProp.+make + Live (external ref) RecordLabel +ComponentAsProp.props.title + Live (external ref) RecordLabel +ComponentAsProp.props.description + Live (external ref) RecordLabel +ComponentAsProp.props.button + Live (external ref) Value +CreateErrorHandler1.Error1.+notification + Live (external ref) Value +CreateErrorHandler2.Error2.+notification + Live (external ref) Value +DeadCodeImplementation.M.+x + Live (external ref) Exception +DeadExn.Etoplevel + Live (external ref) Exception +DeadExn.Inside.Einside + Dead Exception +DeadExn.DeadE + Dead Value +DeadExn.+eToplevel + Live (external ref) Value +DeadExn.+eInside + Live (propagated) VariantCase +DeadRT.moduleAccessPath.Root + Live (external ref) VariantCase +DeadRT.moduleAccessPath.Kaboom + Dead Value +DeadRT.+emitModuleAccessPath + Live (external ref) VariantCase DeadRT.moduleAccessPath.Root + Live (propagated) VariantCase DeadRT.moduleAccessPath.Kaboom + Dead Value +DeadTest.+fortytwo + Live (annotated) Value +DeadTest.+fortyTwoButExported + Live (external ref) Value +DeadTest.+thisIsUsedOnce + Live (external ref) Value +DeadTest.+thisIsUsedTwice + Dead Value +DeadTest.+thisIsMarkedDead + Live (propagated) Value +DeadTest.+thisIsKeptAlive + Live (annotated) Value +DeadTest.+thisIsMarkedLive + Dead Value +DeadTest.Inner.+thisIsAlsoMarkedDead + Dead Value +DeadTest.M.+thisSignatureItemIsDead + Dead Value +DeadTest.M.+thisSignatureItemIsDead + Live (propagated) VariantCase +DeadTest.VariantUsedOnlyInImplementation.t.A + Live (external ref) Value +DeadTest.VariantUsedOnlyInImplementation.+a + Live (external ref) VariantCase +DeadTest.VariantUsedOnlyInImplementation.t.A + Live (propagated) Value +DeadTest.VariantUsedOnlyInImplementation.+a + Dead Value +DeadTest.+_ + Dead Value +DeadTest.+_ + Live (external ref) RecordLabel +DeadTest.record.xxx + Live (external ref) RecordLabel +DeadTest.record.yyy + Dead Value +DeadTest.+_ + Dead Value +DeadTest.+_ + Dead Value +DeadTest.UnderscoreInside.+_ + Live (external ref) Value +DeadTest.MM.+x + Dead Value +DeadTest.MM.+y + Live (propagated) Value +DeadTest.MM.+y + Live (propagated) Value +DeadTest.MM.+x + Dead Value +DeadTest.MM.+valueOnlyInImplementation + Dead Value +DeadTest.+unusedRec + Dead Value +DeadTest.+split_map + Dead Value +DeadTest.+rec1 + Dead Value +DeadTest.+rec2 + Dead Value +DeadTest.+recWithCallback + Dead Value +DeadTest.+cb + Dead Value +DeadTest.+foo + Dead Value +DeadTest.+cb + Dead Value +DeadTest.+bar + Dead Value +DeadTest.+withDefaultValue + Dead Value +DeadTest.+zzz + Dead Value +DeadTest.+a1 + Dead Value +DeadTest.+a2 + Dead Value +DeadTest.+a3 + Dead Value +DeadTest.+second + Dead Value +DeadTest.+deadRef + Live (external ref) Value +DeadTest.+make + Live (external ref) RecordLabel +DeadTest.props.s + Dead Value +DeadTest.+theSideEffectIsLogging + Dead Value +DeadTest.+stringLengthNoSideEffects + Live (annotated) Value +DeadTest.GloobLive.+globallyLive1 + Live (annotated) Value +DeadTest.GloobLive.+globallyLive2 + Live (annotated) Value +DeadTest.GloobLive.+globallyLive3 + Live (external ref) VariantCase +DeadTest.WithInclude.t.A + Live (propagated) VariantCase +DeadTest.WithInclude.t.A + Dead Value +DeadTest.+funWithInnerVars + Dead Value +DeadTest.+x + Dead Value +DeadTest.+y + Dead RecordLabel +DeadTest.rc.a + Live (external ref) Value +DeadTest.+deadIncorrect + Dead Value +DeadTest.+_ + Live (external ref) VariantCase +DeadTest.inlineRecord.IR + Dead RecordLabel +DeadTest.inlineRecord.IR.a + Live (external ref) RecordLabel +DeadTest.inlineRecord.IR.b + Live (external ref) RecordLabel +DeadTest.inlineRecord.IR.c + Dead RecordLabel +DeadTest.inlineRecord.IR.d + Live (annotated) RecordLabel +DeadTest.inlineRecord.IR.e + Live (external ref) Value +DeadTest.+ira + Dead Value +DeadTest.+_ + Dead VariantCase +DeadTest.inlineRecord2.IR2 + Dead RecordLabel +DeadTest.inlineRecord2.IR2.a + Dead RecordLabel +DeadTest.inlineRecord2.IR2.b + Dead VariantCase +DeadTest.inlineRecord3.IR3 + Dead RecordLabel +DeadTest.inlineRecord3.IR3.a + Dead RecordLabel +DeadTest.inlineRecord3.IR3.b + Dead Value +DeadTestBlacklist.+x + Dead Value +DeadTestWithInterface.Ext_buffer.+x + Dead Value +DeadTestWithInterface.Ext_buffer.+x + Live (external ref) VariantCase +DeadTypeTest.t.A + Dead VariantCase +DeadTypeTest.t.B + Dead Value +DeadTypeTest.+a + Live (external ref) VariantCase +DeadTypeTest.deadType.OnlyInImplementation + Live (propagated) VariantCase +DeadTypeTest.deadType.OnlyInInterface + Live (external ref) VariantCase +DeadTypeTest.deadType.InBoth + Dead VariantCase +DeadTypeTest.deadType.InNeither + Dead Value +DeadTypeTest.+_ + Dead Value +DeadTypeTest.+_ + Live (annotated) RecordLabel +DeadTypeTest.record.x + Live (annotated) RecordLabel +DeadTypeTest.record.y + Live (annotated) RecordLabel +DeadTypeTest.record.z + Live (propagated) VariantCase DeadTypeTest.t.A + Dead VariantCase DeadTypeTest.t.B + Dead Value DeadTypeTest.+a + Live (propagated) VariantCase DeadTypeTest.deadType.OnlyInImplementation + Live (external ref) VariantCase DeadTypeTest.deadType.OnlyInInterface + Live (external ref) VariantCase DeadTypeTest.deadType.InBoth + Dead VariantCase DeadTypeTest.deadType.InNeither + Live (propagated) Value +DeadValueTest.+valueAlive + Dead Value +DeadValueTest.+valueDead + Dead Value +DeadValueTest.+valueOnlyInImplementation + Dead Value +DeadValueTest.+subList + Dead Value +DeadValueTest.+tail + Live (external ref) Value DeadValueTest.+valueAlive + Dead Value DeadValueTest.+valueDead + Live (annotated) Value +Docstrings.+flat + Live (annotated) Value +Docstrings.+signMessage + Live (annotated) Value +Docstrings.+one + Live (annotated) Value +Docstrings.+two + Live (annotated) Value +Docstrings.+tree + Live (annotated) Value +Docstrings.+oneU + Live (annotated) Value +Docstrings.+twoU + Live (annotated) Value +Docstrings.+treeU + Live (annotated) Value +Docstrings.+useParam + Live (annotated) Value +Docstrings.+useParamU + Live (annotated) Value +Docstrings.+unnamed1 + Live (annotated) Value +Docstrings.+unnamed1U + Live (annotated) Value +Docstrings.+unnamed2 + Live (annotated) Value +Docstrings.+unnamed2U + Live (annotated) Value +Docstrings.+grouped + Live (annotated) Value +Docstrings.+unitArgWithoutConversion + Live (annotated) Value +Docstrings.+unitArgWithoutConversionU + Live (external ref) VariantCase +Docstrings.t.A + Dead VariantCase +Docstrings.t.B + Live (annotated) Value +Docstrings.+unitArgWithConversion + Live (annotated) Value +Docstrings.+unitArgWithConversionU + Live (external ref) Value +DynamicallyLoadedComponent.+make + Live (external ref) RecordLabel +DynamicallyLoadedComponent.props.s + Live (external ref) Value +EmptyArray.Z.+make + Live (propagated) Value +ErrorHandler.Make.+notify + Dead Value +ErrorHandler.+x + Live (external ref) Value ErrorHandler.Make.+notify + Dead Value ErrorHandler.+x + Dead Value +EverythingLiveHere.+x + Dead Value +EverythingLiveHere.+y + Dead Value +EverythingLiveHere.+z + Live (external ref) Value +FirstClassModules.M.+y + Live (external ref) Value +FirstClassModules.M.InnerModule2.+k + Live (external ref) Value +FirstClassModules.M.InnerModule3.+k3 + Live (external ref) Value +FirstClassModules.M.Z.+u + Live (external ref) Value +FirstClassModules.M.+x + Live (annotated) Value +FirstClassModules.+firstClassModule + Live (annotated) Value +FirstClassModules.+testConvert + Live (external ref) Value +FirstClassModules.SomeFunctor.+ww + Live (annotated) Value +FirstClassModules.+someFunctorAsFunction + Dead RecordLabel +FirstClassModulesInterface.record.x + Dead RecordLabel +FirstClassModulesInterface.record.y + Dead Value +FirstClassModulesInterface.+r + Dead RecordLabel FirstClassModulesInterface.record.x + Dead RecordLabel FirstClassModulesInterface.record.y + Dead Value FirstClassModulesInterface.+r + Live (external ref) RecordLabel +Hooks.vehicle.name + Live (propagated) Value +Hooks.+make + Live (external ref) RecordLabel +Hooks.props.vehicle + Live (annotated) Value +Hooks.+default + Live (annotated) Value +Hooks.Inner.+make + Live (external ref) RecordLabel +Hooks.Inner.props.vehicle + Live (annotated) Value +Hooks.Inner.Inner2.+make + Live (external ref) RecordLabel +Hooks.Inner.Inner2.props.vehicle + Live (annotated) Value +Hooks.NoProps.+make + Live (annotated) Value +Hooks.+functionWithRenamedArgs + Dead RecordLabel +Hooks.r.x + Live (annotated) Value +Hooks.RenderPropRequiresConversion.+make + Live (external ref) RecordLabel +Hooks.RenderPropRequiresConversion.props.renderVehicle + Live (external ref) Value +Hooks.RenderPropRequiresConversion.+car + Live (propagated) Value +ImmutableArray.+fromArray + Dead Value +ImmutableArray.+toArray + Dead Value +ImmutableArray.+length + Dead Value +ImmutableArray.+size + Live (propagated) Value +ImmutableArray.+get + Dead Value +ImmutableArray.+getExn + Dead Value +ImmutableArray.+getUnsafe + Dead Value +ImmutableArray.+getUndefined + Dead Value +ImmutableArray.+shuffle + Dead Value +ImmutableArray.+reverse + Dead Value +ImmutableArray.+makeUninitialized + Dead Value +ImmutableArray.+makeUninitializedUnsafe + Dead Value +ImmutableArray.+make + Dead Value +ImmutableArray.+range + Dead Value +ImmutableArray.+rangeBy + Dead Value +ImmutableArray.+makeByU + Dead Value +ImmutableArray.+makeBy + Dead Value +ImmutableArray.+makeByAndShuffleU + Dead Value +ImmutableArray.+makeByAndShuffle + Dead Value +ImmutableArray.+zip + Dead Value +ImmutableArray.+zipByU + Dead Value +ImmutableArray.+zipBy + Dead Value +ImmutableArray.+unzip + Dead Value +ImmutableArray.+concat + Dead Value +ImmutableArray.+concatMany + Dead Value +ImmutableArray.+slice + Dead Value +ImmutableArray.+sliceToEnd + Dead Value +ImmutableArray.+copy + Dead Value +ImmutableArray.+forEachU + Dead Value +ImmutableArray.+forEach + Dead Value +ImmutableArray.+mapU + Dead Value +ImmutableArray.+map + Dead Value +ImmutableArray.+keepWithIndexU + Dead Value +ImmutableArray.+keepWithIndex + Dead Value +ImmutableArray.+keepMapU + Dead Value +ImmutableArray.+keepMap + Dead Value +ImmutableArray.+forEachWithIndexU + Dead Value +ImmutableArray.+forEachWithIndex + Dead Value +ImmutableArray.+mapWithIndexU + Dead Value +ImmutableArray.+mapWithIndex + Dead Value +ImmutableArray.+partitionU + Dead Value +ImmutableArray.+partition + Dead Value +ImmutableArray.+reduceU + Dead Value +ImmutableArray.+reduce + Dead Value +ImmutableArray.+reduceReverseU + Dead Value +ImmutableArray.+reduceReverse + Dead Value +ImmutableArray.+reduceReverse2U + Dead Value +ImmutableArray.+reduceReverse2 + Dead Value +ImmutableArray.+someU + Dead Value +ImmutableArray.+some + Dead Value +ImmutableArray.+everyU + Dead Value +ImmutableArray.+every + Dead Value +ImmutableArray.+every2U + Dead Value +ImmutableArray.+every2 + Dead Value +ImmutableArray.+some2U + Dead Value +ImmutableArray.+some2 + Dead Value +ImmutableArray.+cmpU + Dead Value +ImmutableArray.+cmp + Dead Value +ImmutableArray.+eqU + Dead Value +ImmutableArray.+eq + Live (propagated) Value ImmutableArray.Array.+get + Live (external ref) Value ImmutableArray.+fromArray + Dead Value ImmutableArray.+toArray + Dead Value ImmutableArray.+length + Dead Value ImmutableArray.+size + Dead Value ImmutableArray.+get + Dead Value ImmutableArray.+getExn + Dead Value ImmutableArray.+getUnsafe + Dead Value ImmutableArray.+getUndefined + Dead Value ImmutableArray.+shuffle + Dead Value ImmutableArray.+reverse + Dead Value ImmutableArray.+makeUninitialized + Dead Value ImmutableArray.+makeUninitializedUnsafe + Dead Value ImmutableArray.+make + Dead Value ImmutableArray.+range + Dead Value ImmutableArray.+rangeBy + Dead Value ImmutableArray.+makeByU + Dead Value ImmutableArray.+makeBy + Dead Value ImmutableArray.+makeByAndShuffleU + Dead Value ImmutableArray.+makeByAndShuffle + Dead Value ImmutableArray.+zip + Dead Value ImmutableArray.+zipByU + Dead Value ImmutableArray.+zipBy + Dead Value ImmutableArray.+unzip + Dead Value ImmutableArray.+concat + Dead Value ImmutableArray.+concatMany + Dead Value ImmutableArray.+slice + Dead Value ImmutableArray.+sliceToEnd + Dead Value ImmutableArray.+copy + Dead Value ImmutableArray.+forEachU + Dead Value ImmutableArray.+forEach + Dead Value ImmutableArray.+mapU + Dead Value ImmutableArray.+map + Dead Value ImmutableArray.+keepWithIndexU + Dead Value ImmutableArray.+keepWithIndex + Dead Value ImmutableArray.+keepMapU + Dead Value ImmutableArray.+keepMap + Dead Value ImmutableArray.+forEachWithIndexU + Dead Value ImmutableArray.+forEachWithIndex + Dead Value ImmutableArray.+mapWithIndexU + Dead Value ImmutableArray.+mapWithIndex + Dead Value ImmutableArray.+partitionU + Dead Value ImmutableArray.+partition + Dead Value ImmutableArray.+reduceU + Dead Value ImmutableArray.+reduce + Dead Value ImmutableArray.+reduceReverseU + Dead Value ImmutableArray.+reduceReverse + Dead Value ImmutableArray.+reduceReverse2U + Dead Value ImmutableArray.+reduceReverse2 + Dead Value ImmutableArray.+someU + Dead Value ImmutableArray.+some + Dead Value ImmutableArray.+everyU + Dead Value ImmutableArray.+every + Dead Value ImmutableArray.+every2U + Dead Value ImmutableArray.+every2 + Dead Value ImmutableArray.+some2U + Dead Value ImmutableArray.+some2 + Dead Value ImmutableArray.+cmpU + Dead Value ImmutableArray.+cmp + Dead Value ImmutableArray.+eqU + Dead Value ImmutableArray.+eq + Dead RecordLabel +ImportHookDefault.person.name + Dead RecordLabel +ImportHookDefault.person.age + Live (annotated) Value +ImportHookDefault.+make + Live (annotated) RecordLabel +ImportHookDefault.props.person + Live (annotated) RecordLabel +ImportHookDefault.props.children + Live (annotated) RecordLabel +ImportHookDefault.props.renderMe + Dead RecordLabel +ImportHooks.person.name + Dead RecordLabel +ImportHooks.person.age + Live (annotated) Value +ImportHooks.+make + Live (annotated) RecordLabel +ImportHooks.props.person + Live (annotated) RecordLabel +ImportHooks.props.children + Live (annotated) RecordLabel +ImportHooks.props.renderMe + Live (annotated) Value +ImportHooks.+foo + Live (annotated) Value +ImportIndex.+make + Live (annotated) RecordLabel +ImportIndex.props.method + Live (annotated) Value +ImportJsValue.+round + Dead RecordLabel +ImportJsValue.point.x + Dead RecordLabel +ImportJsValue.point.y + Live (annotated) Value +ImportJsValue.+area + Live (annotated) Value +ImportJsValue.+returnMixedArray + Live (annotated) Value +ImportJsValue.+roundedNumber + Live (annotated) Value +ImportJsValue.+areaValue + Live (propagated) Value +ImportJsValue.AbsoluteValue.+getAbs + Live (propagated) Value +ImportJsValue.AbsoluteValue.+getAbs + Live (annotated) Value +ImportJsValue.+useGetProp + Live (annotated) Value +ImportJsValue.+useGetAbs + Live (annotated) Value +ImportJsValue.+useColor + Live (annotated) Value +ImportJsValue.+higherOrder + Live (annotated) Value +ImportJsValue.+returnedFromHigherOrder + Dead VariantCase +ImportJsValue.variant.I + Dead VariantCase +ImportJsValue.variant.S + Live (annotated) Value +ImportJsValue.+convertVariant + Live (annotated) Value +ImportJsValue.+polymorphic + Live (annotated) Value +ImportJsValue.+default + Dead RecordLabel +ImportMyBanner.message.text + Live (annotated) Value +ImportMyBanner.+make + Dead Value +ImportMyBanner.+make + Live (propagated) VariantCase +InnerModuleTypes.I.t.Foo + Live (external ref) VariantCase InnerModuleTypes.I.t.Foo + Live (external ref) Value +JsxV4.C.+make + Live (annotated) Value +LetPrivate.local_1.+x + Live (annotated) Value +LetPrivate.+y + Dead RecordLabel +ModuleAliases.Outer.Inner.innerT.inner + Dead RecordLabel +ModuleAliases.Outer2.Inner2.InnerNested.t.nested + Live (annotated) Value +ModuleAliases.+testNested + Live (annotated) Value +ModuleAliases.+testInner + Live (annotated) Value +ModuleAliases.+testInner2 + Dead RecordLabel +ModuleAliases2.record.x + Dead RecordLabel +ModuleAliases2.record.y + Dead RecordLabel +ModuleAliases2.Outer.outer.outer + Dead RecordLabel +ModuleAliases2.Outer.Inner.inner.inner + Dead Value +ModuleAliases2.+q + Dead Value +ModuleExceptionBug.Dep.+customDouble + Dead Exception +ModuleExceptionBug.MyOtherException + Live (external ref) Value +ModuleExceptionBug.+ddjdj + Live (annotated) Value +NestedModules.+notNested + Live (annotated) Value +NestedModules.Universe.+theAnswer + Dead Value +NestedModules.Universe.+notExported + Dead Value +NestedModules.Universe.Nested2.+x + Live (annotated) Value +NestedModules.Universe.Nested2.+nested2Value + Dead Value +NestedModules.Universe.Nested2.+y + Dead Value +NestedModules.Universe.Nested2.Nested3.+x + Dead Value +NestedModules.Universe.Nested2.Nested3.+y + Dead Value +NestedModules.Universe.Nested2.Nested3.+z + Dead Value +NestedModules.Universe.Nested2.Nested3.+w + Live (annotated) Value +NestedModules.Universe.Nested2.Nested3.+nested3Value + Live (annotated) Value +NestedModules.Universe.Nested2.Nested3.+nested3Function + Live (annotated) Value +NestedModules.Universe.Nested2.+nested2Function + Dead VariantCase +NestedModules.Universe.variant.A + Dead VariantCase +NestedModules.Universe.variant.B + Live (annotated) Value +NestedModules.Universe.+someString + Live (propagated) Value +NestedModulesInSignature.Universe.+theAnswer + Live (annotated) Value NestedModulesInSignature.Universe.+theAnswer + Dead Value +Newsyntax.+x + Dead Value +Newsyntax.+y + Dead RecordLabel +Newsyntax.record.xxx + Dead RecordLabel +Newsyntax.record.yyy + Dead VariantCase +Newsyntax.variant.A + Dead VariantCase +Newsyntax.variant.B + Dead VariantCase +Newsyntax.variant.C + Dead RecordLabel +Newsyntax.record2.xx + Dead RecordLabel +Newsyntax.record2.yy + Live (propagated) Value +Newton.+- + Live (propagated) Value +Newton.++ + Live (propagated) Value +Newton.+* + Live (propagated) Value +Newton.+/ + Live (propagated) Value +Newton.+newton + Live (propagated) Value +Newton.+current + Live (propagated) Value +Newton.+iterateMore + Live (propagated) Value +Newton.+delta + Live (propagated) Value +Newton.+loop + Live (propagated) Value +Newton.+previous + Live (propagated) Value +Newton.+next + Live (external ref) Value +Newton.+f + Live (propagated) Value +Newton.+fPrimed + Live (external ref) Value +Newton.+result + Dead VariantCase +Opaque.opaqueFromRecords.A + Live (annotated) Value +Opaque.+noConversion + Live (annotated) Value +Opaque.+testConvertNestedRecordFromOtherFile + Live (external ref) Value +OptArg.+foo + Live (external ref) Value +OptArg.+bar + Live (external ref) Value +OptArg.+threeArgs + Live (external ref) Value +OptArg.+twoArgs + Live (propagated) Value +OptArg.+oneArg + Live (external ref) Value +OptArg.+wrapOneArg + Live (propagated) Value +OptArg.+fourArgs + Live (external ref) Value +OptArg.+wrapfourArgs + Dead Value OptArg.+foo + Live (external ref) Value OptArg.+bar + Live (propagated) Value +OptionalArgsLiveDead.+formatDate + Dead Value +OptionalArgsLiveDead.+deadCaller + Live (external ref) Value +OptionalArgsLiveDead.+liveCaller + Live (external ref) RecordLabel +Records.coord.x + Live (external ref) RecordLabel +Records.coord.y + Live (external ref) RecordLabel +Records.coord.z + Live (annotated) Value +Records.+origin + Live (annotated) Value +Records.+computeArea + Live (annotated) Value +Records.+coord2d + Dead RecordLabel +Records.person.name + Dead RecordLabel +Records.person.age + Live (external ref) RecordLabel +Records.person.address + Dead RecordLabel +Records.business.name + Live (external ref) RecordLabel +Records.business.owner + Live (external ref) RecordLabel +Records.business.address + Live (propagated) Value +Records.+getOpt + Live (annotated) Value +Records.+findAddress + Live (annotated) Value +Records.+someBusiness + Live (annotated) Value +Records.+findAllAddresses + Dead RecordLabel +Records.payload.num + Live (external ref) RecordLabel +Records.payload.payload + Live (annotated) Value +Records.+getPayload + Live (external ref) RecordLabel +Records.record.v + Dead RecordLabel +Records.record.w + Live (annotated) Value +Records.+getPayloadRecord + Live (annotated) Value +Records.+recordValue + Live (annotated) Value +Records.+payloadValue + Live (annotated) Value +Records.+getPayloadRecordPlusOne + Dead RecordLabel +Records.business2.name + Dead RecordLabel +Records.business2.owner + Live (external ref) RecordLabel +Records.business2.address2 + Live (annotated) Value +Records.+findAddress2 + Live (annotated) Value +Records.+someBusiness2 + Live (annotated) Value +Records.+computeArea3 + Live (annotated) Value +Records.+computeArea4 + Live (external ref) RecordLabel +Records.myRec.type_ + Live (annotated) Value +Records.+testMyRec + Live (annotated) Value +Records.+testMyRec2 + Live (annotated) Value +Records.+testMyObj + Live (annotated) Value +Records.+testMyObj2 + Live (external ref) RecordLabel +Records.myRecBsAs.type_ + Live (annotated) Value +Records.+testMyRecBsAs + Live (annotated) Value +Records.+testMyRecBsAs2 + Live (annotated) Value +References.+create + Live (annotated) Value +References.+access + Live (annotated) Value +References.+update + Live (propagated) Value +References.R.+get + Live (propagated) Value +References.R.+make + Live (propagated) Value +References.R.+set + Live (propagated) Value +References.R.+get + Live (propagated) Value +References.R.+make + Live (propagated) Value +References.R.+set + Live (annotated) Value +References.+get + Live (annotated) Value +References.+make + Live (annotated) Value +References.+set + Dead RecordLabel +References.requiresConversion.x + Live (annotated) Value +References.+destroysRefIdentity + Live (annotated) Value +References.+preserveRefIdentity + Dead RecordLabel +RepeatedLabel.userData.a + Dead RecordLabel +RepeatedLabel.userData.b + Live (external ref) RecordLabel +RepeatedLabel.tabState.a + Live (external ref) RecordLabel +RepeatedLabel.tabState.b + Dead RecordLabel +RepeatedLabel.tabState.f + Live (external ref) Value +RepeatedLabel.+userData + Live (annotated) Value +Shadow.+test + Live (annotated) Value +Shadow.+test + Live (annotated) Value +Shadow.M.+test + Dead Value +Shadow.M.+test + Live (annotated) Value +TestEmitInnerModules.Inner.+x + Live (annotated) Value +TestEmitInnerModules.Inner.+y + Live (annotated) Value +TestEmitInnerModules.Outer.Medium.Inner.+y + Live (annotated) Value +TestFirstClassModules.+convert + Live (annotated) Value +TestFirstClassModules.+convertInterface + Live (annotated) Value +TestFirstClassModules.+convertRecord + Live (annotated) Value +TestFirstClassModules.+convertFirstClassModuleWithTypeEquations + Live (annotated) Value +TestImmutableArray.+testImmutableArrayGet + Dead Value +TestImmutableArray.+testBeltArrayGet + Dead Value +TestImmutableArray.+testBeltArraySet + Live (annotated) Value +TestImport.+innerStuffContents + Live (annotated) Value +TestImport.+innerStuffContentsAsEmptyObject + Dead Value +TestImport.+innerStuffContents + Live (annotated) Value +TestImport.+valueStartingWithUpperCaseLetter + Live (annotated) Value +TestImport.+defaultValue + Dead RecordLabel +TestImport.message.text + Live (annotated) Value +TestImport.+make + Dead Value +TestImport.+make + Live (annotated) Value +TestImport.+defaultValue2 + Dead Value +TestInnedModuleTypes.+_ + Live (annotated) Value +TestModuleAliases.+testInner1 + Live (annotated) Value +TestModuleAliases.+testInner1Expanded + Live (annotated) Value +TestModuleAliases.+testInner2 + Live (annotated) Value +TestModuleAliases.+testInner2Expanded + Live (propagated) Value +TestOptArg.+foo + Live (external ref) Value +TestOptArg.+bar + Live (external ref) Value +TestOptArg.+notSuppressesOptArgs + Live (annotated) Value +TestOptArg.+liveSuppressesOptArgs + Dead RecordLabel +TestPromise.fromPayload.x + Live (external ref) RecordLabel +TestPromise.fromPayload.s + Dead RecordLabel +TestPromise.toPayload.result + Live (annotated) Value +TestPromise.+convert + Dead Value +ToSuppress.+toSuppress + Live (annotated) Value +TransitiveType1.+convert + Live (annotated) Value +TransitiveType1.+convertAlias + Dead Value +TransitiveType2.+convertT2 + Dead RecordLabel +TransitiveType3.t3.i + Dead RecordLabel +TransitiveType3.t3.s + Live (annotated) Value +TransitiveType3.+convertT3 + Live (annotated) Value +Tuples.+testTuple + Live (annotated) Value +Tuples.+origin + Live (annotated) Value +Tuples.+computeArea + Live (annotated) Value +Tuples.+computeAreaWithIdent + Live (annotated) Value +Tuples.+computeAreaNoConverters + Live (annotated) Value +Tuples.+coord2d + Live (external ref) RecordLabel +Tuples.person.name + Live (external ref) RecordLabel +Tuples.person.age + Live (annotated) Value +Tuples.+getFirstName + Live (annotated) Value +Tuples.+marry + Live (annotated) Value +Tuples.+changeSecondAge + Dead Value +TypeParams1.+exportSomething + Dead RecordLabel +TypeParams2.item.id + Dead Value +TypeParams2.+exportSomething + Live (annotated) Value +TypeParams3.+test + Live (annotated) Value +TypeParams3.+test2 + Live (annotated) Value +Types.+someIntList + Live (annotated) Value +Types.+map + Dead VariantCase +Types.typeWithVars.A + Dead VariantCase +Types.typeWithVars.B + Live (annotated) Value +Types.+swap + Live (external ref) RecordLabel +Types.selfRecursive.self + Live (external ref) RecordLabel +Types.mutuallyRecursiveA.b + Dead RecordLabel +Types.mutuallyRecursiveB.a + Live (annotated) Value +Types.+selfRecursiveConverter + Live (annotated) Value +Types.+mutuallyRecursiveConverter + Live (annotated) Value +Types.+testFunctionOnOptionsAsArgument + Dead VariantCase +Types.opaqueVariant.A + Dead VariantCase +Types.opaqueVariant.B + Live (annotated) Value +Types.+jsStringT + Live (annotated) Value +Types.+jsString2T + Live (annotated) Value +Types.+jsonStringify + Dead RecordLabel +Types.record.i + Dead RecordLabel +Types.record.s + Live (annotated) Value +Types.+testConvertNull + Live (annotated) Value +Types.+testMarshalFields + Live (annotated) Value +Types.+setMatch + Dead RecordLabel +Types.someRecord.id + Live (annotated) Value +Types.+testInstantiateTypeParameter + Live (annotated) Value +Types.+currentTime + Live (annotated) Value +Types.+i64Const + Live (annotated) Value +Types.+optFunction + Dead Value +Types.ObjectId.+x + Dead VariantCase +Unboxed.v1.A + Dead VariantCase +Unboxed.v2.A + Live (annotated) Value +Unboxed.+testV1 + Dead RecordLabel +Unboxed.r1.x + Dead VariantCase +Unboxed.r2.B + Dead RecordLabel +Unboxed.r2.B.g + Live (annotated) Value +Unboxed.+r2Test + Live (annotated) Value +Uncurried.+uncurried0 + Live (annotated) Value +Uncurried.+uncurried1 + Live (annotated) Value +Uncurried.+uncurried2 + Live (annotated) Value +Uncurried.+uncurried3 + Live (annotated) Value +Uncurried.+curried3 + Live (annotated) Value +Uncurried.+callback + Live (external ref) RecordLabel +Uncurried.auth.login + Live (external ref) RecordLabel +Uncurried.authU.loginU + Live (annotated) Value +Uncurried.+callback2 + Live (annotated) Value +Uncurried.+callback2U + Live (annotated) Value +Uncurried.+sumU + Live (annotated) Value +Uncurried.+sumU2 + Live (annotated) Value +Uncurried.+sumCurried + Live (annotated) Value +Uncurried.+sumLblCurried + Live (external ref) VariantCase +Unison.break.IfNeed + Live (external ref) VariantCase +Unison.break.Never + Live (external ref) VariantCase +Unison.break.Always + Live (external ref) RecordLabel +Unison.t.break + Live (external ref) RecordLabel +Unison.t.doc + Live (external ref) VariantCase +Unison.stack.Empty + Live (external ref) VariantCase +Unison.stack.Cons + Live (external ref) Value +Unison.+group + Live (propagated) Value +Unison.+fits + Live (external ref) Value +Unison.+toString + Live (annotated) Value +UseImportJsValue.+useGetProp + Live (annotated) Value +UseImportJsValue.+useTypeImportedInOtherModule + Live (annotated) Value +Variants.+isWeekend + Live (annotated) Value +Variants.+monday + Live (annotated) Value +Variants.+saturday + Live (annotated) Value +Variants.+sunday + Live (annotated) Value +Variants.+onlySunday + Live (annotated) Value +Variants.+swap + Live (annotated) Value +Variants.+testConvert + Live (annotated) Value +Variants.+fortytwoOK + Live (annotated) Value +Variants.+fortytwoBAD + Live (annotated) Value +Variants.+testConvert2 + Live (annotated) Value +Variants.+testConvert3 + Live (annotated) Value +Variants.+testConvert2to3 + Live (annotated) Value +Variants.+id1 + Live (annotated) Value +Variants.+id2 + Dead VariantCase +Variants.type_.Type + Live (annotated) Value +Variants.+polyWithOpt + Dead VariantCase +Variants.result1.Ok + Dead VariantCase +Variants.result1.Error + Live (annotated) Value +Variants.+restResult1 + Live (annotated) Value +Variants.+restResult2 + Live (annotated) Value +Variants.+restResult3 + Live (external ref) RecordLabel +VariantsWithPayload.payload.x + Live (external ref) RecordLabel +VariantsWithPayload.payload.y + Live (annotated) Value +VariantsWithPayload.+testWithPayload + Live (annotated) Value +VariantsWithPayload.+printVariantWithPayload + Live (annotated) Value +VariantsWithPayload.+testManyPayloads + Live (annotated) Value +VariantsWithPayload.+printManyPayloads + Dead VariantCase +VariantsWithPayload.simpleVariant.A + Dead VariantCase +VariantsWithPayload.simpleVariant.B + Dead VariantCase +VariantsWithPayload.simpleVariant.C + Live (annotated) Value +VariantsWithPayload.+testSimpleVariant + Dead VariantCase +VariantsWithPayload.variantWithPayloads.A + Dead VariantCase +VariantsWithPayload.variantWithPayloads.B + Dead VariantCase +VariantsWithPayload.variantWithPayloads.C + Dead VariantCase +VariantsWithPayload.variantWithPayloads.D + Dead VariantCase +VariantsWithPayload.variantWithPayloads.E + Live (annotated) Value +VariantsWithPayload.+testVariantWithPayloads + Live (annotated) Value +VariantsWithPayload.+printVariantWithPayloads + Dead VariantCase +VariantsWithPayload.variant1Int.R + Live (annotated) Value +VariantsWithPayload.+testVariant1Int + Dead VariantCase +VariantsWithPayload.variant1Object.R + Live (annotated) Value +VariantsWithPayload.+testVariant1Object Incorrect Dead Annotation DeadTest.res:153:1-28 From 5f3c964b7d9da021fa4505e812d0894c1ec9d97a Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 17:08:46 +0100 Subject: [PATCH 16/45] Refactor hasRefBelow to use on-demand per-decl search - Remove RefsToLazy module (no longer builds inverse refs index) - Add shared make_hasRefBelow function used by both reactive and non-reactive modes - Non-reactive mode: iterates through References.t directly - Reactive mode: iterates through Reactive.t directly (no freezing needed) - Remove unused posHashFindSet and posHashAddSet helpers - Remove deadcode-minimal test directory (not useful) This eliminates the need for frozen refs in reactive mode when transitive=true, and shares the same O(total_refs) per-dead-decl algorithm between both modes. --- analysis/reanalyze/src/DeadCommon.ml | 103 ++++++++---------- analysis/reanalyze/src/Reanalyze.ml | 12 +- .../deadcode-minimal/.gitignore | 5 - .../deadcode-minimal/README.md | 36 ------ .../deadcode-minimal/package.json | 11 -- .../deadcode-minimal/rescript.json | 13 --- .../deadcode-minimal/src/DeadTest.res | 9 -- 7 files changed, 51 insertions(+), 138 deletions(-) delete mode 100644 tests/analysis_tests/tests-reanalyze/deadcode-minimal/.gitignore delete mode 100644 tests/analysis_tests/tests-reanalyze/deadcode-minimal/README.md delete mode 100644 tests/analysis_tests/tests-reanalyze/deadcode-minimal/package.json delete mode 100644 tests/analysis_tests/tests-reanalyze/deadcode-minimal/rescript.json delete mode 100644 tests/analysis_tests/tests-reanalyze/deadcode-minimal/src/DeadTest.res diff --git a/analysis/reanalyze/src/DeadCommon.ml b/analysis/reanalyze/src/DeadCommon.ml index b00cea42e2..4d401e94c7 100644 --- a/analysis/reanalyze/src/DeadCommon.ml +++ b/analysis/reanalyze/src/DeadCommon.ml @@ -28,13 +28,6 @@ let fileIsImplementationOf s1 s2 = let liveAnnotation = "live" -(* Helper functions for PosHash with PosSet values *) -let posHashFindSet h k = try PosHash.find h k with Not_found -> PosSet.empty - -let posHashAddSet h k v = - let set = posHashFindSet h k in - PosHash.replace h k (PosSet.add v set) - type decls = Decl.t PosHash.t (** type alias for declaration hashtables *) @@ -55,30 +48,6 @@ end (* NOTE: Global TypeReferences removed - now using References.builder/t pattern *) -(** Lazy computation of refs_to from refs_from. - Only computed when debug or transitive mode is enabled. - Zero cost in the common case. *) -module RefsToLazy = struct - type t = {value_refs_to: PosSet.t PosHash.t; type_refs_to: PosSet.t PosHash.t} - - (** Compute refs_to by inverting refs_from. O(total_refs) one-time cost. *) - let compute (refs : References.t) : t = - let value_refs_to = PosHash.create 256 in - let type_refs_to = PosHash.create 256 in - References.iter_value_refs_from refs (fun posFrom posToSet -> - PosSet.iter - (fun posTo -> posHashAddSet value_refs_to posTo posFrom) - posToSet); - References.iter_type_refs_from refs (fun posFrom posToSet -> - PosSet.iter - (fun posTo -> posHashAddSet type_refs_to posTo posFrom) - posToSet); - {value_refs_to; type_refs_to} - - let find_value_refs (t : t) pos = posHashFindSet t.value_refs_to pos - let find_type_refs (t : t) pos = posHashFindSet t.type_refs_to pos -end - let declGetLoc decl = let loc_start = let offset = @@ -178,11 +147,32 @@ let isInsideReportedValue (ctx : ReportingContext.t) decl = ReportingContext.set_max_end ctx decl.posEnd; insideReportedValue +(** Check if a reference position is "below" the declaration. + A ref is below if it's in a different file, or comes after the declaration + (but not inside it, e.g. not a callback). *) +let refIsBelow (decl : Decl.t) (posFrom : Lexing.position) = + decl.pos.pos_fname <> posFrom.pos_fname + || decl.pos.pos_cnum < posFrom.pos_cnum + && + (* not a function defined inside a function, e.g. not a callback *) + decl.posEnd.pos_cnum < posFrom.pos_cnum + +(** Create hasRefBelow function using on-demand per-decl search. + [iter_value_refs_from] iterates over (posFrom, posToSet) pairs. + O(total_refs) per dead decl, but dead decls should be few. *) +let make_hasRefBelow ~transitive ~iter_value_refs_from = + if transitive then fun _ -> false + else fun decl -> + let found = ref false in + iter_value_refs_from (fun posFrom posToSet -> + if (not !found) && PosSet.mem decl.Decl.pos posToSet then + if refIsBelow decl posFrom then found := true); + !found + (** Report a dead declaration. Returns list of issues (dead module first, then dead value). - [refs_to_opt] is only needed when [config.run.transitive] is false, since the - non-transitive mode suppresses some warnings when there are references "below" - the declaration (requires inverse refs). Caller is responsible for logging. *) -let reportDeclaration ~config ~refs_to_opt (ctx : ReportingContext.t) decl : + [hasRefBelow] checks if there are references from "below" the declaration. + Only used when [config.run.transitive] is false. *) +let reportDeclaration ~config ~hasRefBelow (ctx : ReportingContext.t) decl : Issue.t list = let insideReportedValue = decl |> isInsideReportedValue ctx in if not decl.report then [] @@ -216,26 +206,12 @@ let reportDeclaration ~config ~refs_to_opt (ctx : ReportingContext.t) decl : | VariantCase -> (WarningDeadType, "is a variant case which is never constructed") in - let hasRefBelow () = - match refs_to_opt with - | None -> false (* No refs_to available, assume no ref below *) - | Some refs_to -> - let decl_refs = RefsToLazy.find_value_refs refs_to decl.pos in - let refIsBelow (pos : Lexing.position) = - decl.pos.pos_fname <> pos.pos_fname - || decl.pos.pos_cnum < pos.pos_cnum - && - (* not a function defined inside a function, e.g. not a callback *) - decl.posEnd.pos_cnum < pos.pos_cnum - in - decl_refs |> PosSet.exists refIsBelow - in let shouldEmitWarning = (not insideReportedValue) && (match decl.path with | name :: _ when name |> Name.isUnderscore -> Config.reportUnderscore | _ -> true) - && (config.DceConfig.run.transitive || not (hasRefBelow ())) + && (config.DceConfig.run.transitive || not (hasRefBelow decl)) in if shouldEmitWarning then let dead_module_issue = @@ -268,9 +244,10 @@ let solveDeadForward ~ann_store ~config ~decl_store ~refs ~optional_args_state let transitive = config.DceConfig.run.transitive in let live = Liveness.compute_forward ~debug ~decl_store ~refs ~ann_store in - (* Inverse refs are only needed for non-transitive reporting (hasRefBelow). *) - let refs_to_opt = - if not transitive then Some (RefsToLazy.compute refs) else None + (* hasRefBelow uses on-demand search through refs_from *) + let hasRefBelow = + make_hasRefBelow ~transitive + ~iter_value_refs_from:(References.iter_value_refs_from refs) in (* Process each declaration based on computed liveness *) @@ -342,13 +319,15 @@ let solveDeadForward ~ann_store ~config ~decl_store ~refs ~optional_args_state let dead_issues = sortedDeadDeclarations |> List.concat_map (fun decl -> - reportDeclaration ~config ~refs_to_opt reporting_ctx decl) + reportDeclaration ~config ~hasRefBelow reporting_ctx decl) in let all_issues = List.rev !inline_issues @ dead_issues in AnalysisResult.add_issues AnalysisResult.empty all_issues -(** Reactive solver using reactive liveness collection. *) -let solveDeadReactive ~ann_store ~config ~decl_store ~refs +(** Reactive solver using reactive liveness collection. + [value_refs_from] is only needed when [transitive=false] for hasRefBelow. + Pass [None] when [transitive=true] to avoid any refs computation. *) +let solveDeadReactive ~ann_store ~config ~decl_store ~value_refs_from ~(live : (Lexing.position, unit) Reactive.t) ~(roots : (Lexing.position, unit) Reactive.t) ~optional_args_state ~checkOptionalArg: @@ -362,9 +341,13 @@ let solveDeadReactive ~ann_store ~config ~decl_store ~refs let transitive = config.DceConfig.run.transitive in let is_live pos = Reactive.get live pos <> None in - (* Inverse refs are only needed for non-transitive reporting (hasRefBelow). *) - let refs_to_opt = - if not transitive then Some (RefsToLazy.compute refs) else None + (* hasRefBelow uses on-demand search through value_refs_from *) + let hasRefBelow = + match value_refs_from with + | None -> fun _ -> false + | Some refs_from -> + make_hasRefBelow ~transitive ~iter_value_refs_from:(fun f -> + Reactive.iter f refs_from) in (* Process each declaration based on computed liveness *) @@ -442,7 +425,7 @@ let solveDeadReactive ~ann_store ~config ~decl_store ~refs let dead_issues = sortedDeadDeclarations |> List.concat_map (fun decl -> - reportDeclaration ~config ~refs_to_opt reporting_ctx decl) + reportDeclaration ~config ~hasRefBelow reporting_ctx decl) in let all_issues = List.rev !inline_issues @ dead_issues in AnalysisResult.add_issues AnalysisResult.empty all_issues diff --git a/analysis/reanalyze/src/Reanalyze.ml b/analysis/reanalyze/src/Reanalyze.ml index d831fd958d..a2762ed381 100644 --- a/analysis/reanalyze/src/Reanalyze.ml +++ b/analysis/reanalyze/src/Reanalyze.ml @@ -371,10 +371,14 @@ let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge | Some merged, Some liveness_result -> let live = liveness_result.ReactiveLiveness.live in let roots = liveness_result.ReactiveLiveness.roots in - (* Freeze refs for debug/transitive support in solver *) - let refs = ReactiveMerge.freeze_refs merged in - DeadCommon.solveDeadReactive ~ann_store ~decl_store ~refs ~live - ~roots ~optional_args_state:empty_optional_args_state + (* Pass value_refs_from for on-demand hasRefBelow (no freezing) *) + let value_refs_from = + if dce_config.DceConfig.run.transitive then None + else Some merged.ReactiveMerge.value_refs_from + in + DeadCommon.solveDeadReactive ~ann_store ~decl_store + ~value_refs_from ~live ~roots + ~optional_args_state:empty_optional_args_state ~config:dce_config ~checkOptionalArg:(fun ~optional_args_state:_ ~ann_store:_ ~config:_ _ -> []) diff --git a/tests/analysis_tests/tests-reanalyze/deadcode-minimal/.gitignore b/tests/analysis_tests/tests-reanalyze/deadcode-minimal/.gitignore deleted file mode 100644 index 931e87a662..0000000000 --- a/tests/analysis_tests/tests-reanalyze/deadcode-minimal/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -lib/ -node_modules/ -.bsb.lock -.DS_Store - diff --git a/tests/analysis_tests/tests-reanalyze/deadcode-minimal/README.md b/tests/analysis_tests/tests-reanalyze/deadcode-minimal/README.md deleted file mode 100644 index 56e193426c..0000000000 --- a/tests/analysis_tests/tests-reanalyze/deadcode-minimal/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Deadcode Minimal - -This is a small end-to-end regression test for reactive deadcode analysis. - -Historically this directory was used to minimize an incremental fixpoint bug (re-derivation -after removals). The underlying issue has since been fixed; this test remains to prevent -regressions. - -## Test File (src/DeadTest.res) - -```rescript -module MM: { - let x: int - let y: int -} = { - let y = 55 // BUG: incorrectly marked dead in reactive mode - let x = y // live (externally referenced) -} - -let _ = Js.log(MM.x) // external reference to x -``` - -## Running - -```bash -# Build -../../../../cli/rescript.js build - -# Non-reactive (correct): 1 issue (signature y) -dune exec rescript-editor-analysis -- reanalyze -config -ci - -# Reactive -dune exec rescript-editor-analysis -- reanalyze -config -ci -reactive -``` - -Reactive and non-reactive should report the same results. diff --git a/tests/analysis_tests/tests-reanalyze/deadcode-minimal/package.json b/tests/analysis_tests/tests-reanalyze/deadcode-minimal/package.json deleted file mode 100644 index 4e24ba92b7..0000000000 --- a/tests/analysis_tests/tests-reanalyze/deadcode-minimal/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@tests/deadcode-minimal", - "private": true, - "scripts": { - "build": "rescript build", - "clean": "rescript clean" - }, - "dependencies": { - "rescript": "workspace:^" - } -} diff --git a/tests/analysis_tests/tests-reanalyze/deadcode-minimal/rescript.json b/tests/analysis_tests/tests-reanalyze/deadcode-minimal/rescript.json deleted file mode 100644 index 9eaeb9bcd3..0000000000 --- a/tests/analysis_tests/tests-reanalyze/deadcode-minimal/rescript.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "reanalyze": { - "analysis": ["dce"], - "transitive": true - }, - "name": "@tests/deadcode-minimal", - "sources": ["src"], - "package-specs": { - "module": "esmodule", - "in-source": false - }, - "suffix": ".res.js" -} diff --git a/tests/analysis_tests/tests-reanalyze/deadcode-minimal/src/DeadTest.res b/tests/analysis_tests/tests-reanalyze/deadcode-minimal/src/DeadTest.res deleted file mode 100644 index 1722b49fa5..0000000000 --- a/tests/analysis_tests/tests-reanalyze/deadcode-minimal/src/DeadTest.res +++ /dev/null @@ -1,9 +0,0 @@ -module MM: { - let x: int - let y: int -} = { - let y = 55 - let x = y -} - -let _ = Js.log(MM.x) From 49bcf5ceb5c6275a606435b835fe2791eb0ccbc8 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 20:23:43 +0100 Subject: [PATCH 17/45] Update ReactiveSolver TODO with accurate status and missing issues - Clarify current status: collect_issues iterates all dead_decls + live_decls - Document TODO items: isInsideReportedValue, hasRefBelow, module marking - Document missing issues: 18 optional args (6 Redundant + 12 Unused) - Document correct issues: 362 dead code + 1 incorrect @dead --- analysis/reanalyze/ARCHITECTURE.md | 138 +++++++++++++----- .../reanalyze/diagrams/reactive-pipeline.mmd | 22 ++- .../reanalyze/diagrams/reactive-pipeline.svg | 2 +- analysis/reanalyze/src/DeadCommon.ml | 42 +++++- analysis/reanalyze/src/ReactiveSolver.ml | 137 +++++++++++++++++ analysis/reanalyze/src/ReactiveSolver.mli | 26 ++++ analysis/reanalyze/src/Reanalyze.ml | 119 +++++++++------ analysis/src/DceCommand.ml | 2 +- .../deadcode-benchmark/Makefile | 2 +- 9 files changed, 403 insertions(+), 87 deletions(-) create mode 100644 analysis/reanalyze/src/ReactiveSolver.ml create mode 100644 analysis/reanalyze/src/ReactiveSolver.mli diff --git a/analysis/reanalyze/ARCHITECTURE.md b/analysis/reanalyze/ARCHITECTURE.md index 5bc5ad4e50..cb264c4d36 100644 --- a/analysis/reanalyze/ARCHITECTURE.md +++ b/analysis/reanalyze/ARCHITECTURE.md @@ -159,49 +159,119 @@ The reactive layer (`analysis/reactive/`) provides delta-based incremental updat | `lookup` | Single-key subscription | | `ReactiveFileCollection` | File-backed collection with change detection | -### Reactive Analysis Pipeline +### Fully Reactive Analysis Pipeline + +The reactive pipeline computes issues directly from source files with **zero recomputation on cache hits**: + +``` +Files → file_data → decls, annotations, refs → live (fixpoint) → dead_decls → issues → REPORT + ↓ ↓ ↓ ↓ ↓ ↓ + ReactiveFile ReactiveMerge ReactiveLiveness ReactiveSolver iter + Collection (flatMap) (fixpoint) (join+join) (only) +``` + +**Key property**: When no files change, no computation happens. All reactive collections are stable. Only the final `collect_issues` call iterates (O(issues)). + +### Pipeline Stages + +| Stage | Input | Output | Combinator | +|-------|-------|--------|------------| +| **File Processing** | `.cmt` files | `file_data` | `ReactiveFileCollection` | +| **Merge** | `file_data` | `decls`, `annotations`, `refs` | `flatMap` | +| **Liveness** | `refs`, `annotations` | `live` (positions) | `fixpoint` | +| **Dead Decls** | `decls`, `live` | `dead_decls` | `join` (left-join, filter `None`: decls where NOT in live) | +| **Issues** | `dead_decls`, `annotations` | `issues` | `join` (filter by annotation, generate Issue.t) | +| **Report** | `issues` | stdout | `iter` (ONLY iteration in entire pipeline) | + +**Note**: Optional args analysis (unused/redundant arguments) is not yet in the reactive pipeline - it still uses the non-reactive path. TODO: Add `live_decls + cross_file_items → optional_args_issues` to the reactive pipeline. + +### Reactive Pipeline Diagram > **Source**: [`diagrams/reactive-pipeline.mmd`](diagrams/reactive-pipeline.mmd) ![Reactive Pipeline](diagrams/reactive-pipeline.svg) -**Legend:** - -| Symbol | Collection | Type | -|--------|-----------|------| -| **RFC** | `ReactiveFileCollection` | File change detection | -| **FD** | `file_data` | `path → file_data option` | -| **D** | `decls` | `pos → Decl.t` | -| **A** | `annotations` | `pos → annotation` | -| **VR** | `value_refs_from` | `pos → PosSet` (source → targets) | -| **TR** | `type_refs_from` | `pos → PosSet` (source → targets) | -| **CFI** | `cross_file_items` | `path → CrossFileItems.t` | -| **DBP** | `decl_by_path` | `path → decl_info list` | -| **ATR** | `all_type_refs_from` | Combined type refs | -| **ER** | `exception_refs` | Exception references | -| **ED** | `exception_decls` | Exception declarations | -| **RR** | `resolved_refs_from` | Resolved exception refs | -| **DR** | `decl_refs` | `pos → (value_targets, type_targets)` | -| **roots** | Root declarations | `@live`/`@genType` or externally referenced | -| **edges** | Reference graph | Declaration → referenced declarations | -| **fixpoint** | `Reactive.fixpoint` | Transitive closure combinator | -| **LIVE** | Output | Set of live positions | +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ REACTIVE ANALYSIS PIPELINE │ +│ │ +│ ┌──────────┐ │ +│ │ .cmt │ │ +│ │ files │ │ +│ └────┬─────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────┐ │ +│ │ ReactiveFileCollection│ File change detection + caching │ +│ │ file_data │ │ +│ └────┬─────────────────┘ │ +│ │ flatMap │ +│ ▼ │ +│ ┌──────────────────────┐ │ +│ │ ReactiveMerge │ Derives collections from file_data │ +│ │ ┌──────┐ ┌────────┐ │ │ +│ │ │decls │ │ refs │ │ │ +│ │ └──┬───┘ └───┬────┘ │ │ +│ │ │ ┌──────┴─────┐ │ │ +│ │ │ │annotations │ │ │ +│ │ │ └──────┬─────┘ │ │ +│ └────┼─────────┼───────┘ │ +│ │ │ │ +│ │ ▼ │ +│ │ ┌─────────────────────┐ │ +│ │ │ ReactiveLiveness │ roots + edges → live (fixpoint) │ +│ │ │ ┌──────┐ ┌──────┐ │ │ +│ │ │ │roots │→│ live │ │ │ +│ │ │ └──────┘ └──┬───┘ │ │ +│ │ └──────────────┼──────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────────────────────┐ │ +│ │ ReactiveSolver │ Pure reactive joins (NO iteration) │ +│ │ │ │ +│ │ decls ──┬──► dead_decls ──┬──► issues │ +│ │ │ ↑ │ ↑ │ +│ │ live ───┘ (join, keep │ (join with annotations) │ +│ │ if NOT in live)│ │ +│ │ annotations ──────────────┘ │ +│ │ │ │ +│ │ (Optional args: TODO - not yet reactive) │ +│ └─────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────┐ │ +│ │ REPORT │ ONLY iteration: O(issues) │ +│ │ collect_issues → Log_.warning │ (linear in number of issues) │ +│ └─────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` ### Delta Propagation -> **Source**: [`diagrams/delta-propagation.mmd`](diagrams/delta-propagation.mmd) +When a file changes: + +1. `ReactiveFileCollection` detects change, emits delta for `file_data` +2. `ReactiveMerge` receives delta, updates `decls`, `refs`, `annotations` +3. `ReactiveLiveness` receives delta, updates `live` set via incremental fixpoint +4. `ReactiveSolver` receives delta, updates `dead_decls` and `issues` via reactive joins +5. **Only affected entries are recomputed** - untouched entries remain stable + +When no files change: +- **Zero computation** - all reactive collections are stable +- Only `collect_issues` iterates (O(issues)) - this is the ONLY iteration in the entire pipeline +- Reporting is linear in the number of issues -![Delta Propagation](diagrams/delta-propagation.svg) +### Performance Characteristics -### Key Benefits +| Scenario | Solving | Reporting | Total | +|----------|---------|-----------|-------| +| Cold start (4900 files) | ~2ms | ~3ms | ~7.7s | +| Cache hit (0 files changed) | ~1-5ms | ~3-8ms | ~30ms | +| Single file change | O(affected_decls) | O(issues) | minimal | -| Aspect | Batch Pipeline | Reactive Pipeline | -|--------|----------------|-------------------| -| File change | Re-process all files | Re-process changed file only | -| Merge | Re-merge all data | Update affected entries only | -| Type deps | Rebuild entire index | Update affected paths only | -| Exception refs | Re-resolve all | Re-resolve affected only | -| Memory | O(N) per phase | O(N) total, shared | +**Key insight**: On cache hit, `Solving` time is just iterating the reactive `issues` collection. +No joins are recomputed, no fixpoints are re-run - the reactive collections are stable. ### Reactive Modules @@ -211,10 +281,11 @@ The reactive layer (`analysis/reactive/`) provides delta-based incremental updat | `ReactiveFileCollection` | File-backed collection with change detection | | `ReactiveAnalysis` | CMT processing with file caching | | `ReactiveMerge` | Derives decls, annotations, refs from file_data | -| `ReactiveTypeDeps` | Type-label dependency resolution, produces `all_type_refs_from` | +| `ReactiveTypeDeps` | Type-label dependency resolution | | `ReactiveExceptionRefs` | Exception ref resolution via join | | `ReactiveDeclRefs` | Maps declarations to their outgoing references | | `ReactiveLiveness` | Computes live positions via reactive fixpoint | +| `ReactiveSolver` | Computes dead_decls and issues via reactive joins | --- @@ -246,4 +317,5 @@ The reactive layer (`analysis/reactive/`) provides delta-based incremental updat | `AnalysisResult` | Immutable solver output | | `Issue` | Issue type definitions | | `Log_` | Phase 4: Logging output | +| `ReactiveSolver` | Reactive dead_decls → issues computation | diff --git a/analysis/reanalyze/diagrams/reactive-pipeline.mmd b/analysis/reanalyze/diagrams/reactive-pipeline.mmd index 4646c40fc3..c8411ef5b4 100644 --- a/analysis/reanalyze/diagrams/reactive-pipeline.mmd +++ b/analysis/reanalyze/diagrams/reactive-pipeline.mmd @@ -38,6 +38,15 @@ flowchart TB LIVE["LIVE"] end + subgraph Solver["ReactiveSolver"] + DEAD["dead"] + ISSUES["issues"] + end + + subgraph Report["Report"] + OUTPUT[("REPORT")] + end + RFC -->|"process"| FD FD -->|"flatMap"| DECLS FD -->|"flatMap"| ANNOT @@ -67,12 +76,20 @@ flowchart TB EDGES --> FP FP -->|"fixpoint"| LIVE + DECLS -->|"join"| DEAD + LIVE -->|"left-join, filter None"| DEAD + DEAD -->|"join"| ISSUES + ANNOT -->|"join"| ISSUES + + ISSUES -->|"iter"| OUTPUT + classDef fileLayer fill:#e8f4fd,stroke:#4a90d9,stroke-width:2px classDef extracted fill:#f0f7e6,stroke:#6b8e23,stroke-width:2px classDef typeDeps fill:#fff5e6,stroke:#d4a574,stroke-width:2px classDef excDeps fill:#f5e6ff,stroke:#9966cc,stroke-width:2px classDef declRefs fill:#e6f0ff,stroke:#4a74d9,stroke-width:2px classDef liveness fill:#ffe6e6,stroke:#cc6666,stroke-width:2px + classDef solver fill:#ffe6f0,stroke:#cc6699,stroke-width:2px classDef output fill:#e6ffe6,stroke:#2e8b2e,stroke-width:2px class RFC,FD fileLayer @@ -80,5 +97,6 @@ flowchart TB class DBP,ATR typeDeps class EXCREF,EXCDECL,RESOLVED excDeps class DR declRefs - class ROOTS,EDGES,FP liveness - class LIVE output + class ROOTS,EDGES,FP,LIVE liveness + class DEAD,ISSUES solver + class OUTPUT output diff --git a/analysis/reanalyze/diagrams/reactive-pipeline.svg b/analysis/reanalyze/diagrams/reactive-pipeline.svg index c93c9e0d61..1830fbe698 100644 --- a/analysis/reanalyze/diagrams/reactive-pipeline.svg +++ b/analysis/reanalyze/diagrams/reactive-pipeline.svg @@ -1 +1 @@ -

ReactiveLiveness

ReactiveDeclRefs

ReactiveExceptionRefs

ReactiveTypeDeps

Extracted (ReactiveMerge)

Per-File Data

File Layer

process

flatMap

flatMap

flatMap

flatMap

flatMap

flatMap

union+flatMap

flatMap

flatMap

join

join

flatMap

fixpoint

RFC

FD

D

A

VR

TR

CFI

DBP

ATR

ER

ED

RR

DR

roots

edges

fixpoint

LIVE

\ No newline at end of file +

Report

ReactiveSolver

ReactiveLiveness

ReactiveDeclRefs

ReactiveExceptionRefs

ReactiveTypeDeps

Extracted (ReactiveMerge)

Per-File Data

File Layer

process

flatMap

flatMap

flatMap

flatMap

flatMap

flatMap

union+flatMap

flatMap

flatMap

join

join

flatMap

fixpoint

join

left-join, filter None

join

join

iter

RFC

FD

D

A

VR

TR

CFI

DBP

ATR

ER

ED

RR

DR

roots

edges

fixpoint

LIVE

dead

issues

REPORT

\ No newline at end of file diff --git a/analysis/reanalyze/src/DeadCommon.ml b/analysis/reanalyze/src/DeadCommon.ml index 4d401e94c7..561105e342 100644 --- a/analysis/reanalyze/src/DeadCommon.ml +++ b/analysis/reanalyze/src/DeadCommon.ml @@ -337,6 +337,7 @@ let solveDeadReactive ~ann_store ~config ~decl_store ~value_refs_from config:DceConfig.t -> Decl.t -> Issue.t list) : AnalysisResult.t = + let t0 = Unix.gettimeofday () in let debug = config.DceConfig.cli.debug in let transitive = config.DceConfig.run.transitive in let is_live pos = Reactive.get live pos <> None in @@ -354,15 +355,25 @@ let solveDeadReactive ~ann_store ~config ~decl_store ~value_refs_from let deadDeclarations = ref [] in let inline_issues = ref [] in + let t1 = Unix.gettimeofday () in (* For consistent debug output, collect and sort declarations *) let all_decls = DeclarationStore.fold (fun _pos decl acc -> decl :: acc) decl_store [] - |> List.fast_sort Decl.compareForReporting in + let t2 = Unix.gettimeofday () in + let all_decls = all_decls |> List.fast_sort Decl.compareForReporting in + let t3 = Unix.gettimeofday () in + let num_decls = List.length all_decls in + + (* Count operations in the loop *) + let num_live_checks = ref 0 in + let num_dead = ref 0 in + let num_live = ref 0 in all_decls |> List.iter (fun (decl : Decl.t) -> let pos = decl.pos in + incr num_live_checks; let is_live = is_live pos in let is_dead = not is_live in @@ -389,6 +400,7 @@ let solveDeadReactive ~ann_store ~config ~decl_store ~value_refs_from decl.resolvedDead <- Some is_dead; if is_dead then ( + incr num_dead; decl.path |> DeadModules.markDead ~config ~isType:(decl.declKind |> Decl.Kind.isType) @@ -396,6 +408,7 @@ let solveDeadReactive ~ann_store ~config ~decl_store ~value_refs_from if not (doReportDead ~ann_store decl.pos) then decl.report <- false; deadDeclarations := decl :: !deadDeclarations) else ( + incr num_live; (* Collect optional args issues for live declarations *) checkOptionalArgFn ~optional_args_state ~ann_store ~config decl |> List.iter (fun issue -> inline_issues := issue :: !inline_issues); @@ -415,10 +428,12 @@ let solveDeadReactive ~ann_store ~config ~decl_store ~value_refs_from |> Option.iter (fun mod_issue -> inline_issues := mod_issue :: !inline_issues); inline_issues := issue :: !inline_issues))); + let t4 = Unix.gettimeofday () in let sortedDeadDeclarations = !deadDeclarations |> List.fast_sort Decl.compareForReporting in + let t5 = Unix.gettimeofday () in (* Collect issues from dead declarations *) let reporting_ctx = ReportingContext.create () in @@ -427,7 +442,32 @@ let solveDeadReactive ~ann_store ~config ~decl_store ~value_refs_from |> List.concat_map (fun decl -> reportDeclaration ~config ~hasRefBelow reporting_ctx decl) in + let t6 = Unix.gettimeofday () in let all_issues = List.rev !inline_issues @ dead_issues in + let t7 = Unix.gettimeofday () in + + Printf.eprintf + " solveDeadReactive timing breakdown:\n\ + \ setup: %6.2fms\n\ + \ collect: %6.2fms (DeclarationStore.fold)\n\ + \ sort: %6.2fms (List.fast_sort %d decls)\n\ + \ iterate: %6.2fms (check liveness for %d decls: %d dead, %d live)\n\ + \ sort_dead: %6.2fms (sort %d dead decls)\n\ + \ report: %6.2fms (generate issues)\n\ + \ combine: %6.2fms\n\ + \ TOTAL: %6.2fms\n" + ((t1 -. t0) *. 1000.0) + ((t2 -. t1) *. 1000.0) + ((t3 -. t2) *. 1000.0) + num_decls + ((t4 -. t3) *. 1000.0) + !num_live_checks !num_dead !num_live + ((t5 -. t4) *. 1000.0) + !num_dead + ((t6 -. t5) *. 1000.0) + ((t7 -. t6) *. 1000.0) + ((t7 -. t0) *. 1000.0); + AnalysisResult.add_issues AnalysisResult.empty all_issues (** Main entry point - uses forward solver. *) diff --git a/analysis/reanalyze/src/ReactiveSolver.ml b/analysis/reanalyze/src/ReactiveSolver.ml new file mode 100644 index 0000000000..1bff1b31cf --- /dev/null +++ b/analysis/reanalyze/src/ReactiveSolver.ml @@ -0,0 +1,137 @@ +(** Reactive dead code solver. + + Reactive pipeline: decls + live → dead_decls, live_decls + + Current status: + - dead_decls, live_decls are reactive (zero recomputation on cache hit) + - collect_issues iterates ALL dead_decls + live_decls every call + (linear in their total size, not in changes) + - Uses DeadCommon.reportDeclaration for isInsideReportedValue and hasRefBelow + + TODO for fully reactive issues: + - isInsideReportedValue: needs reactive tracking of reported positions + (currently relies on sequential iteration order via ReportingContext) + - hasRefBelow: uses O(total_refs) linear scan of refs_from per dead decl; + could use reactive refs_to index for O(1) lookup per decl + - Module marking: needs reactive module dead/live tracking + (currently uses mutable DeadModules.markDead/markLive) + + Missing issues in reactive mode (18 total on deadcode test): + - Optional args: 18 issues missing (6 Redundant Optional Argument + 12 Unused Argument) + Needs reactive cross_file_items + liveness filtering + + Correct in reactive mode: + - Dead code issues: all match (362 issues) + - Incorrect @dead detection: matches (1 issue) *) + +type t = { + dead_decls: (Lexing.position, Decl.t) Reactive.t; + live_decls: (Lexing.position, Decl.t) Reactive.t; + annotations: (Lexing.position, FileAnnotations.annotated_as) Reactive.t; + value_refs_from: (Lexing.position, PosSet.t) Reactive.t option; +} + +let create ~(decls : (Lexing.position, Decl.t) Reactive.t) + ~(live : (Lexing.position, unit) Reactive.t) + ~(annotations : (Lexing.position, FileAnnotations.annotated_as) Reactive.t) + ~(value_refs_from : (Lexing.position, PosSet.t) Reactive.t option) + ~(config : DceConfig.t) : t = + ignore config; + + (* dead_decls = decls where NOT in live (reactive join) *) + let dead_decls = + Reactive.join decls live + ~key_of:(fun pos _decl -> pos) + ~f:(fun pos decl live_opt -> + match live_opt with + | None -> [(pos, decl)] + | Some () -> []) + () + in + + (* live_decls = decls where in live (reactive join) *) + let live_decls = + Reactive.join decls live + ~key_of:(fun pos _decl -> pos) + ~f:(fun pos decl live_opt -> + match live_opt with + | Some () -> [(pos, decl)] + | None -> []) + () + in + + {dead_decls; live_decls; annotations; value_refs_from} + +(** Collect issues from dead and live declarations. + Uses DeadCommon.reportDeclaration for correct filtering. + O(dead_decls + live_decls), not O(all_decls). *) +let collect_issues ~(t : t) ~(config : DceConfig.t) ~(ann_store : AnnotationStore.t) + : Issue.t list = + (* Mark dead declarations and collect them *) + let dead_list = ref [] in + Reactive.iter + (fun _pos (decl : Decl.t) -> + decl.resolvedDead <- Some true; + decl.path + |> DeadModules.markDead ~config + ~isType:(decl.declKind |> Decl.Kind.isType) + ~loc:decl.moduleLoc; + (* Check annotation to decide if we report. + Don't report if @live, @genType, or @dead (user knows it's dead) *) + let should_report = + match Reactive.get t.annotations decl.pos with + | Some FileAnnotations.Live -> false + | Some FileAnnotations.GenType -> false + | Some FileAnnotations.Dead -> false (* @dead = user knows, don't warn *) + | None -> true + in + if not should_report then decl.report <- false; + dead_list := decl :: !dead_list) + t.dead_decls; + + (* Mark live declarations *) + let incorrect_dead_issues = ref [] in + Reactive.iter + (fun _pos (decl : Decl.t) -> + decl.resolvedDead <- Some false; + decl.path + |> DeadModules.markLive ~config + ~isType:(decl.declKind |> Decl.Kind.isType) + ~loc:decl.moduleLoc; + (* Check for incorrect @dead annotation on live decl *) + if AnnotationStore.is_annotated_dead ann_store decl.pos then + let issue = + DeadCommon.makeDeadIssue ~decl + ~message:" is annotated @dead but is live" + Issue.IncorrectDeadAnnotation + in + decl.path + |> DcePath.toModuleName ~isType:(decl.declKind |> Decl.Kind.isType) + |> DeadModules.checkModuleDead ~config ~fileName:decl.pos.pos_fname + |> Option.iter (fun mod_issue -> + incorrect_dead_issues := mod_issue :: !incorrect_dead_issues); + incorrect_dead_issues := issue :: !incorrect_dead_issues) + t.live_decls; + + (* Sort and generate issues using DeadCommon.reportDeclaration *) + let sorted_dead = !dead_list |> List.fast_sort Decl.compareForReporting in + let transitive = config.DceConfig.run.transitive in + let hasRefBelow = + match t.value_refs_from with + | None -> fun _ -> false + | Some refs_from -> + DeadCommon.make_hasRefBelow ~transitive ~iter_value_refs_from:(fun f -> + Reactive.iter f refs_from) + in + let reporting_ctx = DeadCommon.ReportingContext.create () in + let dead_issues = + sorted_dead + |> List.concat_map (fun decl -> + DeadCommon.reportDeclaration ~config ~hasRefBelow reporting_ctx decl) + in + + List.rev !incorrect_dead_issues @ dead_issues + +(** Stats *) +let stats ~(t : t) : int * int = + (Reactive.length t.dead_decls, Reactive.length t.live_decls) diff --git a/analysis/reanalyze/src/ReactiveSolver.mli b/analysis/reanalyze/src/ReactiveSolver.mli new file mode 100644 index 0000000000..e7a3d1f094 --- /dev/null +++ b/analysis/reanalyze/src/ReactiveSolver.mli @@ -0,0 +1,26 @@ +(** Reactive dead code solver. + + Reactive pipeline: decls + live → dead_decls, live_decls + Issue generation uses DeadCommon.reportDeclaration for correct filtering. + + O(dead_decls + live_decls), not O(all_decls). *) + +type t + +val create : + decls:(Lexing.position, Decl.t) Reactive.t -> + live:(Lexing.position, unit) Reactive.t -> + annotations:(Lexing.position, FileAnnotations.annotated_as) Reactive.t -> + value_refs_from:(Lexing.position, PosSet.t) Reactive.t option -> + config:DceConfig.t -> + t + +val collect_issues : + t:t -> + config:DceConfig.t -> + ann_store:AnnotationStore.t -> + Issue.t list +(** Collect issues. O(dead_decls + live_decls). *) + +val stats : t:t -> int * int +(** (dead, live) counts *) diff --git a/analysis/reanalyze/src/Reanalyze.ml b/analysis/reanalyze/src/Reanalyze.ml index a2762ed381..10b2116df2 100644 --- a/analysis/reanalyze/src/Reanalyze.ml +++ b/analysis/reanalyze/src/Reanalyze.ml @@ -245,7 +245,7 @@ let shuffle_list lst = Array.to_list arr let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge - ~reactive_liveness = + ~reactive_liveness:_ ~reactive_solver = (* Map: process each file -> list of file_data *) let {dce_data_list; exception_results} = processCmtFiles ~config:dce_config ~cmtRoot ~reactive_collection @@ -365,57 +365,62 @@ let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge in (* Solving phase: run the solver and collect issues *) Timing.time_phase `Solving (fun () -> - let empty_optional_args_state = OptionalArgsState.create () in - let analysis_result_core = - match (reactive_merge, reactive_liveness) with - | Some merged, Some liveness_result -> - let live = liveness_result.ReactiveLiveness.live in - let roots = liveness_result.ReactiveLiveness.roots in - (* Pass value_refs_from for on-demand hasRefBelow (no freezing) *) - let value_refs_from = - if dce_config.DceConfig.run.transitive then None - else Some merged.ReactiveMerge.value_refs_from - in - DeadCommon.solveDeadReactive ~ann_store ~decl_store - ~value_refs_from ~live ~roots - ~optional_args_state:empty_optional_args_state - ~config:dce_config - ~checkOptionalArg:(fun - ~optional_args_state:_ ~ann_store:_ ~config:_ _ -> []) - | _ -> + match reactive_solver with + | Some solver -> + (* Reactive solver: iterate dead_decls + live_decls *) + let t0 = Unix.gettimeofday () in + let issues = + ReactiveSolver.collect_issues ~t:solver ~config:dce_config + ~ann_store + in + let t1 = Unix.gettimeofday () in + let num_dead, num_live = ReactiveSolver.stats ~t:solver in + if !Cli.timing then + Printf.eprintf + " ReactiveSolver: collect=%.3fms (dead=%d, live=%d, \ + issues=%d)\n" + ((t1 -. t0) *. 1000.0) + num_dead num_live (List.length issues); + (* TODO: add optional args to reactive pipeline *) + Some (AnalysisResult.add_issues AnalysisResult.empty issues) + | None -> + (* Non-reactive path: use old solver with optional args *) + let empty_optional_args_state = OptionalArgsState.create () in + let analysis_result_core = DeadCommon.solveDead ~ann_store ~decl_store ~ref_store ~optional_args_state:empty_optional_args_state ~config:dce_config ~checkOptionalArg:(fun ~optional_args_state:_ ~ann_store:_ ~config:_ _ -> []) - in - (* Compute liveness-aware optional args state *) - let is_live pos = - match DeclarationStore.find_opt decl_store pos with - | Some decl -> Decl.isLive decl - | None -> true - in - let optional_args_state = - CrossFileItemsStore.compute_optional_args_state cross_file_store - ~find_decl:(DeclarationStore.find_opt decl_store) - ~is_live - in - (* Collect optional args issues only for live declarations *) - let optional_args_issues = - DeclarationStore.fold - (fun _pos decl acc -> - if Decl.isLive decl then - let issues = - DeadOptionalArgs.check ~optional_args_state ~ann_store - ~config:dce_config decl - in - List.rev_append issues acc - else acc) - decl_store [] - |> List.rev - in - Some - (AnalysisResult.add_issues analysis_result_core optional_args_issues)) + in + (* Compute liveness-aware optional args state *) + let is_live pos = + match DeclarationStore.find_opt decl_store pos with + | Some decl -> Decl.isLive decl + | None -> true + in + let optional_args_state = + CrossFileItemsStore.compute_optional_args_state cross_file_store + ~find_decl:(DeclarationStore.find_opt decl_store) + ~is_live + in + (* Collect optional args issues only for live declarations *) + let optional_args_issues = + DeclarationStore.fold + (fun _pos decl acc -> + if Decl.isLive decl then + let issues = + DeadOptionalArgs.check ~optional_args_state ~ann_store + ~config:dce_config decl + in + List.rev_append issues acc + else acc) + decl_store [] + |> List.rev + in + Some + (AnalysisResult.add_issues analysis_result_core + optional_args_issues)) else None in (* Reporting phase *) @@ -460,6 +465,24 @@ let runAnalysisAndReport ~cmtRoot = | Some merged -> Some (ReactiveLiveness.create ~merged) | None -> None in + (* Create reactive solver once - sets up the reactive pipeline: + decls + live → dead_decls → issues + All downstream collections update automatically when inputs change. *) + let reactive_solver = + match (reactive_merge, reactive_liveness) with + | Some merged, Some liveness_result -> + (* Pass value_refs_from for hasRefBelow (needed when transitive=false) *) + let value_refs_from = + if dce_config.DceConfig.run.transitive then None + else Some merged.ReactiveMerge.value_refs_from + in + Some + (ReactiveSolver.create ~decls:merged.ReactiveMerge.decls + ~live:liveness_result.ReactiveLiveness.live + ~annotations:merged.ReactiveMerge.annotations ~value_refs_from + ~config:dce_config) + | _ -> None + in for run = 1 to numRuns do Timing.reset (); (* Clear stats at start of each run to avoid accumulation *) @@ -467,7 +490,7 @@ let runAnalysisAndReport ~cmtRoot = if numRuns > 1 && !Cli.timing then Printf.eprintf "\n=== Run %d/%d ===\n%!" run numRuns; runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge - ~reactive_liveness; + ~reactive_liveness ~reactive_solver; if run = numRuns then ( (* Only report on last run *) Log_.Stats.report ~config:dce_config; diff --git a/analysis/src/DceCommand.ml b/analysis/src/DceCommand.ml index eb2e1f98c8..5e0420c3c3 100644 --- a/analysis/src/DceCommand.ml +++ b/analysis/src/DceCommand.ml @@ -2,6 +2,6 @@ let command () = Reanalyze.RunConfig.dce (); let dce_config = Reanalyze.DceConfig.current () in Reanalyze.runAnalysis ~dce_config ~cmtRoot:None ~reactive_collection:None - ~reactive_merge:None ~reactive_liveness:None; + ~reactive_merge:None ~reactive_liveness:None ~reactive_solver:None; let issues = !Reanalyze.Log_.Stats.issues in Printf.printf "issues:%d\n" (List.length issues) diff --git a/tests/analysis_tests/tests-reanalyze/deadcode-benchmark/Makefile b/tests/analysis_tests/tests-reanalyze/deadcode-benchmark/Makefile index 33bc025c4e..da6be9b027 100644 --- a/tests/analysis_tests/tests-reanalyze/deadcode-benchmark/Makefile +++ b/tests/analysis_tests/tests-reanalyze/deadcode-benchmark/Makefile @@ -60,7 +60,7 @@ time-reactive: generate build @dune exec rescript-editor-analysis -- reanalyze -config -ci -timing 2>&1 | grep -E "=== Timing|CMT processing|File loading|Total:" @echo "" @echo "Reactive mode (3 runs - first is cold, subsequent are warm):" - @dune exec rescript-editor-analysis -- reanalyze -config -ci -timing -reactive -runs 3 2>/dev/null + @dune exec rescript-editor-analysis -- reanalyze -config -ci -timing -reactive -runs 3 >/dev/null .DEFAULT_GOAL := benchmark From 595e8ef95a749a995cdbb95385d9162575531dea Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 20:28:58 +0100 Subject: [PATCH 18/45] Add optional args to reactive pipeline - Add iter_live_decls to ReactiveSolver for iterating live declarations - Compute optional_args_state using reactive cross_file_items - Use Decl.isLive for liveness check (matches non-reactive behavior) - All 380 issues now match between reactive and non-reactive modes Signed-Off-By: Cristiano Calcagno --- analysis/reanalyze/src/ReactiveSolver.ml | 15 ++++---- analysis/reanalyze/src/ReactiveSolver.mli | 3 ++ analysis/reanalyze/src/Reanalyze.ml | 44 ++++++++++++++++++++--- 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/analysis/reanalyze/src/ReactiveSolver.ml b/analysis/reanalyze/src/ReactiveSolver.ml index 1bff1b31cf..499289d924 100644 --- a/analysis/reanalyze/src/ReactiveSolver.ml +++ b/analysis/reanalyze/src/ReactiveSolver.ml @@ -16,13 +16,10 @@ - Module marking: needs reactive module dead/live tracking (currently uses mutable DeadModules.markDead/markLive) - Missing issues in reactive mode (18 total on deadcode test): - - Optional args: 18 issues missing (6 Redundant Optional Argument + 12 Unused Argument) - Needs reactive cross_file_items + liveness filtering - - Correct in reactive mode: - - Dead code issues: all match (362 issues) - - Incorrect @dead detection: matches (1 issue) *) + All issues now match between reactive and non-reactive modes (380 on deadcode test): + - Dead code issues: 362 (Exception:2, Module:31, Type:87, Value:233, ValueWithSideEffects:8) + - Incorrect @dead: 1 + - Optional args: 18 (Redundant:6, Unused:12) *) type t = { dead_decls: (Lexing.position, Decl.t) Reactive.t; @@ -132,6 +129,10 @@ let collect_issues ~(t : t) ~(config : DceConfig.t) ~(ann_store : AnnotationStor List.rev !incorrect_dead_issues @ dead_issues +(** Iterate over live declarations *) +let iter_live_decls ~(t : t) (f : Decl.t -> unit) : unit = + Reactive.iter (fun _pos decl -> f decl) t.live_decls + (** Stats *) let stats ~(t : t) : int * int = (Reactive.length t.dead_decls, Reactive.length t.live_decls) diff --git a/analysis/reanalyze/src/ReactiveSolver.mli b/analysis/reanalyze/src/ReactiveSolver.mli index e7a3d1f094..b2fdc035e1 100644 --- a/analysis/reanalyze/src/ReactiveSolver.mli +++ b/analysis/reanalyze/src/ReactiveSolver.mli @@ -22,5 +22,8 @@ val collect_issues : Issue.t list (** Collect issues. O(dead_decls + live_decls). *) +val iter_live_decls : t:t -> (Decl.t -> unit) -> unit +(** Iterate over live declarations *) + val stats : t:t -> int * int (** (dead, live) counts *) diff --git a/analysis/reanalyze/src/Reanalyze.ml b/analysis/reanalyze/src/Reanalyze.ml index 10b2116df2..c9779beff0 100644 --- a/analysis/reanalyze/src/Reanalyze.ml +++ b/analysis/reanalyze/src/Reanalyze.ml @@ -369,20 +369,54 @@ let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge | Some solver -> (* Reactive solver: iterate dead_decls + live_decls *) let t0 = Unix.gettimeofday () in - let issues = + let dead_code_issues = ReactiveSolver.collect_issues ~t:solver ~config:dce_config ~ann_store in let t1 = Unix.gettimeofday () in + (* Collect optional args issues from live declarations *) + let optional_args_issues = + match reactive_merge with + | Some merged -> + (* Create CrossFileItemsStore from reactive collection *) + let cross_file_store = + CrossFileItemsStore.of_reactive merged.ReactiveMerge.cross_file_items + in + (* Compute optional args state using declaration liveness + Note: is_live returns true if pos is not a declaration (matches non-reactive) + Uses Decl.isLive which checks resolvedDead set by collect_issues *) + let is_live pos = + match Reactive.get merged.ReactiveMerge.decls pos with + | None -> true (* not a declaration, assume live *) + | Some decl -> Decl.isLive decl + in + let find_decl pos = Reactive.get merged.ReactiveMerge.decls pos in + let optional_args_state = + CrossFileItemsStore.compute_optional_args_state cross_file_store + ~find_decl ~is_live + in + (* Iterate live declarations and check for optional args issues *) + let issues = ref [] in + ReactiveSolver.iter_live_decls ~t:solver (fun decl -> + let decl_issues = + DeadOptionalArgs.check ~optional_args_state ~ann_store + ~config:dce_config decl + in + issues := List.rev_append decl_issues !issues); + List.rev !issues + | None -> [] + in + let t2 = Unix.gettimeofday () in + let all_issues = dead_code_issues @ optional_args_issues in let num_dead, num_live = ReactiveSolver.stats ~t:solver in if !Cli.timing then Printf.eprintf - " ReactiveSolver: collect=%.3fms (dead=%d, live=%d, \ + " ReactiveSolver: dead_code=%.3fms opt_args=%.3fms (dead=%d, live=%d, \ issues=%d)\n" ((t1 -. t0) *. 1000.0) - num_dead num_live (List.length issues); - (* TODO: add optional args to reactive pipeline *) - Some (AnalysisResult.add_issues AnalysisResult.empty issues) + ((t2 -. t1) *. 1000.0) + num_dead num_live (List.length all_issues); + Some (AnalysisResult.add_issues AnalysisResult.empty all_issues) | None -> (* Non-reactive path: use old solver with optional args *) let empty_optional_args_state = OptionalArgsState.create () in From 3b6f878b45679b02440f3110a9cec163712dfcf6 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 20:31:54 +0100 Subject: [PATCH 19/45] Format ReactiveSolver and Reanalyze Signed-Off-By: Cristiano Calcagno --- analysis/reanalyze/src/ReactiveSolver.ml | 11 ++++++----- analysis/reanalyze/src/ReactiveSolver.mli | 5 +---- analysis/reanalyze/src/Reanalyze.ml | 15 +++++++++------ 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/analysis/reanalyze/src/ReactiveSolver.ml b/analysis/reanalyze/src/ReactiveSolver.ml index 499289d924..61093c9ff4 100644 --- a/analysis/reanalyze/src/ReactiveSolver.ml +++ b/analysis/reanalyze/src/ReactiveSolver.ml @@ -62,8 +62,8 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) (** Collect issues from dead and live declarations. Uses DeadCommon.reportDeclaration for correct filtering. O(dead_decls + live_decls), not O(all_decls). *) -let collect_issues ~(t : t) ~(config : DceConfig.t) ~(ann_store : AnnotationStore.t) - : Issue.t list = +let collect_issues ~(t : t) ~(config : DceConfig.t) + ~(ann_store : AnnotationStore.t) : Issue.t list = (* Mark dead declarations and collect them *) let dead_list = ref [] in Reactive.iter @@ -79,7 +79,8 @@ let collect_issues ~(t : t) ~(config : DceConfig.t) ~(ann_store : AnnotationStor match Reactive.get t.annotations decl.pos with | Some FileAnnotations.Live -> false | Some FileAnnotations.GenType -> false - | Some FileAnnotations.Dead -> false (* @dead = user knows, don't warn *) + | Some FileAnnotations.Dead -> + false (* @dead = user knows, don't warn *) | None -> true in if not should_report then decl.report <- false; @@ -96,7 +97,7 @@ let collect_issues ~(t : t) ~(config : DceConfig.t) ~(ann_store : AnnotationStor ~isType:(decl.declKind |> Decl.Kind.isType) ~loc:decl.moduleLoc; (* Check for incorrect @dead annotation on live decl *) - if AnnotationStore.is_annotated_dead ann_store decl.pos then + if AnnotationStore.is_annotated_dead ann_store decl.pos then ( let issue = DeadCommon.makeDeadIssue ~decl ~message:" is annotated @dead but is live" @@ -107,7 +108,7 @@ let collect_issues ~(t : t) ~(config : DceConfig.t) ~(ann_store : AnnotationStor |> DeadModules.checkModuleDead ~config ~fileName:decl.pos.pos_fname |> Option.iter (fun mod_issue -> incorrect_dead_issues := mod_issue :: !incorrect_dead_issues); - incorrect_dead_issues := issue :: !incorrect_dead_issues) + incorrect_dead_issues := issue :: !incorrect_dead_issues)) t.live_decls; (* Sort and generate issues using DeadCommon.reportDeclaration *) diff --git a/analysis/reanalyze/src/ReactiveSolver.mli b/analysis/reanalyze/src/ReactiveSolver.mli index b2fdc035e1..0268992aca 100644 --- a/analysis/reanalyze/src/ReactiveSolver.mli +++ b/analysis/reanalyze/src/ReactiveSolver.mli @@ -16,10 +16,7 @@ val create : t val collect_issues : - t:t -> - config:DceConfig.t -> - ann_store:AnnotationStore.t -> - Issue.t list + t:t -> config:DceConfig.t -> ann_store:AnnotationStore.t -> Issue.t list (** Collect issues. O(dead_decls + live_decls). *) val iter_live_decls : t:t -> (Decl.t -> unit) -> unit diff --git a/analysis/reanalyze/src/Reanalyze.ml b/analysis/reanalyze/src/Reanalyze.ml index c9779beff0..1e6b241eb8 100644 --- a/analysis/reanalyze/src/Reanalyze.ml +++ b/analysis/reanalyze/src/Reanalyze.ml @@ -380,7 +380,8 @@ let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge | Some merged -> (* Create CrossFileItemsStore from reactive collection *) let cross_file_store = - CrossFileItemsStore.of_reactive merged.ReactiveMerge.cross_file_items + CrossFileItemsStore.of_reactive + merged.ReactiveMerge.cross_file_items in (* Compute optional args state using declaration liveness Note: is_live returns true if pos is not a declaration (matches non-reactive) @@ -390,10 +391,12 @@ let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge | None -> true (* not a declaration, assume live *) | Some decl -> Decl.isLive decl in - let find_decl pos = Reactive.get merged.ReactiveMerge.decls pos in + let find_decl pos = + Reactive.get merged.ReactiveMerge.decls pos + in let optional_args_state = - CrossFileItemsStore.compute_optional_args_state cross_file_store - ~find_decl ~is_live + CrossFileItemsStore.compute_optional_args_state + cross_file_store ~find_decl ~is_live in (* Iterate live declarations and check for optional args issues *) let issues = ref [] in @@ -411,8 +414,8 @@ let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge let num_dead, num_live = ReactiveSolver.stats ~t:solver in if !Cli.timing then Printf.eprintf - " ReactiveSolver: dead_code=%.3fms opt_args=%.3fms (dead=%d, live=%d, \ - issues=%d)\n" + " ReactiveSolver: dead_code=%.3fms opt_args=%.3fms (dead=%d, \ + live=%d, issues=%d)\n" ((t1 -. t0) *. 1000.0) ((t2 -. t1) *. 1000.0) num_dead num_live (List.length all_issues); From 96a122d24d94f4c9ce7cd46196ab28e2b1a90b67 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 20:44:51 +0100 Subject: [PATCH 20/45] Make dead_modules reactive in ReactiveSolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add reactive dead_modules collection (anti-join of modules_with_dead - modules_with_live) - Remove DeadModules.markDead/markLive calls in collect_issues - Add ?checkModuleDead callback parameter to DeadCommon.reportDeclaration - Use reactive dead_modules lookup instead of mutable DeadModules table Timing improvement on cache hits: - iter_dead: 9.94ms → 5.73ms (-4.2ms) - iter_live: 9.32ms → 5.69ms (-3.6ms) All 380 issues match between reactive and non-reactive modes. --- analysis/reanalyze/src/DeadCommon.ml | 15 ++- analysis/reanalyze/src/ReactiveSolver.ml | 120 ++++++++++++++++++----- 2 files changed, 105 insertions(+), 30 deletions(-) diff --git a/analysis/reanalyze/src/DeadCommon.ml b/analysis/reanalyze/src/DeadCommon.ml index 561105e342..97e2fad2f8 100644 --- a/analysis/reanalyze/src/DeadCommon.ml +++ b/analysis/reanalyze/src/DeadCommon.ml @@ -171,9 +171,10 @@ let make_hasRefBelow ~transitive ~iter_value_refs_from = (** Report a dead declaration. Returns list of issues (dead module first, then dead value). [hasRefBelow] checks if there are references from "below" the declaration. - Only used when [config.run.transitive] is false. *) -let reportDeclaration ~config ~hasRefBelow (ctx : ReportingContext.t) decl : - Issue.t list = + Only used when [config.run.transitive] is false. + [?checkModuleDead] optional callback for checking dead modules. Defaults to DeadModules.checkModuleDead. *) +let reportDeclaration ~config ~hasRefBelow ?checkModuleDead + (ctx : ReportingContext.t) decl : Issue.t list = let insideReportedValue = decl |> isInsideReportedValue ctx in if not decl.report then [] else @@ -214,10 +215,14 @@ let reportDeclaration ~config ~hasRefBelow (ctx : ReportingContext.t) decl : && (config.DceConfig.run.transitive || not (hasRefBelow decl)) in if shouldEmitWarning then - let dead_module_issue = + let moduleName = decl.path |> DcePath.toModuleName ~isType:(decl.declKind |> Decl.Kind.isType) - |> DeadModules.checkModuleDead ~config ~fileName:decl.pos.pos_fname + in + let dead_module_issue = + match checkModuleDead with + | Some f -> f ~fileName:decl.pos.pos_fname moduleName + | None -> DeadModules.checkModuleDead ~config ~fileName:decl.pos.pos_fname moduleName in let dead_value_issue = makeDeadIssue ~decl ~message deadWarning in (* Return in order: dead module first (if any), then dead value *) diff --git a/analysis/reanalyze/src/ReactiveSolver.ml b/analysis/reanalyze/src/ReactiveSolver.ml index 61093c9ff4..3316ebda85 100644 --- a/analysis/reanalyze/src/ReactiveSolver.ml +++ b/analysis/reanalyze/src/ReactiveSolver.ml @@ -1,11 +1,11 @@ (** Reactive dead code solver. - Reactive pipeline: decls + live → dead_decls, live_decls + Reactive pipeline: decls + live → dead_decls, live_decls, dead_modules Current status: - - dead_decls, live_decls are reactive (zero recomputation on cache hit) - - collect_issues iterates ALL dead_decls + live_decls every call - (linear in their total size, not in changes) + - dead_decls, live_decls, dead_modules are reactive (zero recomputation on cache hit) + - dead_modules = modules with dead decls but no live decls (reactive anti-join) + - collect_issues still iterates dead_decls + live_decls to set resolvedDead - Uses DeadCommon.reportDeclaration for isInsideReportedValue and hasRefBelow TODO for fully reactive issues: @@ -13,8 +13,7 @@ (currently relies on sequential iteration order via ReportingContext) - hasRefBelow: uses O(total_refs) linear scan of refs_from per dead decl; could use reactive refs_to index for O(1) lookup per decl - - Module marking: needs reactive module dead/live tracking - (currently uses mutable DeadModules.markDead/markLive) + - resolvedDead: still mutated on every call; could be computed on-demand All issues now match between reactive and non-reactive modes (380 on deadcode test): - Dead code issues: 362 (Exception:2, Module:31, Type:87, Value:233, ValueWithSideEffects:8) @@ -26,15 +25,19 @@ type t = { live_decls: (Lexing.position, Decl.t) Reactive.t; annotations: (Lexing.position, FileAnnotations.annotated_as) Reactive.t; value_refs_from: (Lexing.position, PosSet.t) Reactive.t option; + dead_modules: (Name.t, Location.t) Reactive.t; + (** Modules where all declarations are dead. Reactive anti-join. *) } +(** Extract module name from a declaration *) +let decl_module_name (decl : Decl.t) : Name.t = + decl.path |> DcePath.toModuleName ~isType:(decl.declKind |> Decl.Kind.isType) + let create ~(decls : (Lexing.position, Decl.t) Reactive.t) ~(live : (Lexing.position, unit) Reactive.t) ~(annotations : (Lexing.position, FileAnnotations.annotated_as) Reactive.t) ~(value_refs_from : (Lexing.position, PosSet.t) Reactive.t option) ~(config : DceConfig.t) : t = - ignore config; - (* dead_decls = decls where NOT in live (reactive join) *) let dead_decls = Reactive.join decls live @@ -57,22 +60,72 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) () in - {dead_decls; live_decls; annotations; value_refs_from} + (* Reactive dead modules: modules with dead decls but no live decls *) + let dead_modules = + if not config.DceConfig.run.transitive then + (* Dead modules only reported in transitive mode *) + Reactive.flatMap dead_decls ~f:(fun _ _ -> []) () + else + (* modules_with_dead: (moduleName, loc) for each module with dead decls *) + let modules_with_dead = + Reactive.flatMap dead_decls + ~f:(fun _pos decl -> [(decl_module_name decl, decl.moduleLoc)]) + ~merge:(fun loc1 _loc2 -> loc1) (* keep first location *) + () + in + (* modules_with_live: (moduleName, ()) for each module with live decls *) + let modules_with_live = + Reactive.flatMap live_decls + ~f:(fun _pos decl -> [(decl_module_name decl, ())]) + () + in + (* Anti-join: modules in dead but not in live *) + Reactive.join modules_with_dead modules_with_live + ~key_of:(fun modName _loc -> modName) + ~f:(fun modName loc live_opt -> + match live_opt with + | None -> [(modName, loc)] (* dead: no live decls *) + | Some () -> []) (* live: has at least one live decl *) + () + in + + {dead_decls; live_decls; annotations; value_refs_from; dead_modules} + +(** Check if a module is dead using reactive collection. Returns issue if dead. + Uses reported_modules set to avoid duplicate reports. *) +let check_module_dead ~(dead_modules : (Name.t, Location.t) Reactive.t) + ~(reported_modules : (Name.t, unit) Hashtbl.t) ~fileName:pos_fname moduleName + : Issue.t option = + if Hashtbl.mem reported_modules moduleName then None + else + match Reactive.get dead_modules moduleName with + | Some loc -> + Hashtbl.replace reported_modules moduleName (); + let loc = + if loc.Location.loc_ghost then + let pos = + {Lexing.pos_fname; pos_lnum = 0; pos_bol = 0; pos_cnum = 0} + in + {Location.loc_start = pos; loc_end = pos; loc_ghost = false} + else loc + in + Some (AnalysisResult.make_dead_module_issue ~loc ~moduleName) + | None -> None (** Collect issues from dead and live declarations. - Uses DeadCommon.reportDeclaration for correct filtering. + Uses reactive dead_modules instead of mutable DeadModules. O(dead_decls + live_decls), not O(all_decls). *) let collect_issues ~(t : t) ~(config : DceConfig.t) ~(ann_store : AnnotationStore.t) : Issue.t list = - (* Mark dead declarations and collect them *) + let t0 = Unix.gettimeofday () in + (* Track reported modules to avoid duplicates *) + let reported_modules = Hashtbl.create 64 in + + (* Mark dead declarations and collect them (no DeadModules.markDead) *) let dead_list = ref [] in Reactive.iter (fun _pos (decl : Decl.t) -> decl.resolvedDead <- Some true; - decl.path - |> DeadModules.markDead ~config - ~isType:(decl.declKind |> Decl.Kind.isType) - ~loc:decl.moduleLoc; (* Check annotation to decide if we report. Don't report if @live, @genType, or @dead (user knows it's dead) *) let should_report = @@ -86,16 +139,13 @@ let collect_issues ~(t : t) ~(config : DceConfig.t) if not should_report then decl.report <- false; dead_list := decl :: !dead_list) t.dead_decls; + let t1 = Unix.gettimeofday () in - (* Mark live declarations *) + (* Mark live declarations (no DeadModules.markLive) *) let incorrect_dead_issues = ref [] in Reactive.iter (fun _pos (decl : Decl.t) -> decl.resolvedDead <- Some false; - decl.path - |> DeadModules.markLive ~config - ~isType:(decl.declKind |> Decl.Kind.isType) - ~loc:decl.moduleLoc; (* Check for incorrect @dead annotation on live decl *) if AnnotationStore.is_annotated_dead ann_store decl.pos then ( let issue = @@ -103,16 +153,20 @@ let collect_issues ~(t : t) ~(config : DceConfig.t) ~message:" is annotated @dead but is live" Issue.IncorrectDeadAnnotation in - decl.path - |> DcePath.toModuleName ~isType:(decl.declKind |> Decl.Kind.isType) - |> DeadModules.checkModuleDead ~config ~fileName:decl.pos.pos_fname + (* Check if module is dead using reactive collection *) + check_module_dead ~dead_modules:t.dead_modules ~reported_modules + ~fileName:decl.pos.pos_fname (decl_module_name decl) |> Option.iter (fun mod_issue -> incorrect_dead_issues := mod_issue :: !incorrect_dead_issues); incorrect_dead_issues := issue :: !incorrect_dead_issues)) t.live_decls; + let t2 = Unix.gettimeofday () in - (* Sort and generate issues using DeadCommon.reportDeclaration *) + (* Sort dead declarations for isInsideReportedValue ordering *) let sorted_dead = !dead_list |> List.fast_sort Decl.compareForReporting in + let t3 = Unix.gettimeofday () in + + (* Generate issues - use reactive dead_modules via callback *) let transitive = config.DceConfig.run.transitive in let hasRefBelow = match t.value_refs_from with @@ -121,12 +175,28 @@ let collect_issues ~(t : t) ~(config : DceConfig.t) DeadCommon.make_hasRefBelow ~transitive ~iter_value_refs_from:(fun f -> Reactive.iter f refs_from) in + (* Callback for checking dead modules using reactive collection *) + let checkModuleDead ~fileName moduleName = + check_module_dead ~dead_modules:t.dead_modules ~reported_modules ~fileName + moduleName + in let reporting_ctx = DeadCommon.ReportingContext.create () in let dead_issues = sorted_dead |> List.concat_map (fun decl -> - DeadCommon.reportDeclaration ~config ~hasRefBelow reporting_ctx decl) + DeadCommon.reportDeclaration ~config ~hasRefBelow ~checkModuleDead + reporting_ctx decl) in + let t4 = Unix.gettimeofday () in + + if !Cli.timing then + Printf.eprintf + " collect_issues: iter_dead=%.2fms iter_live=%.2fms sort=%.2fms \ + report=%.2fms\n" + ((t1 -. t0) *. 1000.0) + ((t2 -. t1) *. 1000.0) + ((t3 -. t2) *. 1000.0) + ((t4 -. t3) *. 1000.0); List.rev !incorrect_dead_issues @ dead_issues From 97cf8aeceabb4cb0943f9b64d3f9dfe2a2b04892 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 20:51:55 +0100 Subject: [PATCH 21/45] Remove resolvedDead mutation - use reactive liveness check - Add is_pos_live function to ReactiveSolver that uses reactive live collection - Store decls and live in ReactiveSolver.t for direct reactive lookup - Update Reanalyze.ml to use is_pos_live instead of Decl.isLive - Remove resolvedDead mutations from collect_issues Optional args now use reactive liveness check instead of mutable field. All 380 issues still match between reactive and non-reactive modes. --- analysis/reanalyze/src/ReactiveSolver.ml | 23 ++++++++++++++++------- analysis/reanalyze/src/ReactiveSolver.mli | 4 ++++ analysis/reanalyze/src/Reanalyze.ml | 12 ++++-------- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/analysis/reanalyze/src/ReactiveSolver.ml b/analysis/reanalyze/src/ReactiveSolver.ml index 3316ebda85..8554e40120 100644 --- a/analysis/reanalyze/src/ReactiveSolver.ml +++ b/analysis/reanalyze/src/ReactiveSolver.ml @@ -5,7 +5,8 @@ Current status: - dead_decls, live_decls, dead_modules are reactive (zero recomputation on cache hit) - dead_modules = modules with dead decls but no live decls (reactive anti-join) - - collect_issues still iterates dead_decls + live_decls to set resolvedDead + - is_pos_live uses reactive live collection (no resolvedDead mutation needed) + - collect_issues still iterates dead_decls + live_decls for annotations + sorting - Uses DeadCommon.reportDeclaration for isInsideReportedValue and hasRefBelow TODO for fully reactive issues: @@ -13,7 +14,8 @@ (currently relies on sequential iteration order via ReportingContext) - hasRefBelow: uses O(total_refs) linear scan of refs_from per dead decl; could use reactive refs_to index for O(1) lookup per decl - - resolvedDead: still mutated on every call; could be computed on-demand + - report field: still mutated to suppress annotated decls; could check in reportDeclaration + - Sorting: O(n log n) for isInsideReportedValue ordering; fundamentally sequential All issues now match between reactive and non-reactive modes (380 on deadcode test): - Dead code issues: 362 (Exception:2, Module:31, Type:87, Value:233, ValueWithSideEffects:8) @@ -21,6 +23,8 @@ - Optional args: 18 (Redundant:6, Unused:12) *) type t = { + decls: (Lexing.position, Decl.t) Reactive.t; + live: (Lexing.position, unit) Reactive.t; dead_decls: (Lexing.position, Decl.t) Reactive.t; live_decls: (Lexing.position, Decl.t) Reactive.t; annotations: (Lexing.position, FileAnnotations.annotated_as) Reactive.t; @@ -89,7 +93,7 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) () in - {dead_decls; live_decls; annotations; value_refs_from; dead_modules} + {decls; live; dead_decls; live_decls; annotations; value_refs_from; dead_modules} (** Check if a module is dead using reactive collection. Returns issue if dead. Uses reported_modules set to avoid duplicate reports. *) @@ -121,11 +125,10 @@ let collect_issues ~(t : t) ~(config : DceConfig.t) (* Track reported modules to avoid duplicates *) let reported_modules = Hashtbl.create 64 in - (* Mark dead declarations and collect them (no DeadModules.markDead) *) + (* Collect dead declarations - NO resolvedDead mutation *) let dead_list = ref [] in Reactive.iter (fun _pos (decl : Decl.t) -> - decl.resolvedDead <- Some true; (* Check annotation to decide if we report. Don't report if @live, @genType, or @dead (user knows it's dead) *) let should_report = @@ -141,11 +144,10 @@ let collect_issues ~(t : t) ~(config : DceConfig.t) t.dead_decls; let t1 = Unix.gettimeofday () in - (* Mark live declarations (no DeadModules.markLive) *) + (* Check live declarations for incorrect @dead - NO resolvedDead mutation *) let incorrect_dead_issues = ref [] in Reactive.iter (fun _pos (decl : Decl.t) -> - decl.resolvedDead <- Some false; (* Check for incorrect @dead annotation on live decl *) if AnnotationStore.is_annotated_dead ann_store decl.pos then ( let issue = @@ -204,6 +206,13 @@ let collect_issues ~(t : t) ~(config : DceConfig.t) let iter_live_decls ~(t : t) (f : Decl.t -> unit) : unit = Reactive.iter (fun _pos decl -> f decl) t.live_decls +(** Check if a position is live using the reactive collection. + Returns true if pos is not a declaration (matches non-reactive behavior). *) +let is_pos_live ~(t : t) (pos : Lexing.position) : bool = + match Reactive.get t.decls pos with + | None -> true (* not a declaration, assume live *) + | Some _ -> Reactive.get t.live pos <> None + (** Stats *) let stats ~(t : t) : int * int = (Reactive.length t.dead_decls, Reactive.length t.live_decls) diff --git a/analysis/reanalyze/src/ReactiveSolver.mli b/analysis/reanalyze/src/ReactiveSolver.mli index 0268992aca..9ae080bab8 100644 --- a/analysis/reanalyze/src/ReactiveSolver.mli +++ b/analysis/reanalyze/src/ReactiveSolver.mli @@ -22,5 +22,9 @@ val collect_issues : val iter_live_decls : t:t -> (Decl.t -> unit) -> unit (** Iterate over live declarations *) +val is_pos_live : t:t -> Lexing.position -> bool +(** Check if a position is live using the reactive collection. + Returns true if pos is not a declaration (matches non-reactive behavior). *) + val stats : t:t -> int * int (** (dead, live) counts *) diff --git a/analysis/reanalyze/src/Reanalyze.ml b/analysis/reanalyze/src/Reanalyze.ml index 1e6b241eb8..0b72b03d50 100644 --- a/analysis/reanalyze/src/Reanalyze.ml +++ b/analysis/reanalyze/src/Reanalyze.ml @@ -383,14 +383,10 @@ let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge CrossFileItemsStore.of_reactive merged.ReactiveMerge.cross_file_items in - (* Compute optional args state using declaration liveness - Note: is_live returns true if pos is not a declaration (matches non-reactive) - Uses Decl.isLive which checks resolvedDead set by collect_issues *) - let is_live pos = - match Reactive.get merged.ReactiveMerge.decls pos with - | None -> true (* not a declaration, assume live *) - | Some decl -> Decl.isLive decl - in + (* Compute optional args state using reactive liveness check. + Uses ReactiveSolver.is_pos_live which checks the reactive live collection + instead of mutable resolvedDead field. *) + let is_live pos = ReactiveSolver.is_pos_live ~t:solver pos in let find_decl pos = Reactive.get merged.ReactiveMerge.decls pos in From 05c83f950b4b7c002b20b1e1703689f7f468d3fd Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 20:56:55 +0100 Subject: [PATCH 22/45] Use per-file issue generation for isInsideReportedValue isInsideReportedValue only checks within same file, so files are independent. Now group dead declarations by file and process each file with: - Sort within file only (not global sort) - Fresh ReportingContext per file Timing improvement: - group: 4-5ms (was sort: 7-8ms for global sort) - report: 10-11ms (files processed independently) All 380/19000 issues still match between reactive and non-reactive modes. --- analysis/reanalyze/src/ReactiveSolver.ml | 48 ++++++++++++++++-------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/analysis/reanalyze/src/ReactiveSolver.ml b/analysis/reanalyze/src/ReactiveSolver.ml index 8554e40120..1a467e0cca 100644 --- a/analysis/reanalyze/src/ReactiveSolver.ml +++ b/analysis/reanalyze/src/ReactiveSolver.ml @@ -6,16 +6,15 @@ - dead_decls, live_decls, dead_modules are reactive (zero recomputation on cache hit) - dead_modules = modules with dead decls but no live decls (reactive anti-join) - is_pos_live uses reactive live collection (no resolvedDead mutation needed) - - collect_issues still iterates dead_decls + live_decls for annotations + sorting - - Uses DeadCommon.reportDeclaration for isInsideReportedValue and hasRefBelow + - Per-file issue generation: group by file, sort within file, fresh ReportingContext per file + - isInsideReportedValue is per-file only, so files are independent TODO for fully reactive issues: - - isInsideReportedValue: needs reactive tracking of reported positions - (currently relies on sequential iteration order via ReportingContext) + - Make dead_decls_by_file a reactive collection (per-file grouping) + - Make per-file issues reactive (only recompute changed files) - hasRefBelow: uses O(total_refs) linear scan of refs_from per dead decl; could use reactive refs_to index for O(1) lookup per decl - report field: still mutated to suppress annotated decls; could check in reportDeclaration - - Sorting: O(n log n) for isInsideReportedValue ordering; fundamentally sequential All issues now match between reactive and non-reactive modes (380 on deadcode test): - Dead code issues: 362 (Exception:2, Module:31, Type:87, Value:233, ValueWithSideEffects:8) @@ -164,11 +163,18 @@ let collect_issues ~(t : t) ~(config : DceConfig.t) t.live_decls; let t2 = Unix.gettimeofday () in - (* Sort dead declarations for isInsideReportedValue ordering *) - let sorted_dead = !dead_list |> List.fast_sort Decl.compareForReporting in + (* Group dead declarations by file *) + let by_file : (string, Decl.t list) Hashtbl.t = Hashtbl.create 64 in + List.iter + (fun (decl : Decl.t) -> + let file = decl.pos.pos_fname in + let existing = Hashtbl.find_opt by_file file |> Option.value ~default:[] in + Hashtbl.replace by_file file (decl :: existing)) + !dead_list; let t3 = Unix.gettimeofday () in - (* Generate issues - use reactive dead_modules via callback *) + (* Generate issues per-file with independent ReportingContext. + isInsideReportedValue only checks within same file, so files are independent. *) let transitive = config.DceConfig.run.transitive in let hasRefBelow = match t.value_refs_from with @@ -182,23 +188,33 @@ let collect_issues ~(t : t) ~(config : DceConfig.t) check_module_dead ~dead_modules:t.dead_modules ~reported_modules ~fileName moduleName in - let reporting_ctx = DeadCommon.ReportingContext.create () in let dead_issues = - sorted_dead - |> List.concat_map (fun decl -> - DeadCommon.reportDeclaration ~config ~hasRefBelow ~checkModuleDead - reporting_ctx decl) + Hashtbl.fold + (fun _file decls acc -> + (* Sort within file for isInsideReportedValue *) + let sorted = decls |> List.fast_sort Decl.compareForReporting in + (* Fresh ReportingContext per file *) + let reporting_ctx = DeadCommon.ReportingContext.create () in + let file_issues = + sorted + |> List.concat_map (fun decl -> + DeadCommon.reportDeclaration ~config ~hasRefBelow ~checkModuleDead + reporting_ctx decl) + in + file_issues @ acc) + by_file [] in let t4 = Unix.gettimeofday () in if !Cli.timing then Printf.eprintf - " collect_issues: iter_dead=%.2fms iter_live=%.2fms sort=%.2fms \ - report=%.2fms\n" + " collect_issues: iter_dead=%.2fms iter_live=%.2fms group=%.2fms \ + report=%.2fms (%d files)\n" ((t1 -. t0) *. 1000.0) ((t2 -. t1) *. 1000.0) ((t3 -. t2) *. 1000.0) - ((t4 -. t3) *. 1000.0); + ((t4 -. t3) *. 1000.0) + (Hashtbl.length by_file); List.rev !incorrect_dead_issues @ dead_issues From 5ef231e35e9e3d98d3f48b530fe8615f6fb32762 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 21:02:23 +0100 Subject: [PATCH 23/45] Make dead_decls_by_file reactive and remove report mutation - Add reactive dead_decls_by_file collection (flatMap with merge) - Add shouldReport callback to reportDeclaration (replaces report field mutation) - Issue generation now uses reactive per-file grouping - No more decl.report mutation in reactive path All 380/19000 issues still match between reactive and non-reactive modes. --- analysis/reanalyze/src/DeadCommon.ml | 12 ++- analysis/reanalyze/src/ReactiveSolver.ml | 95 +++++++++++------------- 2 files changed, 52 insertions(+), 55 deletions(-) diff --git a/analysis/reanalyze/src/DeadCommon.ml b/analysis/reanalyze/src/DeadCommon.ml index 97e2fad2f8..41a79db639 100644 --- a/analysis/reanalyze/src/DeadCommon.ml +++ b/analysis/reanalyze/src/DeadCommon.ml @@ -172,11 +172,17 @@ let make_hasRefBelow ~transitive ~iter_value_refs_from = (** Report a dead declaration. Returns list of issues (dead module first, then dead value). [hasRefBelow] checks if there are references from "below" the declaration. Only used when [config.run.transitive] is false. - [?checkModuleDead] optional callback for checking dead modules. Defaults to DeadModules.checkModuleDead. *) -let reportDeclaration ~config ~hasRefBelow ?checkModuleDead + [?checkModuleDead] optional callback for checking dead modules. Defaults to DeadModules.checkModuleDead. + [?shouldReport] optional callback to check if a decl should be reported. Defaults to checking decl.report. *) +let reportDeclaration ~config ~hasRefBelow ?checkModuleDead ?shouldReport (ctx : ReportingContext.t) decl : Issue.t list = let insideReportedValue = decl |> isInsideReportedValue ctx in - if not decl.report then [] + let should_report = + match shouldReport with + | Some f -> f decl + | None -> decl.report + in + if not should_report then [] else let deadWarning, message = match decl.declKind with diff --git a/analysis/reanalyze/src/ReactiveSolver.ml b/analysis/reanalyze/src/ReactiveSolver.ml index 1a467e0cca..b8bebcdc97 100644 --- a/analysis/reanalyze/src/ReactiveSolver.ml +++ b/analysis/reanalyze/src/ReactiveSolver.ml @@ -1,20 +1,21 @@ (** Reactive dead code solver. - Reactive pipeline: decls + live → dead_decls, live_decls, dead_modules + Reactive pipeline: decls + live → dead_decls, live_decls, dead_modules, dead_decls_by_file Current status: - - dead_decls, live_decls, dead_modules are reactive (zero recomputation on cache hit) + - dead_decls, live_decls, dead_modules, dead_decls_by_file are all reactive - dead_modules = modules with dead decls but no live decls (reactive anti-join) - - is_pos_live uses reactive live collection (no resolvedDead mutation needed) - - Per-file issue generation: group by file, sort within file, fresh ReportingContext per file + - dead_decls_by_file = dead decls grouped by file (reactive flatMap with merge) + - is_pos_live uses reactive live collection (no resolvedDead mutation) + - shouldReport callback replaces report field mutation (no mutation needed) + - Per-file issue generation using reactive dead_decls_by_file - isInsideReportedValue is per-file only, so files are independent TODO for fully reactive issues: - - Make dead_decls_by_file a reactive collection (per-file grouping) - - Make per-file issues reactive (only recompute changed files) + - Make per-file issues a reactive collection (issues_by_file) + Currently we iterate dead_decls_by_file to generate issues - hasRefBelow: uses O(total_refs) linear scan of refs_from per dead decl; could use reactive refs_to index for O(1) lookup per decl - - report field: still mutated to suppress annotated decls; could check in reportDeclaration All issues now match between reactive and non-reactive modes (380 on deadcode test): - Dead code issues: 362 (Exception:2, Module:31, Type:87, Value:233, ValueWithSideEffects:8) @@ -30,6 +31,8 @@ type t = { value_refs_from: (Lexing.position, PosSet.t) Reactive.t option; dead_modules: (Name.t, Location.t) Reactive.t; (** Modules where all declarations are dead. Reactive anti-join. *) + dead_decls_by_file: (string, Decl.t list) Reactive.t; + (** Dead declarations grouped by file. Reactive per-file grouping. *) } (** Extract module name from a declaration *) @@ -92,7 +95,15 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) () in - {decls; live; dead_decls; live_decls; annotations; value_refs_from; dead_modules} + (* Reactive per-file grouping of dead declarations *) + let dead_decls_by_file = + Reactive.flatMap dead_decls + ~f:(fun _pos decl -> [(decl.pos.Lexing.pos_fname, [decl])]) + ~merge:(fun decls1 decls2 -> decls1 @ decls2) + () + in + + {decls; live; dead_decls; live_decls; annotations; value_refs_from; dead_modules; dead_decls_by_file} (** Check if a module is dead using reactive collection. Returns issue if dead. Uses reported_modules set to avoid duplicate reports. *) @@ -124,26 +135,7 @@ let collect_issues ~(t : t) ~(config : DceConfig.t) (* Track reported modules to avoid duplicates *) let reported_modules = Hashtbl.create 64 in - (* Collect dead declarations - NO resolvedDead mutation *) - let dead_list = ref [] in - Reactive.iter - (fun _pos (decl : Decl.t) -> - (* Check annotation to decide if we report. - Don't report if @live, @genType, or @dead (user knows it's dead) *) - let should_report = - match Reactive.get t.annotations decl.pos with - | Some FileAnnotations.Live -> false - | Some FileAnnotations.GenType -> false - | Some FileAnnotations.Dead -> - false (* @dead = user knows, don't warn *) - | None -> true - in - if not should_report then decl.report <- false; - dead_list := decl :: !dead_list) - t.dead_decls; - let t1 = Unix.gettimeofday () in - - (* Check live declarations for incorrect @dead - NO resolvedDead mutation *) + (* Check live declarations for incorrect @dead *) let incorrect_dead_issues = ref [] in Reactive.iter (fun _pos (decl : Decl.t) -> @@ -161,19 +153,9 @@ let collect_issues ~(t : t) ~(config : DceConfig.t) incorrect_dead_issues := mod_issue :: !incorrect_dead_issues); incorrect_dead_issues := issue :: !incorrect_dead_issues)) t.live_decls; - let t2 = Unix.gettimeofday () in + let t1 = Unix.gettimeofday () in - (* Group dead declarations by file *) - let by_file : (string, Decl.t list) Hashtbl.t = Hashtbl.create 64 in - List.iter - (fun (decl : Decl.t) -> - let file = decl.pos.pos_fname in - let existing = Hashtbl.find_opt by_file file |> Option.value ~default:[] in - Hashtbl.replace by_file file (decl :: existing)) - !dead_list; - let t3 = Unix.gettimeofday () in - - (* Generate issues per-file with independent ReportingContext. + (* Generate issues per-file using reactive dead_decls_by_file. isInsideReportedValue only checks within same file, so files are independent. *) let transitive = config.DceConfig.run.transitive in let hasRefBelow = @@ -188,10 +170,21 @@ let collect_issues ~(t : t) ~(config : DceConfig.t) check_module_dead ~dead_modules:t.dead_modules ~reported_modules ~fileName moduleName in + (* Callback to check if we should report a dead decl (no mutation) *) + let shouldReport (decl : Decl.t) = + match Reactive.get t.annotations decl.pos with + | Some FileAnnotations.Live -> false + | Some FileAnnotations.GenType -> false + | Some FileAnnotations.Dead -> false (* @dead = user knows, don't warn *) + | None -> true + in + let num_files = ref 0 in let dead_issues = - Hashtbl.fold - (fun _file decls acc -> - (* Sort within file for isInsideReportedValue *) + let issues = ref [] in + Reactive.iter + (fun _file decls -> + incr num_files; + (* Sort within file for isInsideReportedValue - pass ALL decls for correct context *) let sorted = decls |> List.fast_sort Decl.compareForReporting in (* Fresh ReportingContext per file *) let reporting_ctx = DeadCommon.ReportingContext.create () in @@ -199,22 +192,20 @@ let collect_issues ~(t : t) ~(config : DceConfig.t) sorted |> List.concat_map (fun decl -> DeadCommon.reportDeclaration ~config ~hasRefBelow ~checkModuleDead - reporting_ctx decl) + ~shouldReport reporting_ctx decl) in - file_issues @ acc) - by_file [] + issues := file_issues @ !issues) + t.dead_decls_by_file; + !issues in - let t4 = Unix.gettimeofday () in + let t2 = Unix.gettimeofday () in if !Cli.timing then Printf.eprintf - " collect_issues: iter_dead=%.2fms iter_live=%.2fms group=%.2fms \ - report=%.2fms (%d files)\n" + " collect_issues: iter_live=%.2fms per_file=%.2fms (%d files)\n" ((t1 -. t0) *. 1000.0) ((t2 -. t1) *. 1000.0) - ((t3 -. t2) *. 1000.0) - ((t4 -. t3) *. 1000.0) - (Hashtbl.length by_file); + !num_files; List.rev !incorrect_dead_issues @ dead_issues From c25272cfc18e985d03ca20e85c28850e37f77cbe Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 21:18:41 +0100 Subject: [PATCH 24/45] Make issues_by_file reactive - issues_by_file is now a reactive flatMap from dead_decls_by_file - Per-file issue generation with shouldReport callback (no mutations) - Module issues generated separately from dead_modules + modules_with_reported_values - Correctly handles flatMap not tracking reads from other collections Timing improvement on benchmark (cache hit): - iter_issues: ~1.5ms (down from ~32ms) - iter_modules: ~5-7ms (new, iterates dead_modules) - Total dead_code: ~12-14ms (down from ~38-40ms) All 380/19000 issues still match between reactive and non-reactive modes. --- analysis/reanalyze/src/ReactiveSolver.ml | 156 +++++++++++++++-------- 1 file changed, 101 insertions(+), 55 deletions(-) diff --git a/analysis/reanalyze/src/ReactiveSolver.ml b/analysis/reanalyze/src/ReactiveSolver.ml index b8bebcdc97..36dc45f4fb 100644 --- a/analysis/reanalyze/src/ReactiveSolver.ml +++ b/analysis/reanalyze/src/ReactiveSolver.ml @@ -1,19 +1,19 @@ (** Reactive dead code solver. - Reactive pipeline: decls + live → dead_decls, live_decls, dead_modules, dead_decls_by_file + Reactive pipeline: decls + live → dead_decls, live_decls, dead_modules, dead_decls_by_file, issues_by_file Current status: - - dead_decls, live_decls, dead_modules, dead_decls_by_file are all reactive + - All collections are reactive (zero recomputation on cache hit for unchanged files) + - dead_decls, live_decls = decls partitioned by liveness (reactive join) - dead_modules = modules with dead decls but no live decls (reactive anti-join) - dead_decls_by_file = dead decls grouped by file (reactive flatMap with merge) + - issues_by_file = per-file issue generation (reactive flatMap) - is_pos_live uses reactive live collection (no resolvedDead mutation) - shouldReport callback replaces report field mutation (no mutation needed) - - Per-file issue generation using reactive dead_decls_by_file - isInsideReportedValue is per-file only, so files are independent + - Module issues generated in collect_issues from dead_modules + modules_with_reported_values - TODO for fully reactive issues: - - Make per-file issues a reactive collection (issues_by_file) - Currently we iterate dead_decls_by_file to generate issues + TODO for further optimization: - hasRefBelow: uses O(total_refs) linear scan of refs_from per dead decl; could use reactive refs_to index for O(1) lookup per decl @@ -33,6 +33,11 @@ type t = { (** Modules where all declarations are dead. Reactive anti-join. *) dead_decls_by_file: (string, Decl.t list) Reactive.t; (** Dead declarations grouped by file. Reactive per-file grouping. *) + issues_by_file: (string, Issue.t list * Name.t list) Reactive.t; + (** Dead code issues grouped by file. Reactive per-file issue generation. + First component: value/type/exception issues. + Second component: modules with at least one reported value (for module issue generation). *) + config: DceConfig.t; } (** Extract module name from a declaration *) @@ -103,7 +108,53 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) () in - {decls; live; dead_decls; live_decls; annotations; value_refs_from; dead_modules; dead_decls_by_file} + (* hasRefBelow callback - captured once, uses current state of value_refs_from *) + let transitive = config.DceConfig.run.transitive in + let hasRefBelow = + match value_refs_from with + | None -> fun _ -> false + | Some refs_from -> + DeadCommon.make_hasRefBelow ~transitive ~iter_value_refs_from:(fun f -> + Reactive.iter f refs_from) + in + + (* Reactive per-file issues - recomputed when dead_decls_by_file changes. + Returns (file, (value_issues, modules_with_reported_values)) where + modules_with_reported_values are modules that have at least one reported dead value. + Module issues are generated separately in collect_issues using dead_modules. *) + let issues_by_file = + Reactive.flatMap dead_decls_by_file + ~f:(fun file decls -> + (* Track modules that have reported values *) + let modules_with_values : (Name.t, unit) Hashtbl.t = Hashtbl.create 8 in + (* shouldReport checks annotations reactively *) + let shouldReport (decl : Decl.t) = + match Reactive.get annotations decl.pos with + | Some FileAnnotations.Live -> false + | Some FileAnnotations.GenType -> false + | Some FileAnnotations.Dead -> false + | None -> true + in + (* Don't emit module issues here - track modules for later *) + let checkModuleDead ~fileName:_ moduleName = + Hashtbl.replace modules_with_values moduleName (); + None (* Module issues generated separately *) + in + (* Sort within file and generate issues *) + let sorted = decls |> List.fast_sort Decl.compareForReporting in + let reporting_ctx = DeadCommon.ReportingContext.create () in + let file_issues = + sorted + |> List.concat_map (fun decl -> + DeadCommon.reportDeclaration ~config ~hasRefBelow ~checkModuleDead + ~shouldReport reporting_ctx decl) + in + let modules_list = Hashtbl.fold (fun m () acc -> m :: acc) modules_with_values [] in + [(file, (file_issues, modules_list))]) + () + in + + {decls; live; dead_decls; live_decls; annotations; value_refs_from; dead_modules; dead_decls_by_file; issues_by_file; config} (** Check if a module is dead using reactive collection. Returns issue if dead. Uses reported_modules set to avoid duplicate reports. *) @@ -126,13 +177,14 @@ let check_module_dead ~(dead_modules : (Name.t, Location.t) Reactive.t) Some (AnalysisResult.make_dead_module_issue ~loc ~moduleName) | None -> None -(** Collect issues from dead and live declarations. - Uses reactive dead_modules instead of mutable DeadModules. - O(dead_decls + live_decls), not O(all_decls). *) +(** Collect issues from reactive issues_by_file. + Only iterates the pre-computed reactive issues collection. + Deduplicates module issues across files. *) let collect_issues ~(t : t) ~(config : DceConfig.t) ~(ann_store : AnnotationStore.t) : Issue.t list = + ignore config; (* config is stored in t *) let t0 = Unix.gettimeofday () in - (* Track reported modules to avoid duplicates *) + (* Track reported modules to avoid duplicates across files *) let reported_modules = Hashtbl.create 64 in (* Check live declarations for incorrect @dead *) @@ -155,59 +207,53 @@ let collect_issues ~(t : t) ~(config : DceConfig.t) t.live_decls; let t1 = Unix.gettimeofday () in - (* Generate issues per-file using reactive dead_decls_by_file. - isInsideReportedValue only checks within same file, so files are independent. *) - let transitive = config.DceConfig.run.transitive in - let hasRefBelow = - match t.value_refs_from with - | None -> fun _ -> false - | Some refs_from -> - DeadCommon.make_hasRefBelow ~transitive ~iter_value_refs_from:(fun f -> - Reactive.iter f refs_from) - in - (* Callback for checking dead modules using reactive collection *) - let checkModuleDead ~fileName moduleName = - check_module_dead ~dead_modules:t.dead_modules ~reported_modules ~fileName - moduleName - in - (* Callback to check if we should report a dead decl (no mutation) *) - let shouldReport (decl : Decl.t) = - match Reactive.get t.annotations decl.pos with - | Some FileAnnotations.Live -> false - | Some FileAnnotations.GenType -> false - | Some FileAnnotations.Dead -> false (* @dead = user knows, don't warn *) - | None -> true - in + (* Collect issues from reactive issues_by_file *) let num_files = ref 0 in - let dead_issues = - let issues = ref [] in - Reactive.iter - (fun _file decls -> - incr num_files; - (* Sort within file for isInsideReportedValue - pass ALL decls for correct context *) - let sorted = decls |> List.fast_sort Decl.compareForReporting in - (* Fresh ReportingContext per file *) - let reporting_ctx = DeadCommon.ReportingContext.create () in - let file_issues = - sorted - |> List.concat_map (fun decl -> - DeadCommon.reportDeclaration ~config ~hasRefBelow ~checkModuleDead - ~shouldReport reporting_ctx decl) - in - issues := file_issues @ !issues) - t.dead_decls_by_file; - !issues - in + let dead_issues = ref [] in + (* Track modules that have at least one reported value (for module issue generation) *) + let modules_with_reported_values : (Name.t, unit) Hashtbl.t = Hashtbl.create 64 in + Reactive.iter + (fun _file (file_issues, modules_list) -> + incr num_files; + dead_issues := file_issues @ !dead_issues; + (* Collect modules that have reported values *) + List.iter + (fun moduleName -> Hashtbl.replace modules_with_reported_values moduleName ()) + modules_list) + t.issues_by_file; let t2 = Unix.gettimeofday () in + (* Generate module issues: only for modules that are dead AND have a reported value *) + let module_issues = ref [] in + let reported_modules : (Name.t, unit) Hashtbl.t = Hashtbl.create 64 in + Reactive.iter + (fun moduleName loc -> + (* Only report if module has at least one reported dead value *) + if Hashtbl.mem modules_with_reported_values moduleName then + if not (Hashtbl.mem reported_modules moduleName) then ( + Hashtbl.replace reported_modules moduleName (); + let loc = + if loc.Location.loc_ghost then + let pos_fname = loc.loc_start.pos_fname in + let pos = + {Lexing.pos_fname; pos_lnum = 0; pos_bol = 0; pos_cnum = 0} + in + {Location.loc_start = pos; loc_end = pos; loc_ghost = false} + else loc + in + module_issues := AnalysisResult.make_dead_module_issue ~loc ~moduleName :: !module_issues)) + t.dead_modules; + let t3 = Unix.gettimeofday () in + if !Cli.timing then Printf.eprintf - " collect_issues: iter_live=%.2fms per_file=%.2fms (%d files)\n" + " collect_issues: iter_live=%.2fms iter_issues=%.2fms iter_modules=%.2fms (%d files)\n" ((t1 -. t0) *. 1000.0) ((t2 -. t1) *. 1000.0) + ((t3 -. t2) *. 1000.0) !num_files; - List.rev !incorrect_dead_issues @ dead_issues + List.rev !incorrect_dead_issues @ !module_issues @ !dead_issues (** Iterate over live declarations *) let iter_live_decls ~(t : t) (f : Decl.t -> unit) : unit = From 46012efd7418bfeb6a9580c04a501c449aa0580e Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 21:24:17 +0100 Subject: [PATCH 25/45] Optimize hasRefBelow to use per-file refs hasRefBelow only checks refs from the same file as the declaration. Now we group refs by source file (value_refs_from_by_file) and only scan refs from the declaration's file. Complexity per dead decl: O(file_refs) instead of O(total_refs) All 380/19000 issues still match between reactive and non-reactive modes. --- analysis/reanalyze/src/ReactiveSolver.ml | 41 ++++++++++++++++++------ 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/analysis/reanalyze/src/ReactiveSolver.ml b/analysis/reanalyze/src/ReactiveSolver.ml index 36dc45f4fb..730ac15bdf 100644 --- a/analysis/reanalyze/src/ReactiveSolver.ml +++ b/analysis/reanalyze/src/ReactiveSolver.ml @@ -7,16 +7,14 @@ - dead_decls, live_decls = decls partitioned by liveness (reactive join) - dead_modules = modules with dead decls but no live decls (reactive anti-join) - dead_decls_by_file = dead decls grouped by file (reactive flatMap with merge) + - value_refs_from_by_file = refs grouped by source file (reactive flatMap with merge) - issues_by_file = per-file issue generation (reactive flatMap) - is_pos_live uses reactive live collection (no resolvedDead mutation) - shouldReport callback replaces report field mutation (no mutation needed) - isInsideReportedValue is per-file only, so files are independent + - hasRefBelow uses per-file refs: O(file_refs) per dead decl (was O(total_refs)) - Module issues generated in collect_issues from dead_modules + modules_with_reported_values - TODO for further optimization: - - hasRefBelow: uses O(total_refs) linear scan of refs_from per dead decl; - could use reactive refs_to index for O(1) lookup per decl - All issues now match between reactive and non-reactive modes (380 on deadcode test): - Dead code issues: 362 (Exception:2, Module:31, Type:87, Value:233, ValueWithSideEffects:8) - Incorrect @dead: 1 @@ -108,20 +106,26 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) () in - (* hasRefBelow callback - captured once, uses current state of value_refs_from *) + (* Reactive per-file grouping of value refs (for hasRefBelow optimization) *) let transitive = config.DceConfig.run.transitive in - let hasRefBelow = + let value_refs_from_by_file = match value_refs_from with - | None -> fun _ -> false + | None -> None | Some refs_from -> - DeadCommon.make_hasRefBelow ~transitive ~iter_value_refs_from:(fun f -> - Reactive.iter f refs_from) + Some ( + Reactive.flatMap refs_from + ~f:(fun posFrom posToSet -> [(posFrom.Lexing.pos_fname, [(posFrom, posToSet)])]) + ~merge:(fun refs1 refs2 -> refs1 @ refs2) + () + ) in (* Reactive per-file issues - recomputed when dead_decls_by_file changes. Returns (file, (value_issues, modules_with_reported_values)) where modules_with_reported_values are modules that have at least one reported dead value. - Module issues are generated separately in collect_issues using dead_modules. *) + Module issues are generated separately in collect_issues using dead_modules. + + hasRefBelow now uses per-file refs: O(file_refs) instead of O(total_refs). *) let issues_by_file = Reactive.flatMap dead_decls_by_file ~f:(fun file decls -> @@ -140,6 +144,23 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) Hashtbl.replace modules_with_values moduleName (); None (* Module issues generated separately *) in + (* Per-file hasRefBelow: only scan refs from this file *) + let hasRefBelow = + if transitive then fun _ -> false + else + match value_refs_from_by_file with + | None -> fun _ -> false + | Some refs_by_file -> + let file_refs = Reactive.get refs_by_file file in + fun decl -> + match file_refs with + | None -> false + | Some refs_list -> + List.exists (fun (posFrom, posToSet) -> + PosSet.mem decl.Decl.pos posToSet && + DeadCommon.refIsBelow decl posFrom + ) refs_list + in (* Sort within file and generate issues *) let sorted = decls |> List.fast_sort Decl.compareForReporting in let reporting_ctx = DeadCommon.ReportingContext.create () in From fbc5bbc23a5416de2614c9d97e406f508c9c6cb1 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 21:31:32 +0100 Subject: [PATCH 26/45] Make incorrect @dead detection reactive Add incorrect_dead_decls = reactive join of live_decls + annotations where annotation = Dead. Now we only iterate the few incorrect decls instead of scanning all ~17,500 live declarations. Timing improvement (benchmark): - incorrect_dead: 0.03ms (was ~5ms when scanning all live_decls) - dead_code total: ~7-10ms (was ~10-11ms) All 380/19000 issues still match between reactive and non-reactive modes. --- analysis/reanalyze/src/DeadCommon.ml | 4 +- analysis/reanalyze/src/ReactiveSolver.ml | 111 +++++++++++++++-------- 2 files changed, 76 insertions(+), 39 deletions(-) diff --git a/analysis/reanalyze/src/DeadCommon.ml b/analysis/reanalyze/src/DeadCommon.ml index 41a79db639..6260f98469 100644 --- a/analysis/reanalyze/src/DeadCommon.ml +++ b/analysis/reanalyze/src/DeadCommon.ml @@ -228,7 +228,9 @@ let reportDeclaration ~config ~hasRefBelow ?checkModuleDead ?shouldReport let dead_module_issue = match checkModuleDead with | Some f -> f ~fileName:decl.pos.pos_fname moduleName - | None -> DeadModules.checkModuleDead ~config ~fileName:decl.pos.pos_fname moduleName + | None -> + DeadModules.checkModuleDead ~config ~fileName:decl.pos.pos_fname + moduleName in let dead_value_issue = makeDeadIssue ~decl ~message deadWarning in (* Return in order: dead module first (if any), then dead value *) diff --git a/analysis/reanalyze/src/ReactiveSolver.ml b/analysis/reanalyze/src/ReactiveSolver.ml index 730ac15bdf..4e2ae1d400 100644 --- a/analysis/reanalyze/src/ReactiveSolver.ml +++ b/analysis/reanalyze/src/ReactiveSolver.ml @@ -1,6 +1,7 @@ (** Reactive dead code solver. - Reactive pipeline: decls + live → dead_decls, live_decls, dead_modules, dead_decls_by_file, issues_by_file + Reactive pipeline: decls + live + annotations → dead_decls, live_decls, dead_modules, + dead_decls_by_file, issues_by_file, incorrect_dead_decls Current status: - All collections are reactive (zero recomputation on cache hit for unchanged files) @@ -9,6 +10,7 @@ - dead_decls_by_file = dead decls grouped by file (reactive flatMap with merge) - value_refs_from_by_file = refs grouped by source file (reactive flatMap with merge) - issues_by_file = per-file issue generation (reactive flatMap) + - incorrect_dead_decls = live decls with @dead annotation (reactive join) - is_pos_live uses reactive live collection (no resolvedDead mutation) - shouldReport callback replaces report field mutation (no mutation needed) - isInsideReportedValue is per-file only, so files are independent @@ -35,6 +37,8 @@ type t = { (** Dead code issues grouped by file. Reactive per-file issue generation. First component: value/type/exception issues. Second component: modules with at least one reported value (for module issue generation). *) + incorrect_dead_decls: (Lexing.position, Decl.t) Reactive.t; + (** Live declarations with @dead annotation. Reactive join of live_decls + annotations. *) config: DceConfig.t; } @@ -112,12 +116,12 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) match value_refs_from with | None -> None | Some refs_from -> - Some ( - Reactive.flatMap refs_from - ~f:(fun posFrom posToSet -> [(posFrom.Lexing.pos_fname, [(posFrom, posToSet)])]) - ~merge:(fun refs1 refs2 -> refs1 @ refs2) - () - ) + Some + (Reactive.flatMap refs_from + ~f:(fun posFrom posToSet -> + [(posFrom.Lexing.pos_fname, [(posFrom, posToSet)])]) + ~merge:(fun refs1 refs2 -> refs1 @ refs2) + ()) in (* Reactive per-file issues - recomputed when dead_decls_by_file changes. @@ -150,16 +154,17 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) else match value_refs_from_by_file with | None -> fun _ -> false - | Some refs_by_file -> + | Some refs_by_file -> ( let file_refs = Reactive.get refs_by_file file in fun decl -> match file_refs with | None -> false | Some refs_list -> - List.exists (fun (posFrom, posToSet) -> - PosSet.mem decl.Decl.pos posToSet && - DeadCommon.refIsBelow decl posFrom - ) refs_list + List.exists + (fun (posFrom, posToSet) -> + PosSet.mem decl.Decl.pos posToSet + && DeadCommon.refIsBelow decl posFrom) + refs_list) in (* Sort within file and generate issues *) let sorted = decls |> List.fast_sort Decl.compareForReporting in @@ -167,21 +172,46 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) let file_issues = sorted |> List.concat_map (fun decl -> - DeadCommon.reportDeclaration ~config ~hasRefBelow ~checkModuleDead - ~shouldReport reporting_ctx decl) + DeadCommon.reportDeclaration ~config ~hasRefBelow + ~checkModuleDead ~shouldReport reporting_ctx decl) + in + let modules_list = + Hashtbl.fold (fun m () acc -> m :: acc) modules_with_values [] in - let modules_list = Hashtbl.fold (fun m () acc -> m :: acc) modules_with_values [] in [(file, (file_issues, modules_list))]) () in - {decls; live; dead_decls; live_decls; annotations; value_refs_from; dead_modules; dead_decls_by_file; issues_by_file; config} + (* Reactive incorrect @dead: live decls with @dead annotation *) + let incorrect_dead_decls = + Reactive.join live_decls annotations + ~key_of:(fun pos _decl -> pos) + ~f:(fun pos decl ann_opt -> + match ann_opt with + | Some FileAnnotations.Dead -> [(pos, decl)] + | _ -> []) + () + in + + { + decls; + live; + dead_decls; + live_decls; + annotations; + value_refs_from; + dead_modules; + dead_decls_by_file; + issues_by_file; + incorrect_dead_decls; + config; + } (** Check if a module is dead using reactive collection. Returns issue if dead. Uses reported_modules set to avoid duplicate reports. *) let check_module_dead ~(dead_modules : (Name.t, Location.t) Reactive.t) - ~(reported_modules : (Name.t, unit) Hashtbl.t) ~fileName:pos_fname moduleName - : Issue.t option = + ~(reported_modules : (Name.t, unit) Hashtbl.t) ~fileName:pos_fname + moduleName : Issue.t option = if Hashtbl.mem reported_modules moduleName then None else match Reactive.get dead_modules moduleName with @@ -203,43 +233,45 @@ let check_module_dead ~(dead_modules : (Name.t, Location.t) Reactive.t) Deduplicates module issues across files. *) let collect_issues ~(t : t) ~(config : DceConfig.t) ~(ann_store : AnnotationStore.t) : Issue.t list = - ignore config; (* config is stored in t *) + ignore (config, ann_store); + (* config is stored in t, ann_store used via reactive annotations *) let t0 = Unix.gettimeofday () in (* Track reported modules to avoid duplicates across files *) let reported_modules = Hashtbl.create 64 in - (* Check live declarations for incorrect @dead *) + (* Collect incorrect @dead issues from reactive collection *) let incorrect_dead_issues = ref [] in Reactive.iter (fun _pos (decl : Decl.t) -> - (* Check for incorrect @dead annotation on live decl *) - if AnnotationStore.is_annotated_dead ann_store decl.pos then ( - let issue = - DeadCommon.makeDeadIssue ~decl - ~message:" is annotated @dead but is live" - Issue.IncorrectDeadAnnotation - in - (* Check if module is dead using reactive collection *) - check_module_dead ~dead_modules:t.dead_modules ~reported_modules - ~fileName:decl.pos.pos_fname (decl_module_name decl) - |> Option.iter (fun mod_issue -> - incorrect_dead_issues := mod_issue :: !incorrect_dead_issues); - incorrect_dead_issues := issue :: !incorrect_dead_issues)) - t.live_decls; + let issue = + DeadCommon.makeDeadIssue ~decl + ~message:" is annotated @dead but is live" + Issue.IncorrectDeadAnnotation + in + (* Check if module is dead using reactive collection *) + check_module_dead ~dead_modules:t.dead_modules ~reported_modules + ~fileName:decl.pos.pos_fname (decl_module_name decl) + |> Option.iter (fun mod_issue -> + incorrect_dead_issues := mod_issue :: !incorrect_dead_issues); + incorrect_dead_issues := issue :: !incorrect_dead_issues) + t.incorrect_dead_decls; let t1 = Unix.gettimeofday () in (* Collect issues from reactive issues_by_file *) let num_files = ref 0 in let dead_issues = ref [] in (* Track modules that have at least one reported value (for module issue generation) *) - let modules_with_reported_values : (Name.t, unit) Hashtbl.t = Hashtbl.create 64 in + let modules_with_reported_values : (Name.t, unit) Hashtbl.t = + Hashtbl.create 64 + in Reactive.iter (fun _file (file_issues, modules_list) -> incr num_files; dead_issues := file_issues @ !dead_issues; (* Collect modules that have reported values *) List.iter - (fun moduleName -> Hashtbl.replace modules_with_reported_values moduleName ()) + (fun moduleName -> + Hashtbl.replace modules_with_reported_values moduleName ()) modules_list) t.issues_by_file; let t2 = Unix.gettimeofday () in @@ -262,13 +294,16 @@ let collect_issues ~(t : t) ~(config : DceConfig.t) {Location.loc_start = pos; loc_end = pos; loc_ghost = false} else loc in - module_issues := AnalysisResult.make_dead_module_issue ~loc ~moduleName :: !module_issues)) + module_issues := + AnalysisResult.make_dead_module_issue ~loc ~moduleName + :: !module_issues)) t.dead_modules; let t3 = Unix.gettimeofday () in if !Cli.timing then Printf.eprintf - " collect_issues: iter_live=%.2fms iter_issues=%.2fms iter_modules=%.2fms (%d files)\n" + " collect_issues: incorrect_dead=%.2fms iter_issues=%.2fms \ + iter_modules=%.2fms (%d files)\n" ((t1 -. t0) *. 1000.0) ((t2 -. t1) *. 1000.0) ((t3 -. t2) *. 1000.0) From b98b29ab35fb06ac82edb02e9f8a5ed854680db1 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 21:37:22 +0100 Subject: [PATCH 27/45] Make dead module issues reactive Add modules_with_reported = flatMap of issues_by_file to collect modules with reported values. Add dead_module_issues = reactive join of dead_modules + modules_with_reported. Now collect_issues only iterates pre-computed reactive collections: - incorrect_dead_decls (few) - issues_by_file (pre-computed per-file issues) - dead_module_issues (pre-computed module issues) Timing improvement (benchmark, cache hit): - iter_modules: 0.06-0.07ms (was ~5-6ms when iterating all dead_modules) - dead_code total: 0.66-1.3ms (was ~7-10ms) All 380/19000 issues still match between reactive and non-reactive modes. --- analysis/reanalyze/src/ReactiveSolver.ml | 72 +++++++++++++----------- 1 file changed, 39 insertions(+), 33 deletions(-) diff --git a/analysis/reanalyze/src/ReactiveSolver.ml b/analysis/reanalyze/src/ReactiveSolver.ml index 4e2ae1d400..1a86d29ac0 100644 --- a/analysis/reanalyze/src/ReactiveSolver.ml +++ b/analysis/reanalyze/src/ReactiveSolver.ml @@ -1,7 +1,7 @@ (** Reactive dead code solver. Reactive pipeline: decls + live + annotations → dead_decls, live_decls, dead_modules, - dead_decls_by_file, issues_by_file, incorrect_dead_decls + dead_decls_by_file, issues_by_file, incorrect_dead_decls, dead_module_issues Current status: - All collections are reactive (zero recomputation on cache hit for unchanged files) @@ -11,11 +11,11 @@ - value_refs_from_by_file = refs grouped by source file (reactive flatMap with merge) - issues_by_file = per-file issue generation (reactive flatMap) - incorrect_dead_decls = live decls with @dead annotation (reactive join) + - dead_module_issues = dead_modules joined with modules_with_reported (reactive join) - is_pos_live uses reactive live collection (no resolvedDead mutation) - shouldReport callback replaces report field mutation (no mutation needed) - isInsideReportedValue is per-file only, so files are independent - hasRefBelow uses per-file refs: O(file_refs) per dead decl (was O(total_refs)) - - Module issues generated in collect_issues from dead_modules + modules_with_reported_values All issues now match between reactive and non-reactive modes (380 on deadcode test): - Dead code issues: 362 (Exception:2, Module:31, Type:87, Value:233, ValueWithSideEffects:8) @@ -39,6 +39,8 @@ type t = { Second component: modules with at least one reported value (for module issue generation). *) incorrect_dead_decls: (Lexing.position, Decl.t) Reactive.t; (** Live declarations with @dead annotation. Reactive join of live_decls + annotations. *) + dead_module_issues: (Name.t, Issue.t) Reactive.t; + (** Dead module issues. Reactive join of dead_modules + modules_with_reported. *) config: DceConfig.t; } @@ -193,6 +195,35 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) () in + (* Reactive modules_with_reported: modules that have at least one reported dead value *) + let modules_with_reported = + Reactive.flatMap issues_by_file + ~f:(fun _file (_issues, modules_list) -> + List.map (fun m -> (m, ())) modules_list) + () + in + + (* Reactive dead module issues: dead_modules joined with modules_with_reported *) + let dead_module_issues = + Reactive.join dead_modules modules_with_reported + ~key_of:(fun moduleName _loc -> moduleName) + ~f:(fun moduleName loc has_reported_opt -> + match has_reported_opt with + | Some () -> + let loc = + if loc.Location.loc_ghost then + let pos_fname = loc.loc_start.pos_fname in + let pos = + {Lexing.pos_fname; pos_lnum = 0; pos_bol = 0; pos_cnum = 0} + in + {Location.loc_start = pos; loc_end = pos; loc_ghost = false} + else loc + in + [(moduleName, AnalysisResult.make_dead_module_issue ~loc ~moduleName)] + | None -> []) + () + in + { decls; live; @@ -204,6 +235,7 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) dead_decls_by_file; issues_by_file; incorrect_dead_decls; + dead_module_issues; config; } @@ -260,44 +292,18 @@ let collect_issues ~(t : t) ~(config : DceConfig.t) (* Collect issues from reactive issues_by_file *) let num_files = ref 0 in let dead_issues = ref [] in - (* Track modules that have at least one reported value (for module issue generation) *) - let modules_with_reported_values : (Name.t, unit) Hashtbl.t = - Hashtbl.create 64 - in Reactive.iter - (fun _file (file_issues, modules_list) -> + (fun _file (file_issues, _modules_list) -> incr num_files; - dead_issues := file_issues @ !dead_issues; - (* Collect modules that have reported values *) - List.iter - (fun moduleName -> - Hashtbl.replace modules_with_reported_values moduleName ()) - modules_list) + dead_issues := file_issues @ !dead_issues) t.issues_by_file; let t2 = Unix.gettimeofday () in - (* Generate module issues: only for modules that are dead AND have a reported value *) + (* Collect module issues from reactive dead_module_issues *) let module_issues = ref [] in - let reported_modules : (Name.t, unit) Hashtbl.t = Hashtbl.create 64 in Reactive.iter - (fun moduleName loc -> - (* Only report if module has at least one reported dead value *) - if Hashtbl.mem modules_with_reported_values moduleName then - if not (Hashtbl.mem reported_modules moduleName) then ( - Hashtbl.replace reported_modules moduleName (); - let loc = - if loc.Location.loc_ghost then - let pos_fname = loc.loc_start.pos_fname in - let pos = - {Lexing.pos_fname; pos_lnum = 0; pos_bol = 0; pos_cnum = 0} - in - {Location.loc_start = pos; loc_end = pos; loc_ghost = false} - else loc - in - module_issues := - AnalysisResult.make_dead_module_issue ~loc ~moduleName - :: !module_issues)) - t.dead_modules; + (fun _moduleName issue -> module_issues := issue :: !module_issues) + t.dead_module_issues; let t3 = Unix.gettimeofday () in if !Cli.timing then From fbb716b12b3dc108043eb0ba20efef62accc126f Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 21:50:08 +0100 Subject: [PATCH 28/45] Update reactive pipeline documentation and diagrams - Update ARCHITECTURE.md with current ReactiveSolver collections - Add table documenting all reactive collections in ReactiveSolver - Update pipeline stages to show full reactive flow - Regenerate reactive-pipeline.svg with detailed solver internals - Show timing breakdown (~0.7ms on cache hit for dead_code solving) --- analysis/reanalyze/ARCHITECTURE.md | 156 +++++++++++------- .../reanalyze/diagrams/reactive-pipeline.mmd | 44 ++++- .../reanalyze/diagrams/reactive-pipeline.svg | 2 +- 3 files changed, 129 insertions(+), 73 deletions(-) diff --git a/analysis/reanalyze/ARCHITECTURE.md b/analysis/reanalyze/ARCHITECTURE.md index cb264c4d36..c6a440e472 100644 --- a/analysis/reanalyze/ARCHITECTURE.md +++ b/analysis/reanalyze/ARCHITECTURE.md @@ -164,13 +164,13 @@ The reactive layer (`analysis/reactive/`) provides delta-based incremental updat The reactive pipeline computes issues directly from source files with **zero recomputation on cache hits**: ``` -Files → file_data → decls, annotations, refs → live (fixpoint) → dead_decls → issues → REPORT - ↓ ↓ ↓ ↓ ↓ ↓ - ReactiveFile ReactiveMerge ReactiveLiveness ReactiveSolver iter - Collection (flatMap) (fixpoint) (join+join) (only) +Files → file_data → decls, annotations, refs → live (fixpoint) → dead/live_decls → issues → REPORT + ↓ ↓ ↓ ↓ ↓ ↓ + ReactiveFile ReactiveMerge ReactiveLiveness ReactiveSolver iter + Collection (flatMap) (fixpoint) (multiple joins) (only) ``` -**Key property**: When no files change, no computation happens. All reactive collections are stable. Only the final `collect_issues` call iterates (O(issues)). +**Key property**: When no files change, no computation happens. All reactive collections are stable. Only the final `collect_issues` call iterates pre-computed collections (O(issues)). ### Pipeline Stages @@ -179,11 +179,28 @@ Files → file_data → decls, annotations, refs → live (fixpoint) → dead_de | **File Processing** | `.cmt` files | `file_data` | `ReactiveFileCollection` | | **Merge** | `file_data` | `decls`, `annotations`, `refs` | `flatMap` | | **Liveness** | `refs`, `annotations` | `live` (positions) | `fixpoint` | -| **Dead Decls** | `decls`, `live` | `dead_decls` | `join` (left-join, filter `None`: decls where NOT in live) | -| **Issues** | `dead_decls`, `annotations` | `issues` | `join` (filter by annotation, generate Issue.t) | -| **Report** | `issues` | stdout | `iter` (ONLY iteration in entire pipeline) | - -**Note**: Optional args analysis (unused/redundant arguments) is not yet in the reactive pipeline - it still uses the non-reactive path. TODO: Add `live_decls + cross_file_items → optional_args_issues` to the reactive pipeline. +| **Dead/Live Partition** | `decls`, `live` | `dead_decls`, `live_decls` | `join` (partition by liveness) | +| **Dead Modules** | `dead_decls`, `live_decls` | `dead_modules` | `flatMap` + `join` (anti-join) | +| **Per-File Grouping** | `dead_decls`, `refs` | `dead_decls_by_file`, `refs_by_file` | `flatMap` with merge | +| **Per-File Issues** | `dead_decls_by_file`, `annotations` | `issues_by_file` | `flatMap` (sort + filter + generate) | +| **Incorrect @dead** | `live_decls`, `annotations` | `incorrect_dead_decls` | `join` (live with Dead annotation) | +| **Module Issues** | `dead_modules`, `issues_by_file` | `dead_module_issues` | `flatMap` + `join` | +| **Report** | all issue collections | stdout | `iter` (ONLY iteration) | + +### ReactiveSolver Collections + +| Collection | Type | Description | +|------------|------|-------------| +| `dead_decls` | `(pos, Decl.t)` | Declarations NOT in live set | +| `live_decls` | `(pos, Decl.t)` | Declarations IN live set | +| `dead_modules` | `(Name.t, Location.t)` | Modules with only dead declarations (anti-join) | +| `dead_decls_by_file` | `(file, Decl.t list)` | Dead decls grouped by file | +| `value_refs_from_by_file` | `(file, (pos, PosSet.t) list)` | Refs grouped by source file (for hasRefBelow) | +| `issues_by_file` | `(file, Issue.t list * Name.t list)` | Per-file issues + reported modules | +| `incorrect_dead_decls` | `(pos, Decl.t)` | Live decls with @dead annotation | +| `dead_module_issues` | `(Name.t, Issue.t)` | Module issues (join of dead_modules + modules_with_reported) | + +**Note**: Optional args analysis (unused/redundant arguments) is not yet in the reactive pipeline - it still uses the non-reactive path (~8-14ms). TODO: Add `live_decls + cross_file_items → optional_args_issues` to the reactive pipeline. ### Reactive Pipeline Diagram @@ -192,59 +209,72 @@ Files → file_data → decls, annotations, refs → live (fixpoint) → dead_de ![Reactive Pipeline](diagrams/reactive-pipeline.svg) ``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ REACTIVE ANALYSIS PIPELINE │ -│ │ -│ ┌──────────┐ │ -│ │ .cmt │ │ -│ │ files │ │ -│ └────┬─────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────────────┐ │ -│ │ ReactiveFileCollection│ File change detection + caching │ -│ │ file_data │ │ -│ └────┬─────────────────┘ │ -│ │ flatMap │ -│ ▼ │ -│ ┌──────────────────────┐ │ -│ │ ReactiveMerge │ Derives collections from file_data │ -│ │ ┌──────┐ ┌────────┐ │ │ -│ │ │decls │ │ refs │ │ │ -│ │ └──┬───┘ └───┬────┘ │ │ -│ │ │ ┌──────┴─────┐ │ │ -│ │ │ │annotations │ │ │ -│ │ │ └──────┬─────┘ │ │ -│ └────┼─────────┼───────┘ │ -│ │ │ │ -│ │ ▼ │ -│ │ ┌─────────────────────┐ │ -│ │ │ ReactiveLiveness │ roots + edges → live (fixpoint) │ -│ │ │ ┌──────┐ ┌──────┐ │ │ -│ │ │ │roots │→│ live │ │ │ -│ │ │ └──────┘ └──┬───┘ │ │ -│ │ └──────────────┼──────┘ │ -│ │ │ │ -│ ▼ ▼ │ -│ ┌─────────────────────────────────┐ │ -│ │ ReactiveSolver │ Pure reactive joins (NO iteration) │ -│ │ │ │ -│ │ decls ──┬──► dead_decls ──┬──► issues │ -│ │ │ ↑ │ ↑ │ -│ │ live ───┘ (join, keep │ (join with annotations) │ -│ │ if NOT in live)│ │ -│ │ annotations ──────────────┘ │ -│ │ │ │ -│ │ (Optional args: TODO - not yet reactive) │ -│ └─────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────┐ │ -│ │ REPORT │ ONLY iteration: O(issues) │ -│ │ collect_issues → Log_.warning │ (linear in number of issues) │ -│ └─────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ +┌───────────────────────────────────────────────────────────────────────────────────┐ +│ REACTIVE ANALYSIS PIPELINE │ +│ │ +│ ┌──────────┐ │ +│ │ .cmt │ │ +│ │ files │ │ +│ └────┬─────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────┐ │ +│ │ ReactiveFileCollection│ File change detection + caching │ +│ │ file_data │ │ +│ └────┬─────────────────┘ │ +│ │ flatMap │ +│ ▼ │ +│ ┌──────────────────────┐ │ +│ │ ReactiveMerge │ Derives collections from file_data │ +│ │ ┌──────┐ ┌────────┐ │ │ +│ │ │decls │ │ refs │ │ │ +│ │ └──┬───┘ └───┬────┘ │ │ +│ │ │ ┌──────┴─────┐ │ │ +│ │ │ │annotations │ │ │ +│ │ │ └──────┬─────┘ │ │ +│ └────┼─────────┼───────┘ │ +│ │ │ │ +│ │ ▼ │ +│ │ ┌─────────────────────┐ │ +│ │ │ ReactiveLiveness │ roots + edges → live (fixpoint) │ +│ │ │ ┌──────┐ ┌──────┐ │ │ +│ │ │ │roots │→│ live │ │ │ +│ │ │ └──────┘ └──┬───┘ │ │ +│ │ └──────────────┼──────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────────────────┐ │ +│ │ ReactiveSolver │ │ +│ │ │ │ +│ │ decls ──┬──► dead_decls ──┬──► dead_decls_by_file ──► issues_by_file │ │ +│ │ │ │ │ │ │ │ │ +│ │ live ───┤ │ │ │ ▼ │ │ +│ │ │ ▼ │ │ modules_with_reported │ │ +│ │ │ dead_modules ─┼────────────┼──────────────┬─────────┘ │ │ +│ │ │ ↑ │ │ │ │ │ +│ │ └──► live_decls ──┼────────────┘ ▼ │ │ +│ │ │ │ dead_module_issues │ │ +│ │ │ │ │ │ +│ │ annotations ─────┼────────┴──► incorrect_dead_decls │ │ +│ │ │ │ │ +│ │ value_refs_from ─┴──► refs_by_file (used by issues_by_file for hasRefBelow)│ │ +│ │ │ │ +│ │ (Optional args: TODO - not yet reactive, ~8-14ms) │ │ +│ └─────────────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────────────────┐ │ +│ │ REPORT │ │ +│ │ │ │ +│ │ collect_issues iterates pre-computed reactive collections: │ │ +│ │ - incorrect_dead_decls (~0.04ms) │ │ +│ │ - issues_by_file (~0.6ms) │ │ +│ │ - dead_module_issues (~0.06ms) │ │ +│ │ │ │ +│ │ Total dead_code solving: ~0.7ms on cache hit │ │ +│ └─────────────────────────────────────────────────────────────────────────────┘ │ +│ │ +└───────────────────────────────────────────────────────────────────────────────────┘ ``` ### Delta Propagation diff --git a/analysis/reanalyze/diagrams/reactive-pipeline.mmd b/analysis/reanalyze/diagrams/reactive-pipeline.mmd index c8411ef5b4..a086482f46 100644 --- a/analysis/reanalyze/diagrams/reactive-pipeline.mmd +++ b/analysis/reanalyze/diagrams/reactive-pipeline.mmd @@ -39,11 +39,18 @@ flowchart TB end subgraph Solver["ReactiveSolver"] - DEAD["dead"] - ISSUES["issues"] + DEAD_DECLS["dead_decls"] + LIVE_DECLS["live_decls"] + DEAD_MODULES["dead_modules"] + DEAD_BY_FILE["dead_by_file"] + REFS_BY_FILE["refs_by_file"] + ISSUES_BY_FILE["issues_by_file"] + INCORRECT["incorrect_dead"] + MOD_REPORTED["mod_reported"] + MOD_ISSUES["mod_issues"] end - subgraph Report["Report"] + subgraph Report["Report (iter only)"] OUTPUT[("REPORT")] end @@ -76,12 +83,31 @@ flowchart TB EDGES --> FP FP -->|"fixpoint"| LIVE - DECLS -->|"join"| DEAD - LIVE -->|"left-join, filter None"| DEAD - DEAD -->|"join"| ISSUES - ANNOT -->|"join"| ISSUES + DECLS -->|"join (NOT in live)"| DEAD_DECLS + LIVE -->|"join"| DEAD_DECLS + DECLS -->|"join (IN live)"| LIVE_DECLS + LIVE -->|"join"| LIVE_DECLS - ISSUES -->|"iter"| OUTPUT + DEAD_DECLS -->|"flatMap (anti-join)"| DEAD_MODULES + LIVE_DECLS -->|"flatMap (anti-join)"| DEAD_MODULES + + DEAD_DECLS -->|"flatMap by file"| DEAD_BY_FILE + VREFS -->|"flatMap by file"| REFS_BY_FILE + + DEAD_BY_FILE -->|"flatMap"| ISSUES_BY_FILE + ANNOT -->|"filter"| ISSUES_BY_FILE + REFS_BY_FILE -->|"hasRefBelow"| ISSUES_BY_FILE + + LIVE_DECLS -->|"join (@dead)"| INCORRECT + ANNOT -->|"join (@dead)"| INCORRECT + + ISSUES_BY_FILE -->|"flatMap"| MOD_REPORTED + DEAD_MODULES -->|"join"| MOD_ISSUES + MOD_REPORTED -->|"join"| MOD_ISSUES + + ISSUES_BY_FILE -->|"iter"| OUTPUT + INCORRECT -->|"iter"| OUTPUT + MOD_ISSUES -->|"iter"| OUTPUT classDef fileLayer fill:#e8f4fd,stroke:#4a90d9,stroke-width:2px classDef extracted fill:#f0f7e6,stroke:#6b8e23,stroke-width:2px @@ -98,5 +124,5 @@ flowchart TB class EXCREF,EXCDECL,RESOLVED excDeps class DR declRefs class ROOTS,EDGES,FP,LIVE liveness - class DEAD,ISSUES solver + class DEAD_DECLS,LIVE_DECLS,DEAD_MODULES,DEAD_BY_FILE,REFS_BY_FILE,ISSUES_BY_FILE,INCORRECT,MOD_REPORTED,MOD_ISSUES solver class OUTPUT output diff --git a/analysis/reanalyze/diagrams/reactive-pipeline.svg b/analysis/reanalyze/diagrams/reactive-pipeline.svg index 1830fbe698..2eb988d960 100644 --- a/analysis/reanalyze/diagrams/reactive-pipeline.svg +++ b/analysis/reanalyze/diagrams/reactive-pipeline.svg @@ -1 +1 @@ -

Report

ReactiveSolver

ReactiveLiveness

ReactiveDeclRefs

ReactiveExceptionRefs

ReactiveTypeDeps

Extracted (ReactiveMerge)

Per-File Data

File Layer

process

flatMap

flatMap

flatMap

flatMap

flatMap

flatMap

union+flatMap

flatMap

flatMap

join

join

flatMap

fixpoint

join

left-join, filter None

join

join

iter

RFC

FD

D

A

VR

TR

CFI

DBP

ATR

ER

ED

RR

DR

roots

edges

fixpoint

LIVE

dead

issues

REPORT

\ No newline at end of file +

Report (iter only)

ReactiveSolver

ReactiveLiveness

ReactiveDeclRefs

ReactiveExceptionRefs

ReactiveTypeDeps

Extracted (ReactiveMerge)

Per-File Data

File Layer

process

flatMap

flatMap

flatMap

flatMap

flatMap

flatMap

union+flatMap

flatMap

flatMap

join

join

flatMap

fixpoint

join (NOT in live)

join

join (IN live)

join

flatMap (anti-join)

flatMap (anti-join)

flatMap by file

flatMap by file

flatMap

filter

hasRefBelow

join (@dead)

join (@dead)

flatMap

join

join

iter

iter

iter

RFC

FD

D

A

VR

TR

CFI

DBP

ATR

ER

ED

RR

DR

roots

edges

fixpoint

LIVE

dead_decls

live_decls

dead_modules

dead_by_file

refs_by_file

issues_by_file

incorrect_dead

mod_reported

mod_issues

REPORT

\ No newline at end of file From 6db81bc7e26e0cd2545eedb2ffe1fb8b5bbb4fc7 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 22:01:32 +0100 Subject: [PATCH 29/45] Add update counters to reactive collections Track updates_received and updates_emitted for each reactive collection. Add stats field to Reactive.t type. Add print_stats to ReactiveSolver to show all collection statistics. Example output (benchmark, cold start): dead_decls: recv=142575 emit=40682 len=18050 live_decls: recv=142575 emit=27674 len=17500 dead_modules: recv=68356 emit=11856 len=1700 dead_decls_by_file: recv=40682 emit=40682 len=2900 issues_by_file: recv=40682 emit=40682 len=2900 incorrect_dead_decls: recv=38474 emit=50 len=50 dead_module_issues: recv=56563 emit=21416 len=1550 On cache hit (second run), counters don't increase - zero new updates. --- analysis/reactive/src/Reactive.ml | 54 ++++++++++++++++--- analysis/reactive/src/Reactive.mli | 16 +++++- .../reactive/src/ReactiveFileCollection.ml | 6 ++- analysis/reactive/test/ReactiveTest.ml | 20 +++++++ analysis/reanalyze/src/ReactiveSolver.ml | 19 +++++++ analysis/reanalyze/src/ReactiveSolver.mli | 3 ++ analysis/reanalyze/src/Reanalyze.ml | 3 +- 7 files changed, 112 insertions(+), 9 deletions(-) diff --git a/analysis/reactive/src/Reactive.ml b/analysis/reactive/src/Reactive.ml index 5b09214d0c..7ef02eeba0 100644 --- a/analysis/reactive/src/Reactive.ml +++ b/analysis/reactive/src/Reactive.ml @@ -12,6 +12,12 @@ let apply_delta tbl = function let apply_deltas tbl deltas = List.iter (apply_delta tbl) deltas +(** {1 Statistics} *) + +type stats = {mutable updates_received: int; mutable updates_emitted: int} + +let create_stats () = {updates_received = 0; updates_emitted = 0} + (** {1 Reactive Collection} *) type ('k, 'v) t = { @@ -19,15 +25,18 @@ type ('k, 'v) t = { iter: ('k -> 'v -> unit) -> unit; get: 'k -> 'v option; length: unit -> int; + stats: stats; } (** A reactive collection that can emit deltas and be read. - All collections share this interface, enabling composition. *) + All collections share this interface, enabling composition. + [stats] tracks updates received/emitted for diagnostics. *) (** {1 Collection operations} *) let iter f t = t.iter f let get t k = t.get k let length t = t.length () +let stats t = t.stats (** {1 FlatMap} *) @@ -47,8 +56,12 @@ let flatMap (source : ('k1, 'v1) t) ~f ?merge () : ('k2, 'v2) t = in let target : ('k2, 'v2) Hashtbl.t = Hashtbl.create 256 in let subscribers : (('k2, 'v2) delta -> unit) list ref = ref [] in + let my_stats = create_stats () in - let emit delta = List.iter (fun h -> h delta) !subscribers in + let emit delta = + my_stats.updates_emitted <- my_stats.updates_emitted + 1; + List.iter (fun h -> h delta) !subscribers + in let recompute_target k2 = match Hashtbl.find_opt contributions k2 with @@ -102,6 +115,7 @@ let flatMap (source : ('k1, 'v1) t) ~f ?merge () : ('k2, 'v2) t = in let handle_delta delta = + my_stats.updates_received <- my_stats.updates_received + 1; let downstream = match delta with | Remove k1 -> @@ -135,6 +149,7 @@ let flatMap (source : ('k1, 'v1) t) ~f ?merge () : ('k2, 'v2) t = iter = (fun f -> Hashtbl.iter f target); get = (fun k -> Hashtbl.find_opt target k); length = (fun () -> Hashtbl.length target); + stats = my_stats; } (** {1 Lookup} *) @@ -147,10 +162,15 @@ let flatMap (source : ('k1, 'v1) t) ~f ?merge () : ('k2, 'v2) t = let lookup (source : ('k, 'v) t) ~key : ('k, 'v) t = let current : ('k, 'v option) Hashtbl.t = Hashtbl.create 1 in let subscribers : (('k, 'v) delta -> unit) list ref = ref [] in + let my_stats = create_stats () in - let emit delta = List.iter (fun h -> h delta) !subscribers in + let emit delta = + my_stats.updates_emitted <- my_stats.updates_emitted + 1; + List.iter (fun h -> h delta) !subscribers + in let handle_delta delta = + my_stats.updates_received <- my_stats.updates_received + 1; match delta with | Set (k, v) when k = key -> Hashtbl.replace current key (Some v); @@ -188,6 +208,7 @@ let lookup (source : ('k, 'v) t) ~key : ('k, 'v) t = match Hashtbl.find_opt current key with | Some (Some _) -> 1 | _ -> 0); + stats = my_stats; } (** {1 Join} *) @@ -221,8 +242,12 @@ let join (left : ('k1, 'v1) t) (right : ('k2, 'v2) t) in let target : ('k3, 'v3) Hashtbl.t = Hashtbl.create 256 in let subscribers : (('k3, 'v3) delta -> unit) list ref = ref [] in + let my_stats = create_stats () in - let emit delta = List.iter (fun h -> h delta) !subscribers in + let emit delta = + my_stats.updates_emitted <- my_stats.updates_emitted + 1; + List.iter (fun h -> h delta) !subscribers + in let recompute_target k3 = match Hashtbl.find_opt contributions k3 with @@ -326,6 +351,7 @@ let join (left : ('k1, 'v1) t) (right : ('k2, 'v2) t) in let handle_left_delta delta = + my_stats.updates_received <- my_stats.updates_received + 1; let downstream = match delta with | Set (k1, v1) -> @@ -337,6 +363,7 @@ let join (left : ('k1, 'v1) t) (right : ('k2, 'v2) t) in let handle_right_delta delta = + my_stats.updates_received <- my_stats.updates_received + 1; (* When right changes, reprocess all left entries that depend on it *) let downstream = match delta with @@ -368,6 +395,7 @@ let join (left : ('k1, 'v1) t) (right : ('k2, 'v2) t) iter = (fun f -> Hashtbl.iter f target); get = (fun k -> Hashtbl.find_opt target k); length = (fun () -> Hashtbl.length target); + stats = my_stats; } (** {1 Union} *) @@ -388,8 +416,12 @@ let union (left : ('k, 'v) t) (right : ('k, 'v) t) ?merge () : ('k, 'v) t = let right_values : ('k, 'v) Hashtbl.t = Hashtbl.create 64 in let target : ('k, 'v) Hashtbl.t = Hashtbl.create 128 in let subscribers : (('k, 'v) delta -> unit) list ref = ref [] in + let my_stats = create_stats () in - let emit delta = List.iter (fun h -> h delta) !subscribers in + let emit delta = + my_stats.updates_emitted <- my_stats.updates_emitted + 1; + List.iter (fun h -> h delta) !subscribers + in let recompute_key k = match (Hashtbl.find_opt left_values k, Hashtbl.find_opt right_values k) with @@ -406,6 +438,7 @@ let union (left : ('k, 'v) t) (right : ('k, 'v) t) ?merge () : ('k, 'v) t = in let handle_left_delta delta = + my_stats.updates_received <- my_stats.updates_received + 1; let downstream = match delta with | Set (k, v) -> @@ -419,6 +452,7 @@ let union (left : ('k, 'v) t) (right : ('k, 'v) t) ?merge () : ('k, 'v) t = in let handle_right_delta delta = + my_stats.updates_received <- my_stats.updates_received + 1; let downstream = match delta with | Set (k, v) -> @@ -448,6 +482,7 @@ let union (left : ('k, 'v) t) (right : ('k, 'v) t) ?merge () : ('k, 'v) t = iter = (fun f -> Hashtbl.iter f target); get = (fun k -> Hashtbl.find_opt target k); length = (fun () -> Hashtbl.length target); + stats = my_stats; } (** {1 Fixpoint} *) @@ -840,8 +875,12 @@ let fixpoint ~(init : ('k, unit) t) ~(edges : ('k, 'k list) t) () : ('k, unit) t = let state = Fixpoint.create () in let subscribers : (('k, unit) delta -> unit) list ref = ref [] in + let my_stats = create_stats () in - let emit delta = List.iter (fun h -> h delta) !subscribers in + let emit delta = + my_stats.updates_emitted <- my_stats.updates_emitted + 1; + List.iter (fun h -> h delta) !subscribers + in let emit_changes (added, removed) = List.iter (fun k -> emit (Set (k, ()))) added; @@ -850,12 +889,14 @@ let fixpoint ~(init : ('k, unit) t) ~(edges : ('k, 'k list) t) () : ('k, unit) t (* Handle init deltas *) let handle_init_delta delta = + my_stats.updates_received <- my_stats.updates_received + 1; let changes = Fixpoint.apply_init_delta state delta in emit_changes changes in (* Handle edges deltas *) let handle_edges_delta delta = + my_stats.updates_received <- my_stats.updates_received + 1; let changes = Fixpoint.apply_edges_delta state delta in emit_changes changes in @@ -888,4 +929,5 @@ let fixpoint ~(init : ('k, unit) t) ~(edges : ('k, 'k list) t) () : ('k, unit) t iter = (fun f -> Hashtbl.iter f state.current); get = (fun k -> Hashtbl.find_opt state.current k); length = (fun () -> Hashtbl.length state.current); + stats = my_stats; } diff --git a/analysis/reactive/src/Reactive.mli b/analysis/reactive/src/Reactive.mli index 2431ab7f9a..a6e75c49b6 100644 --- a/analysis/reactive/src/Reactive.mli +++ b/analysis/reactive/src/Reactive.mli @@ -34,6 +34,15 @@ type ('k, 'v) delta = Set of 'k * 'v | Remove of 'k val apply_delta : ('k, 'v) Hashtbl.t -> ('k, 'v) delta -> unit val apply_deltas : ('k, 'v) Hashtbl.t -> ('k, 'v) delta list -> unit +(** {1 Statistics} *) + +type stats = { + mutable updates_received: int; (** Deltas received from upstream *) + mutable updates_emitted: int; (** Deltas emitted downstream *) +} + +val create_stats : unit -> stats + (** {1 Reactive Collection} *) type ('k, 'v) t = { @@ -41,9 +50,11 @@ type ('k, 'v) t = { iter: ('k -> 'v -> unit) -> unit; get: 'k -> 'v option; length: unit -> int; + stats: stats; } (** A reactive collection that can emit deltas and be read. - All collections share this interface, enabling composition. *) + All collections share this interface, enabling composition. + [stats] tracks updates received/emitted for diagnostics. *) (** {1 Collection operations} *) @@ -56,6 +67,9 @@ val get : ('k, 'v) t -> 'k -> 'v option val length : ('k, 'v) t -> int (** Number of entries. *) +val stats : ('k, 'v) t -> stats +(** Get update statistics for this collection. *) + (** {1 Composition} *) val flatMap : diff --git a/analysis/reactive/src/ReactiveFileCollection.ml b/analysis/reactive/src/ReactiveFileCollection.ml index f634468197..41fd4f2b16 100644 --- a/analysis/reactive/src/ReactiveFileCollection.ml +++ b/analysis/reactive/src/ReactiveFileCollection.ml @@ -27,13 +27,16 @@ type ('raw, 'v) t = { } (** A file collection is just a Reactive.t with some extra operations *) -let emit t delta = List.iter (fun h -> h delta) t.internal.subscribers +let emit t delta = + t.collection.stats.updates_emitted <- t.collection.stats.updates_emitted + 1; + List.iter (fun h -> h delta) t.internal.subscribers (** Create a new reactive file collection *) let create ~read_file ~process : ('raw, 'v) t = let internal = {cache = Hashtbl.create 256; read_file; process; subscribers = []} in + let my_stats = Reactive.create_stats () in let collection = { Reactive.subscribe = @@ -46,6 +49,7 @@ let create ~read_file ~process : ('raw, 'v) t = | Some (_, v) -> Some v | None -> None); length = (fun () -> Hashtbl.length internal.cache); + stats = my_stats; } in {internal; collection} diff --git a/analysis/reactive/test/ReactiveTest.ml b/analysis/reactive/test/ReactiveTest.ml index 076a04fdcb..0abae67204 100644 --- a/analysis/reactive/test/ReactiveTest.ml +++ b/analysis/reactive/test/ReactiveTest.ml @@ -35,6 +35,7 @@ let test_flatmap_basic () = iter = (fun f -> Hashtbl.iter f data); get = (fun k -> Hashtbl.find_opt data k); length = (fun () -> Hashtbl.length data); + stats = create_stats (); } in @@ -93,6 +94,7 @@ let test_flatmap_with_merge () = iter = (fun f -> Hashtbl.iter f data); get = (fun k -> Hashtbl.find_opt data k); length = (fun () -> Hashtbl.length data); + stats = create_stats (); } in @@ -144,6 +146,7 @@ let test_composition () = iter = (fun f -> Hashtbl.iter f data); get = (fun k -> Hashtbl.find_opt data k); length = (fun () -> Hashtbl.length data); + stats = create_stats (); } in @@ -211,6 +214,7 @@ let test_flatmap_on_existing_data () = iter = (fun f -> Hashtbl.iter f data); get = (fun k -> Hashtbl.find_opt data k); length = (fun () -> Hashtbl.length data); + stats = create_stats (); } in @@ -341,6 +345,7 @@ let test_lookup () = iter = (fun f -> Hashtbl.iter f data); get = (fun k -> Hashtbl.find_opt data k); length = (fun () -> Hashtbl.length data); + stats = create_stats (); } in @@ -402,6 +407,7 @@ let test_join () = iter = (fun f -> Hashtbl.iter f left_data); get = (fun k -> Hashtbl.find_opt left_data k); length = (fun () -> Hashtbl.length left_data); + stats = create_stats (); } in let emit_left delta = @@ -418,6 +424,7 @@ let test_join () = iter = (fun f -> Hashtbl.iter f right_data); get = (fun k -> Hashtbl.find_opt right_data k); length = (fun () -> Hashtbl.length right_data); + stats = create_stats (); } in let emit_right delta = @@ -498,6 +505,7 @@ let test_join_with_merge () = iter = (fun f -> Hashtbl.iter f left_data); get = (fun k -> Hashtbl.find_opt left_data k); length = (fun () -> Hashtbl.length left_data); + stats = create_stats (); } in let emit_left delta = @@ -513,6 +521,7 @@ let test_join_with_merge () = iter = (fun f -> Hashtbl.iter f right_data); get = (fun k -> Hashtbl.find_opt right_data k); length = (fun () -> Hashtbl.length right_data); + stats = create_stats (); } in let emit_right delta = @@ -568,6 +577,7 @@ let test_union_basic () = iter = (fun f -> Hashtbl.iter f left_data); get = (fun k -> Hashtbl.find_opt left_data k); length = (fun () -> Hashtbl.length left_data); + stats = create_stats (); } in let emit_left delta = @@ -584,6 +594,7 @@ let test_union_basic () = iter = (fun f -> Hashtbl.iter f right_data); get = (fun k -> Hashtbl.find_opt right_data k); length = (fun () -> Hashtbl.length right_data); + stats = create_stats (); } in let emit_right delta = @@ -648,6 +659,7 @@ let test_union_with_merge () = iter = (fun f -> Hashtbl.iter f left_data); get = (fun k -> Hashtbl.find_opt left_data k); length = (fun () -> Hashtbl.length left_data); + stats = create_stats (); } in let emit_left delta = @@ -664,6 +676,7 @@ let test_union_with_merge () = iter = (fun f -> Hashtbl.iter f right_data); get = (fun k -> Hashtbl.find_opt right_data k); length = (fun () -> Hashtbl.length right_data); + stats = create_stats (); } in let emit_right delta = @@ -718,6 +731,7 @@ let test_union_existing_data () = iter = (fun f -> Hashtbl.iter f left_data); get = (fun k -> Hashtbl.find_opt left_data k); length = (fun () -> Hashtbl.length left_data); + stats = create_stats (); } in @@ -732,6 +746,7 @@ let test_union_existing_data () = iter = (fun f -> Hashtbl.iter f right_data); get = (fun k -> Hashtbl.find_opt right_data k); length = (fun () -> Hashtbl.length right_data); + stats = create_stats (); } in @@ -753,7 +768,9 @@ let test_union_existing_data () = let create_mutable_collection () = let tbl = Hashtbl.create 16 in let subscribers = ref [] in + let my_stats = Reactive.create_stats () in let emit delta = + my_stats.updates_emitted <- my_stats.updates_emitted + 1; Reactive.apply_delta tbl delta; List.iter (fun h -> h delta) !subscribers in @@ -763,6 +780,7 @@ let create_mutable_collection () = iter = (fun f -> Hashtbl.iter f tbl); get = (fun k -> Hashtbl.find_opt tbl k); length = (fun () -> Hashtbl.length tbl); + stats = my_stats; } in (collection, emit, tbl) @@ -1665,6 +1683,7 @@ let test_fixpoint_existing_data () = iter = (fun f -> Hashtbl.iter f init_tbl); get = (fun k -> Hashtbl.find_opt init_tbl k); length = (fun () -> Hashtbl.length init_tbl); + stats = Reactive.create_stats (); } in @@ -1678,6 +1697,7 @@ let test_fixpoint_existing_data () = iter = (fun f -> Hashtbl.iter f edges_tbl); get = (fun k -> Hashtbl.find_opt edges_tbl k); length = (fun () -> Hashtbl.length edges_tbl); + stats = Reactive.create_stats (); } in diff --git a/analysis/reanalyze/src/ReactiveSolver.ml b/analysis/reanalyze/src/ReactiveSolver.ml index 1a86d29ac0..8ce15e7706 100644 --- a/analysis/reanalyze/src/ReactiveSolver.ml +++ b/analysis/reanalyze/src/ReactiveSolver.ml @@ -331,3 +331,22 @@ let is_pos_live ~(t : t) (pos : Lexing.position) : bool = (** Stats *) let stats ~(t : t) : int * int = (Reactive.length t.dead_decls, Reactive.length t.live_decls) + +(** Print reactive collection update statistics *) +let print_stats ~(t : t) : unit = + let print name (c : _ Reactive.t) = + let s = Reactive.stats c in + Printf.eprintf " %s: recv=%d emit=%d len=%d\n" name s.updates_received + s.updates_emitted (Reactive.length c) + in + Printf.eprintf "ReactiveSolver collection stats:\n"; + print "dead_decls" t.dead_decls; + print "live_decls" t.live_decls; + print "dead_modules" t.dead_modules; + print "dead_decls_by_file" t.dead_decls_by_file; + print "issues_by_file" t.issues_by_file; + print "incorrect_dead_decls" t.incorrect_dead_decls; + print "dead_module_issues" t.dead_module_issues; + match t.value_refs_from with + | Some refs -> print "value_refs_from" refs + | None -> () diff --git a/analysis/reanalyze/src/ReactiveSolver.mli b/analysis/reanalyze/src/ReactiveSolver.mli index 9ae080bab8..0c5e5e1d0f 100644 --- a/analysis/reanalyze/src/ReactiveSolver.mli +++ b/analysis/reanalyze/src/ReactiveSolver.mli @@ -28,3 +28,6 @@ val is_pos_live : t:t -> Lexing.position -> bool val stats : t:t -> int * int (** (dead, live) counts *) + +val print_stats : t:t -> unit +(** Print update statistics for all reactive collections *) diff --git a/analysis/reanalyze/src/Reanalyze.ml b/analysis/reanalyze/src/Reanalyze.ml index 0b72b03d50..62f451c092 100644 --- a/analysis/reanalyze/src/Reanalyze.ml +++ b/analysis/reanalyze/src/Reanalyze.ml @@ -408,13 +408,14 @@ let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge let t2 = Unix.gettimeofday () in let all_issues = dead_code_issues @ optional_args_issues in let num_dead, num_live = ReactiveSolver.stats ~t:solver in - if !Cli.timing then + if !Cli.timing then ( Printf.eprintf " ReactiveSolver: dead_code=%.3fms opt_args=%.3fms (dead=%d, \ live=%d, issues=%d)\n" ((t1 -. t0) *. 1000.0) ((t2 -. t1) *. 1000.0) num_dead num_live (List.length all_issues); + ReactiveSolver.print_stats ~t:solver); Some (AnalysisResult.add_issues AnalysisResult.empty all_issues) | None -> (* Non-reactive path: use old solver with optional args *) From 0f96d10621e6ee87d72567e8d1034914f0aec584 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 16 Dec 2025 22:07:54 +0100 Subject: [PATCH 30/45] Add stats printing for ReactiveLiveness collections Print roots, edges, and live (fixpoint) stats in timing mode. Example output (benchmark, cold start): ReactiveLiveness collection stats: roots: recv=156200 emit=156200 len=16203 edges: recv=445363 emit=445363 len=35550 live (fixpoint): recv=601563 emit=107025 len=31903 Key insights: - fixpoint receives 601,563 updates (roots + edges combined) - fixpoint emits 107,025 updates (BFS expansion deltas) - 31,903 positions are live (17,500 decls + type positions) - On cache hit (run 2), counters stay same = zero new work --- analysis/reanalyze/src/ReactiveLiveness.ml | 12 ++++++++++++ analysis/reanalyze/src/ReactiveLiveness.mli | 3 +++ analysis/reanalyze/src/Reanalyze.ml | 5 ++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/analysis/reanalyze/src/ReactiveLiveness.ml b/analysis/reanalyze/src/ReactiveLiveness.ml index 88cc422d83..e4ad3be69f 100644 --- a/analysis/reanalyze/src/ReactiveLiveness.ml +++ b/analysis/reanalyze/src/ReactiveLiveness.ml @@ -113,3 +113,15 @@ let create ~(merged : ReactiveMerge.t) : t = (* Step 4: Compute fixpoint - all reachable positions from roots *) let live = Reactive.fixpoint ~init:all_roots ~edges () in {live; edges; roots = all_roots} + +(** Print reactive collection update statistics *) +let print_stats ~(t : t) : unit = + let print name (c : _ Reactive.t) = + let s = Reactive.stats c in + Printf.eprintf " %s: recv=%d emit=%d len=%d\n" name s.updates_received + s.updates_emitted (Reactive.length c) + in + Printf.eprintf "ReactiveLiveness collection stats:\n"; + print "roots" t.roots; + print "edges" t.edges; + print "live (fixpoint)" t.live diff --git a/analysis/reanalyze/src/ReactiveLiveness.mli b/analysis/reanalyze/src/ReactiveLiveness.mli index 9e95ed0707..e0b5fcf53a 100644 --- a/analysis/reanalyze/src/ReactiveLiveness.mli +++ b/analysis/reanalyze/src/ReactiveLiveness.mli @@ -17,3 +17,6 @@ val create : merged:ReactiveMerge.t -> t - roots: initial live positions (annotated + externally referenced) Updates automatically when any input changes. *) + +val print_stats : t:t -> unit +(** Print update statistics for liveness collections (roots, edges, live fixpoint) *) diff --git a/analysis/reanalyze/src/Reanalyze.ml b/analysis/reanalyze/src/Reanalyze.ml index 62f451c092..97ebcade5b 100644 --- a/analysis/reanalyze/src/Reanalyze.ml +++ b/analysis/reanalyze/src/Reanalyze.ml @@ -245,7 +245,7 @@ let shuffle_list lst = Array.to_list arr let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge - ~reactive_liveness:_ ~reactive_solver = + ~reactive_liveness ~reactive_solver = (* Map: process each file -> list of file_data *) let {dce_data_list; exception_results} = processCmtFiles ~config:dce_config ~cmtRoot ~reactive_collection @@ -415,6 +415,9 @@ let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge ((t1 -. t0) *. 1000.0) ((t2 -. t1) *. 1000.0) num_dead num_live (List.length all_issues); + (match reactive_liveness with + | Some liveness -> ReactiveLiveness.print_stats ~t:liveness + | None -> ()); ReactiveSolver.print_stats ~t:solver); Some (AnalysisResult.add_issues AnalysisResult.empty all_issues) | None -> From 2f6ef60fcea334d2d1ea32af6ed170eacfdbcdaa Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Wed, 17 Dec 2025 05:01:09 +0100 Subject: [PATCH 31/45] Add Batch delta type for efficient bulk updates Add a new Batch case to the delta type that allows sending multiple updates at once. This enables more efficient processing during bulk loading (e.g., when processing CMT files). Key changes: - Add Batch of ('k * 'v option) list to delta type - Add set/remove helper functions for batch entries - Update all combinators (flatMap, lookup, join, union, fixpoint) to handle Batch deltas - Process batch entries, deduplicate affected keys, emit as batch - Add tests for batch processing Batch processing uses hashtbl internally for deduplication but passes batches as lists between combinators. This matches the existing pattern in combinators and provides O(1) dedup. Example usage: emit (Batch [ Reactive.set "a" 1; Reactive.set "b" 2; Reactive.remove "c"; ]) --- analysis/reactive/src/Reactive.ml | 378 +++++++++++++++++++++---- analysis/reactive/src/Reactive.mli | 18 +- analysis/reactive/test/ReactiveTest.ml | 115 +++++++- 3 files changed, 446 insertions(+), 65 deletions(-) diff --git a/analysis/reactive/src/Reactive.ml b/analysis/reactive/src/Reactive.ml index 7ef02eeba0..1f950c5066 100644 --- a/analysis/reactive/src/Reactive.ml +++ b/analysis/reactive/src/Reactive.ml @@ -4,14 +4,35 @@ (** {1 Deltas} *) -type ('k, 'v) delta = Set of 'k * 'v | Remove of 'k +type ('k, 'v) delta = + | Set of 'k * 'v + | Remove of 'k + | Batch of ('k * 'v option) list + (** Batch of updates: (key, Some value) = set, (key, None) = remove *) + +(** Convenience constructors for batch *) +let set k v = (k, Some v) + +let remove k = (k, None) let apply_delta tbl = function | Set (k, v) -> Hashtbl.replace tbl k v | Remove k -> Hashtbl.remove tbl k + | Batch entries -> + entries + |> List.iter (fun (k, v_opt) -> + match v_opt with + | Some v -> Hashtbl.replace tbl k v + | None -> Hashtbl.remove tbl k) let apply_deltas tbl deltas = List.iter (apply_delta tbl) deltas +(** Convert single deltas to batch entries *) +let delta_to_entries = function + | Set (k, v) -> [(k, Some v)] + | Remove k -> [(k, None)] + | Batch entries -> entries + (** {1 Statistics} *) type stats = {mutable updates_received: int; mutable updates_emitted: int} @@ -114,27 +135,63 @@ let flatMap (source : ('k1, 'v1) t) ~f ?merge () : ('k2, 'v2) t = target_keys in + (* Convert delta to batch entry for output *) + let delta_to_batch_entry = function + | Set (k, v) -> (k, Some v) + | Remove k -> (k, None) + | Batch _ -> assert false (* not used for output conversion *) + in + + (* Process a single source entry, return affected target keys *) + let process_entry (k1, v1_opt) = + match v1_opt with + | None -> + (* Remove *) + remove_source k1 + | Some v1 -> + (* Set *) + let old_affected = remove_source k1 in + let new_entries = f k1 v1 in + let new_affected = add_source k1 new_entries in + old_affected @ new_affected + in + let handle_delta delta = my_stats.updates_received <- my_stats.updates_received + 1; - let downstream = - match delta with - | Remove k1 -> - let affected = remove_source k1 in - affected |> List.filter_map recompute_target - | Set (k1, v1) -> - let old_affected = remove_source k1 in - let new_entries = f k1 v1 in - let new_affected = add_source k1 new_entries in - let all_affected = old_affected @ new_affected in - let seen = Hashtbl.create (List.length all_affected) in + match delta with + | Remove k1 -> + let affected = remove_source k1 in + let downstream = affected |> List.filter_map recompute_target in + List.iter emit downstream + | Set (k1, v1) -> + let all_affected = process_entry (k1, Some v1) in + let seen = Hashtbl.create (List.length all_affected) in + let downstream = all_affected |> List.filter_map (fun k2 -> if Hashtbl.mem seen k2 then None else ( Hashtbl.replace seen k2 (); recompute_target k2)) - in - List.iter emit downstream + in + List.iter emit downstream + | Batch entries -> + (* Process all entries, collect all affected keys *) + let all_affected = + entries |> List.concat_map (fun entry -> process_entry entry) + in + (* Deduplicate and recompute *) + let seen = Hashtbl.create (List.length all_affected) in + let downstream_entries = + all_affected + |> List.filter_map (fun k2 -> + if Hashtbl.mem seen k2 then None + else ( + Hashtbl.replace seen k2 (); + recompute_target k2 |> Option.map delta_to_batch_entry)) + in + (* Emit as batch if non-empty *) + if downstream_entries <> [] then emit (Batch downstream_entries) in (* Subscribe to future deltas *) @@ -169,6 +226,18 @@ let lookup (source : ('k, 'v) t) ~key : ('k, 'v) t = List.iter (fun h -> h delta) !subscribers in + let handle_entry (k, v_opt) = + if k = key then ( + match v_opt with + | Some v -> + Hashtbl.replace current key (Some v); + Some (key, Some v) + | None -> + Hashtbl.remove current key; + Some (key, None)) + else None + in + let handle_delta delta = my_stats.updates_received <- my_stats.updates_received + 1; match delta with @@ -178,6 +247,9 @@ let lookup (source : ('k, 'v) t) ~key : ('k, 'v) t = | Remove k when k = key -> Hashtbl.remove current key; emit (Remove key) + | Batch entries -> + let relevant = entries |> List.filter_map handle_entry in + if relevant <> [] then emit (Batch relevant) | _ -> () (* Ignore deltas for other keys *) in @@ -350,34 +422,89 @@ let join (left : ('k1, 'v1) t) (right : ('k2, 'v2) t) affected |> List.filter_map recompute_target in + (* Convert delta to batch entry for output *) + let delta_to_batch_entry = function + | Set (k, v) -> (k, Some v) + | Remove k -> (k, None) + | Batch _ -> assert false + in + + (* Process a single left entry, return list of output deltas *) + let process_left_update (k1, v1_opt) = + match v1_opt with + | Some v1 -> + Hashtbl.replace left_entries k1 v1; + process_left_entry k1 v1 + | None -> remove_left_entry k1 + in + + (* Process a right key change, return list of output deltas *) + let process_right_key k2 = + match Hashtbl.find_opt right_key_to_left_keys k2 with + | None -> [] + | Some left_keys -> + left_keys + |> List.concat_map (fun k1 -> + match Hashtbl.find_opt left_entries k1 with + | Some v1 -> process_left_entry k1 v1 + | None -> []) + in + let handle_left_delta delta = my_stats.updates_received <- my_stats.updates_received + 1; - let downstream = - match delta with - | Set (k1, v1) -> - Hashtbl.replace left_entries k1 v1; - process_left_entry k1 v1 - | Remove k1 -> remove_left_entry k1 - in - List.iter emit downstream + match delta with + | Set (k1, v1) -> + Hashtbl.replace left_entries k1 v1; + let downstream = process_left_entry k1 v1 in + List.iter emit downstream + | Remove k1 -> + let downstream = remove_left_entry k1 in + List.iter emit downstream + | Batch entries -> + (* Process all left entries, collect all affected output keys *) + let all_downstream = entries |> List.concat_map process_left_update in + (* Deduplicate *) + let seen = Hashtbl.create (List.length all_downstream) in + let downstream_entries = + all_downstream + |> List.filter_map (fun d -> + let entry = delta_to_batch_entry d in + let k = fst entry in + if Hashtbl.mem seen k then None + else ( + Hashtbl.replace seen k (); + Some entry)) + in + if downstream_entries <> [] then emit (Batch downstream_entries) in let handle_right_delta delta = my_stats.updates_received <- my_stats.updates_received + 1; - (* When right changes, reprocess all left entries that depend on it *) - let downstream = - match delta with - | Set (k2, _) | Remove k2 -> ( - match Hashtbl.find_opt right_key_to_left_keys k2 with - | None -> [] - | Some left_keys -> - left_keys - |> List.concat_map (fun k1 -> - match Hashtbl.find_opt left_entries k1 with - | Some v1 -> process_left_entry k1 v1 - | None -> [])) - in - List.iter emit downstream + match delta with + | Set (k2, _) | Remove k2 -> + let downstream = process_right_key k2 in + List.iter emit downstream + | Batch entries -> + (* Collect all affected right keys, then process *) + let right_keys = + entries + |> List.map (fun (k, _) -> k) + |> List.sort_uniq compare + in + let all_downstream = right_keys |> List.concat_map process_right_key in + (* Deduplicate *) + let seen = Hashtbl.create (List.length all_downstream) in + let downstream_entries = + all_downstream + |> List.filter_map (fun d -> + let entry = delta_to_batch_entry d in + let k = fst entry in + if Hashtbl.mem seen k then None + else ( + Hashtbl.replace seen k (); + Some entry)) + in + if downstream_entries <> [] then emit (Batch downstream_entries) in (* Subscribe to both sources *) @@ -437,32 +564,81 @@ let union (left : ('k, 'v) t) (right : ('k, 'v) t) ?merge () : ('k, 'v) t = Some (Set (k, merged)) in + (* Convert delta to batch entry *) + let delta_to_batch_entry = function + | Set (k, v) -> (k, Some v) + | Remove k -> (k, None) + | Batch _ -> assert false + in + + (* Process a left entry, return affected key *) + let process_left_entry (k, v_opt) = + (match v_opt with + | Some v -> Hashtbl.replace left_values k v + | None -> Hashtbl.remove left_values k); + k + in + + (* Process a right entry, return affected key *) + let process_right_entry (k, v_opt) = + (match v_opt with + | Some v -> Hashtbl.replace right_values k v + | None -> Hashtbl.remove right_values k); + k + in + let handle_left_delta delta = my_stats.updates_received <- my_stats.updates_received + 1; - let downstream = - match delta with - | Set (k, v) -> - Hashtbl.replace left_values k v; - recompute_key k |> Option.to_list - | Remove k -> - Hashtbl.remove left_values k; - recompute_key k |> Option.to_list - in - List.iter emit downstream + match delta with + | Set (k, v) -> + Hashtbl.replace left_values k v; + let downstream = recompute_key k |> Option.to_list in + List.iter emit downstream + | Remove k -> + Hashtbl.remove left_values k; + let downstream = recompute_key k |> Option.to_list in + List.iter emit downstream + | Batch entries -> + (* Process all entries, collect affected keys *) + let affected_keys = entries |> List.map process_left_entry in + (* Deduplicate keys *) + let seen = Hashtbl.create (List.length affected_keys) in + let downstream_entries = + affected_keys + |> List.filter_map (fun k -> + if Hashtbl.mem seen k then None + else ( + Hashtbl.replace seen k (); + recompute_key k |> Option.map delta_to_batch_entry)) + in + if downstream_entries <> [] then emit (Batch downstream_entries) in let handle_right_delta delta = my_stats.updates_received <- my_stats.updates_received + 1; - let downstream = - match delta with - | Set (k, v) -> - Hashtbl.replace right_values k v; - recompute_key k |> Option.to_list - | Remove k -> - Hashtbl.remove right_values k; - recompute_key k |> Option.to_list - in - List.iter emit downstream + match delta with + | Set (k, v) -> + Hashtbl.replace right_values k v; + let downstream = recompute_key k |> Option.to_list in + List.iter emit downstream + | Remove k -> + Hashtbl.remove right_values k; + let downstream = recompute_key k |> Option.to_list in + List.iter emit downstream + | Batch entries -> + (* Process all entries, collect affected keys *) + let affected_keys = entries |> List.map process_right_entry in + (* Deduplicate keys *) + let seen = Hashtbl.create (List.length affected_keys) in + let downstream_entries = + affected_keys + |> List.filter_map (fun k -> + if Hashtbl.mem seen k then None + else ( + Hashtbl.replace seen k (); + recompute_key k |> Option.map delta_to_batch_entry)) + in + if downstream_entries <> [] then emit (Batch downstream_entries) in (* Subscribe to both sources *) @@ -722,6 +898,9 @@ module Fixpoint = struct in (net_added, net_removed)) + | Batch _ -> + (* Batch is handled at a higher level in handle_init_delta *) + ([], []) (* Compute edge diff between old and new successors *) let compute_edge_diff old_succs new_succs = @@ -862,6 +1041,9 @@ module Fixpoint = struct in (net_added, net_removed) + | Batch _ -> + (* Batch is handled at a higher level in handle_edges_delta *) + ([], []) end (** Compute transitive closure via incremental fixpoint. @@ -887,18 +1069,94 @@ let fixpoint ~(init : ('k, unit) t) ~(edges : ('k, 'k list) t) () : ('k, unit) t List.iter (fun k -> emit (Remove k)) removed in + (* Emit changes as a batch *) + let emit_changes_batch (added, removed) = + let batch_entries = + List.map (fun k -> (k, Some ())) added + @ List.map (fun k -> (k, None)) removed + in + if batch_entries <> [] then emit (Batch batch_entries) + in + (* Handle init deltas *) let handle_init_delta delta = my_stats.updates_received <- my_stats.updates_received + 1; - let changes = Fixpoint.apply_init_delta state delta in - emit_changes changes + match delta with + | Batch entries -> + (* Process all init entries as a batch *) + let all_added = ref [] in + let all_removed = ref [] in + entries + |> List.iter (fun (k, v_opt) -> + let d = + match v_opt with + | Some () -> Set (k, ()) + | None -> Remove k + in + let added, removed = Fixpoint.apply_init_delta state d in + all_added := added @ !all_added; + all_removed := removed @ !all_removed); + (* Deduplicate and emit as batch *) + let added_set = Hashtbl.create (List.length !all_added) in + List.iter (fun k -> Hashtbl.replace added_set k ()) !all_added; + let removed_set = Hashtbl.create (List.length !all_removed) in + List.iter (fun k -> Hashtbl.replace removed_set k ()) !all_removed; + (* Net changes: added if in added_set but not removed_set, etc. *) + let net_added = + Hashtbl.fold + (fun k () acc -> + if Hashtbl.mem removed_set k then acc else k :: acc) + added_set [] + in + let net_removed = + Hashtbl.fold + (fun k () acc -> if Hashtbl.mem added_set k then acc else k :: acc) + removed_set [] + in + emit_changes_batch (net_added, net_removed) + | _ -> + let changes = Fixpoint.apply_init_delta state delta in + emit_changes changes in (* Handle edges deltas *) let handle_edges_delta delta = my_stats.updates_received <- my_stats.updates_received + 1; - let changes = Fixpoint.apply_edges_delta state delta in - emit_changes changes + match delta with + | Batch entries -> + (* Process all edge entries as a batch *) + let all_added = ref [] in + let all_removed = ref [] in + entries + |> List.iter (fun (k, v_opt) -> + let d = + match v_opt with + | Some succs -> Set (k, succs) + | None -> Remove k + in + let added, removed = Fixpoint.apply_edges_delta state d in + all_added := added @ !all_added; + all_removed := removed @ !all_removed); + (* Deduplicate and emit as batch *) + let added_set = Hashtbl.create (List.length !all_added) in + List.iter (fun k -> Hashtbl.replace added_set k ()) !all_added; + let removed_set = Hashtbl.create (List.length !all_removed) in + List.iter (fun k -> Hashtbl.replace removed_set k ()) !all_removed; + let net_added = + Hashtbl.fold + (fun k () acc -> + if Hashtbl.mem removed_set k then acc else k :: acc) + added_set [] + in + let net_removed = + Hashtbl.fold + (fun k () acc -> if Hashtbl.mem added_set k then acc else k :: acc) + removed_set [] + in + emit_changes_batch (net_added, net_removed) + | _ -> + let changes = Fixpoint.apply_edges_delta state delta in + emit_changes changes in (* Subscribe to changes *) diff --git a/analysis/reactive/src/Reactive.mli b/analysis/reactive/src/Reactive.mli index a6e75c49b6..8ca2a9d352 100644 --- a/analysis/reactive/src/Reactive.mli +++ b/analysis/reactive/src/Reactive.mli @@ -29,11 +29,27 @@ (** {1 Deltas} *) -type ('k, 'v) delta = Set of 'k * 'v | Remove of 'k +type ('k, 'v) delta = + | Set of 'k * 'v + | Remove of 'k + | Batch of ('k * 'v option) list + (** Batch of updates: (key, Some value) = set, (key, None) = remove. + Batches are processed atomically and emitted as batches downstream. *) + +(** Convenience constructors for batch entries *) + +val set : 'k -> 'v -> 'k * 'v option +(** [set k v] creates a batch entry that sets key [k] to value [v] *) + +val remove : 'k -> 'k * 'v option +(** [remove k] creates a batch entry that removes key [k] *) val apply_delta : ('k, 'v) Hashtbl.t -> ('k, 'v) delta -> unit val apply_deltas : ('k, 'v) Hashtbl.t -> ('k, 'v) delta list -> unit +val delta_to_entries : ('k, 'v) delta -> ('k * 'v option) list +(** Convert any delta to batch entry format *) + (** {1 Statistics} *) type stats = { diff --git a/analysis/reactive/test/ReactiveTest.ml b/analysis/reactive/test/ReactiveTest.ml index 0abae67204..5f95ef3bc6 100644 --- a/analysis/reactive/test/ReactiveTest.ml +++ b/analysis/reactive/test/ReactiveTest.ml @@ -947,7 +947,13 @@ let test_fixpoint_add_base () = let removed = ref [] in fp.subscribe (function | Set (k, ()) -> added := k :: !added - | Remove k -> removed := k :: !removed); + | Remove k -> removed := k :: !removed + | Batch entries -> + entries + |> List.iter (fun (k, v_opt) -> + match v_opt with + | Some () -> added := k :: !added + | None -> removed := k :: !removed)); emit_init (Set ("c", ())); @@ -1216,7 +1222,13 @@ let test_fixpoint_remove_spurious_root () = let removed = ref [] in fp.subscribe (function | Set (k, ()) -> added := k :: !added - | Remove k -> removed := k :: !removed); + | Remove k -> removed := k :: !removed + | Batch entries -> + entries + |> List.iter (fun (k, v_opt) -> + match v_opt with + | Some () -> added := k :: !added + | None -> removed := k :: !removed)); (* Step 1: "b" is spuriously marked as a root (in the real bug, this happens when a reference arrives before its declaration) *) @@ -1366,7 +1378,13 @@ let test_fixpoint_remove_edge_rederivation () = let added = ref [] in fp.subscribe (function | Remove k -> removed := k :: !removed - | Set (k, ()) -> added := k :: !added); + | Set (k, ()) -> added := k :: !added + | Batch entries -> + entries + |> List.iter (fun (k, v_opt) -> + match v_opt with + | Some () -> added := k :: !added + | None -> removed := k :: !removed)); (* Add root *) emit_init (Set ("root", ())); @@ -1520,7 +1538,13 @@ let test_fixpoint_remove_edge_entry_higher_rank_support () = let added = ref [] in fp.subscribe (function | Remove k -> removed := k :: !removed - | Set (k, ()) -> added := k :: !added); + | Set (k, ()) -> added := k :: !added + | Batch entries -> + entries + |> List.iter (fun (k, v_opt) -> + match v_opt with + | Some () -> added := k :: !added + | None -> removed := k :: !removed)); (* Add root *) emit_init (Set ("root", ())); @@ -1714,6 +1738,86 @@ let test_fixpoint_existing_data () = Printf.printf "PASSED\n\n" +(* ==================== Batch Tests ==================== *) + +let test_batch_flatmap () = + Printf.printf "=== Test: batch flatmap ===\n"; + + let source, emit, _ = create_mutable_collection () in + let derived = + Reactive.flatMap source ~f:(fun k v -> [(k ^ "_derived", v * 2)]) () + in + + (* Subscribe to track what comes out *) + let received_batches = ref 0 in + let received_entries = ref [] in + derived.subscribe (function + | Batch entries -> + incr received_batches; + received_entries := entries @ !received_entries + | Set (k, v) -> received_entries := [(k, Some v)] @ !received_entries + | Remove k -> received_entries := [(k, None)] @ !received_entries); + + (* Send a batch *) + emit + (Batch + [ + Reactive.set "a" 1; + Reactive.set "b" 2; + Reactive.set "c" 3; + ]); + + Printf.printf "Received batches: %d, entries: %d\n" !received_batches + (List.length !received_entries); + assert (!received_batches = 1); + assert (List.length !received_entries = 3); + assert (Reactive.get derived "a_derived" = Some 2); + assert (Reactive.get derived "b_derived" = Some 4); + assert (Reactive.get derived "c_derived" = Some 6); + + Printf.printf "PASSED\n\n" + +let test_batch_fixpoint () = + Printf.printf "=== Test: batch fixpoint ===\n"; + + let init, emit_init, _ = create_mutable_collection () in + let edges, emit_edges, _ = create_mutable_collection () in + + let fp = Reactive.fixpoint ~init ~edges () in + + (* Track batches received *) + let batch_count = ref 0 in + let total_added = ref 0 in + fp.subscribe (function + | Batch entries -> + incr batch_count; + entries + |> List.iter (fun (_, v_opt) -> + match v_opt with + | Some () -> incr total_added + | None -> ()) + | Set (_, ()) -> incr total_added + | Remove _ -> ()); + + (* Set up edges first *) + emit_edges (Set ("a", ["b"; "c"])); + emit_edges (Set ("b", ["d"])); + + (* Send batch of roots *) + emit_init (Batch [Reactive.set "a" (); Reactive.set "x" ()]); + + Printf.printf "Batch count: %d, total added: %d\n" !batch_count !total_added; + Printf.printf "fp length: %d\n" (Reactive.length fp); + (* Should have a, b, c, d (reachable from a) and x (standalone root) *) + assert (Reactive.length fp = 5); + assert (Reactive.get fp "a" = Some ()); + assert (Reactive.get fp "b" = Some ()); + assert (Reactive.get fp "c" = Some ()); + assert (Reactive.get fp "d" = Some ()); + assert (Reactive.get fp "x" = Some ()); + + Printf.printf "PASSED\n\n" + let () = Printf.printf "\n====== Reactive Collection Tests ======\n\n"; test_flatmap_basic (); @@ -1750,4 +1854,7 @@ let () = test_fixpoint_remove_edge_entry_higher_rank_support (); test_fixpoint_remove_edge_entry_needs_rederivation (); test_fixpoint_remove_base_needs_rederivation (); + (* Batch tests *) + test_batch_flatmap (); + test_batch_fixpoint (); Printf.printf "All tests passed!\n" From ed29f4d75b218477f61f5cc8e42e875f9643ac81 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Wed, 17 Dec 2025 05:03:56 +0100 Subject: [PATCH 32/45] Use batch processing for ReactiveFileCollection Process all files as a single Batch delta instead of individual deltas. This dramatically reduces the number of updates flowing through reactive combinators during bulk loading. Key changes: - Add process_files_batch to ReactiveFileCollection (emits single Batch) - Add process_file_silent helper (processes without emitting) - Update ReactiveAnalysis.process_files to use batch processing Performance improvement on deadcode-benchmark: - Before: fixpoint recv=601,563 updates - After: fixpoint recv=13 updates (batches) This means downstream combinators process all file data together instead of handling 600K+ individual updates one at a time. --- analysis/reactive/src/Reactive.ml | 10 ++---- .../reactive/src/ReactiveFileCollection.ml | 23 ++++++++++++- .../reactive/src/ReactiveFileCollection.mli | 8 ++++- analysis/reactive/test/ReactiveTest.ml | 8 +---- analysis/reanalyze/src/ReactiveAnalysis.ml | 34 +++++++++---------- 5 files changed, 50 insertions(+), 33 deletions(-) diff --git a/analysis/reactive/src/Reactive.ml b/analysis/reactive/src/Reactive.ml index 1f950c5066..f72825d2d8 100644 --- a/analysis/reactive/src/Reactive.ml +++ b/analysis/reactive/src/Reactive.ml @@ -487,9 +487,7 @@ let join (left : ('k1, 'v1) t) (right : ('k2, 'v2) t) | Batch entries -> (* Collect all affected right keys, then process *) let right_keys = - entries - |> List.map (fun (k, _) -> k) - |> List.sort_uniq compare + entries |> List.map (fun (k, _) -> k) |> List.sort_uniq compare in let all_downstream = right_keys |> List.concat_map process_right_key in (* Deduplicate *) @@ -1104,8 +1102,7 @@ let fixpoint ~(init : ('k, unit) t) ~(edges : ('k, 'k list) t) () : ('k, unit) t (* Net changes: added if in added_set but not removed_set, etc. *) let net_added = Hashtbl.fold - (fun k () acc -> - if Hashtbl.mem removed_set k then acc else k :: acc) + (fun k () acc -> if Hashtbl.mem removed_set k then acc else k :: acc) added_set [] in let net_removed = @@ -1144,8 +1141,7 @@ let fixpoint ~(init : ('k, unit) t) ~(edges : ('k, 'k list) t) () : ('k, unit) t List.iter (fun k -> Hashtbl.replace removed_set k ()) !all_removed; let net_added = Hashtbl.fold - (fun k () acc -> - if Hashtbl.mem removed_set k then acc else k :: acc) + (fun k () acc -> if Hashtbl.mem removed_set k then acc else k :: acc) added_set [] in let net_removed = diff --git a/analysis/reactive/src/ReactiveFileCollection.ml b/analysis/reactive/src/ReactiveFileCollection.ml index 41fd4f2b16..c995b5a598 100644 --- a/analysis/reactive/src/ReactiveFileCollection.ml +++ b/analysis/reactive/src/ReactiveFileCollection.ml @@ -70,10 +70,31 @@ let process_if_changed t path = emit t (Reactive.Set (path, value)); true (* changed *) -(** Process multiple files *) +(** Process multiple files (emits individual deltas) *) let process_files t paths = List.iter (fun path -> ignore (process_if_changed t path)) paths +(** Process a file without emitting. Returns batch entry if changed. *) +let process_file_silent t path = + let new_id = get_file_id path in + match Hashtbl.find_opt t.internal.cache path with + | Some (old_id, _) when not (file_changed ~old_id ~new_id) -> + None (* unchanged *) + | _ -> + let raw = t.internal.read_file path in + let value = t.internal.process path raw in + Hashtbl.replace t.internal.cache path (new_id, value); + Some (Reactive.set path value) + +(** Process multiple files and emit as a single batch. + More efficient than process_files when processing many files at once. *) +let process_files_batch t paths = + let entries = + paths |> List.filter_map (fun path -> process_file_silent t path) + in + if entries <> [] then emit t (Reactive.Batch entries); + List.length entries + (** Remove a file *) let remove t path = Hashtbl.remove t.internal.cache path; diff --git a/analysis/reactive/src/ReactiveFileCollection.mli b/analysis/reactive/src/ReactiveFileCollection.mli index 95a0ca9ef8..571d7fb501 100644 --- a/analysis/reactive/src/ReactiveFileCollection.mli +++ b/analysis/reactive/src/ReactiveFileCollection.mli @@ -40,7 +40,13 @@ val to_collection : ('raw, 'v) t -> (string, 'v) Reactive.t (** {1 Processing} *) val process_files : ('raw, 'v) t -> string list -> unit -(** Process files, emitting deltas for changed files. *) +(** Process files, emitting individual deltas for each changed file. *) + +val process_files_batch : ('raw, 'v) t -> string list -> int +(** Process files, emitting a single [Batch] delta with all changes. + Returns the number of files that changed. + More efficient than [process_files] when processing many files at once, + as downstream combinators can process all changes together. *) val process_if_changed : ('raw, 'v) t -> string -> bool (** Process a file if changed. Returns true if file was processed. *) diff --git a/analysis/reactive/test/ReactiveTest.ml b/analysis/reactive/test/ReactiveTest.ml index 5f95ef3bc6..aeb777cdd2 100644 --- a/analysis/reactive/test/ReactiveTest.ml +++ b/analysis/reactive/test/ReactiveTest.ml @@ -1759,13 +1759,7 @@ let test_batch_flatmap () = | Remove k -> received_entries := [(k, None)] @ !received_entries); (* Send a batch *) - emit - (Batch - [ - Reactive.set "a" 1; - Reactive.set "b" 2; - Reactive.set "c" 3; - ]); + emit (Batch [Reactive.set "a" 1; Reactive.set "b" 2; Reactive.set "c" 3]); Printf.printf "Received batches: %d, entries: %d\n" !received_batches (List.length !received_entries); diff --git a/analysis/reanalyze/src/ReactiveAnalysis.ml b/analysis/reanalyze/src/ReactiveAnalysis.ml index 962b173771..fb0b4056e0 100644 --- a/analysis/reanalyze/src/ReactiveAnalysis.ml +++ b/analysis/reanalyze/src/ReactiveAnalysis.ml @@ -74,27 +74,27 @@ let create ~config : t = process_cmt_infos ~config ~cmtFilePath:path cmt_infos) (** Process all files incrementally using ReactiveFileCollection. - First run processes all files. Subsequent runs only process changed files. *) + First run processes all files. Subsequent runs only process changed files. + Uses batch processing to emit all changes as a single Batch delta. *) let process_files ~(collection : t) ~config:_ cmtFilePaths : all_files_result = Timing.time_phase `FileLoading (fun () -> - let processed = ref 0 in - let from_cache = ref 0 in - - (* Add/update all files in the collection *) - cmtFilePaths - |> List.iter (fun cmtFilePath -> - let was_in_collection = - ReactiveFileCollection.mem collection cmtFilePath - in - let changed = - ReactiveFileCollection.process_if_changed collection cmtFilePath - in - if changed then incr processed - else if was_in_collection then incr from_cache); + let total_files = List.length cmtFilePaths in + let cached_before = + cmtFilePaths + |> List.filter (fun p -> ReactiveFileCollection.mem collection p) + |> List.length + in + + (* Process all files as a batch - emits single Batch delta *) + let processed = + ReactiveFileCollection.process_files_batch collection cmtFilePaths + in + let from_cache = total_files - processed in if !Cli.timing then - Printf.eprintf "Reactive: %d files processed, %d from cache\n%!" - !processed !from_cache; + Printf.eprintf + "Reactive: %d files processed, %d from cache (was cached: %d)\n%!" + processed from_cache cached_before; (* Collect results from the collection *) let dce_data_list = ref [] in From 42d0efe4f06805cc2e70865ea176b0ee616bdf34 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Wed, 17 Dec 2025 05:30:26 +0100 Subject: [PATCH 33/45] Optimize fixpoint batch handling with single BFS expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of calling apply_init_delta (which runs BFS) for each entry in a batch, collect all new roots first and run a single BFS expansion. Before: Batch of N roots → N separate BFS operations After: Batch of N roots → 1 BFS with N-element frontier This matches how the fixpoint initialization already works. Note: The main CMT processing overhead (~2.5s) comes from other combinators (flatMaps, joins), not the fixpoint. The fixpoint only receives 13 batch updates vs 600K+ individual deltas before batching. --- analysis/reactive/src/Reactive.ml | 35 +++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/analysis/reactive/src/Reactive.ml b/analysis/reactive/src/Reactive.ml index f72825d2d8..3f3ddd7adf 100644 --- a/analysis/reactive/src/Reactive.ml +++ b/analysis/reactive/src/Reactive.ml @@ -1081,25 +1081,42 @@ let fixpoint ~(init : ('k, unit) t) ~(edges : ('k, 'k list) t) () : ('k, unit) t my_stats.updates_received <- my_stats.updates_received + 1; match delta with | Batch entries -> - (* Process all init entries as a batch *) - let all_added = ref [] in - let all_removed = ref [] in + (* OPTIMIZED: Process all entries with single BFS instead of per-entry BFS *) + (* Phase 1: Separate adds and removes, collect new roots *) + let new_roots = ref [] in + let removes = ref [] in entries |> List.iter (fun (k, v_opt) -> - let d = - match v_opt with - | Some () -> Set (k, ()) - | None -> Remove k + match v_opt with + | Some () -> + if not (Hashtbl.mem state.base k) then begin + Hashtbl.replace state.base k (); + new_roots := k :: !new_roots + end + | None -> removes := k :: !removes); + + (* Phase 2: Single BFS expansion from all new roots *) + let added_from_expansion = + if !new_roots <> [] then Fixpoint.expand state ~frontier:!new_roots + else [] + in + + (* Phase 3: Handle removes (may trigger contraction) *) + let all_added = ref added_from_expansion in + let all_removed = ref [] in + !removes + |> List.iter (fun k -> + let added, removed = + Fixpoint.apply_init_delta state (Remove k) in - let added, removed = Fixpoint.apply_init_delta state d in all_added := added @ !all_added; all_removed := removed @ !all_removed); + (* Deduplicate and emit as batch *) let added_set = Hashtbl.create (List.length !all_added) in List.iter (fun k -> Hashtbl.replace added_set k ()) !all_added; let removed_set = Hashtbl.create (List.length !all_removed) in List.iter (fun k -> Hashtbl.replace removed_set k ()) !all_removed; - (* Net changes: added if in added_set but not removed_set, etc. *) let net_added = Hashtbl.fold (fun k () acc -> if Hashtbl.mem removed_set k then acc else k :: acc) From aa1941e96377266c06cb3b9cee9a4397abba552c Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Wed, 17 Dec 2025 05:31:48 +0100 Subject: [PATCH 34/45] Revert "Optimize fixpoint batch handling with single BFS expansion" This reverts commit 42d0efe4f06805cc2e70865ea176b0ee616bdf34. --- analysis/reactive/src/Reactive.ml | 35 ++++++++----------------------- 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/analysis/reactive/src/Reactive.ml b/analysis/reactive/src/Reactive.ml index 3f3ddd7adf..f72825d2d8 100644 --- a/analysis/reactive/src/Reactive.ml +++ b/analysis/reactive/src/Reactive.ml @@ -1081,42 +1081,25 @@ let fixpoint ~(init : ('k, unit) t) ~(edges : ('k, 'k list) t) () : ('k, unit) t my_stats.updates_received <- my_stats.updates_received + 1; match delta with | Batch entries -> - (* OPTIMIZED: Process all entries with single BFS instead of per-entry BFS *) - (* Phase 1: Separate adds and removes, collect new roots *) - let new_roots = ref [] in - let removes = ref [] in + (* Process all init entries as a batch *) + let all_added = ref [] in + let all_removed = ref [] in entries |> List.iter (fun (k, v_opt) -> - match v_opt with - | Some () -> - if not (Hashtbl.mem state.base k) then begin - Hashtbl.replace state.base k (); - new_roots := k :: !new_roots - end - | None -> removes := k :: !removes); - - (* Phase 2: Single BFS expansion from all new roots *) - let added_from_expansion = - if !new_roots <> [] then Fixpoint.expand state ~frontier:!new_roots - else [] - in - - (* Phase 3: Handle removes (may trigger contraction) *) - let all_added = ref added_from_expansion in - let all_removed = ref [] in - !removes - |> List.iter (fun k -> - let added, removed = - Fixpoint.apply_init_delta state (Remove k) + let d = + match v_opt with + | Some () -> Set (k, ()) + | None -> Remove k in + let added, removed = Fixpoint.apply_init_delta state d in all_added := added @ !all_added; all_removed := removed @ !all_removed); - (* Deduplicate and emit as batch *) let added_set = Hashtbl.create (List.length !all_added) in List.iter (fun k -> Hashtbl.replace added_set k ()) !all_added; let removed_set = Hashtbl.create (List.length !all_removed) in List.iter (fun k -> Hashtbl.replace removed_set k ()) !all_removed; + (* Net changes: added if in added_set but not removed_set, etc. *) let net_added = Hashtbl.fold (fun k () acc -> if Hashtbl.mem removed_set k then acc else k :: acc) From d4092ed9d794ecb6184745c40a812fe453bc3e31 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Wed, 17 Dec 2025 10:48:32 +0100 Subject: [PATCH 35/45] Reactive: add refined stats tracking, remove V1, rename ReactiveV2 to Reactive - Add adds_received/removes_received to track input churn - Add adds_emitted/removes_emitted to track output changes - Add process_count (runs) to track how many times each node processes - Delete unused V1 API (Reactive.ml/mli) - Rename ReactiveV2 to Reactive throughout codebase - Split ReactiveTest.ml into themed test files - Add Makefile and README for analysis/reactive - Clean up README to remove V1/V2 references --- analysis/reactive/Makefile | 15 + analysis/reactive/README.md | 109 + analysis/reactive/src/Reactive.ml | 1740 ++++++++------- analysis/reactive/src/Reactive.mli | 273 +-- .../reactive/src/ReactiveFileCollection.ml | 42 +- .../reactive/src/ReactiveFileCollection.mli | 2 +- analysis/reactive/test/BatchTest.ml | 87 + analysis/reactive/test/FixpointBasicTest.ml | 212 ++ .../reactive/test/FixpointIncrementalTest.ml | 690 ++++++ analysis/reactive/test/FlatMapTest.ml | 166 ++ analysis/reactive/test/GlitchFreeTest.ml | 289 +++ analysis/reactive/test/IntegrationTest.ml | 85 + analysis/reactive/test/JoinTest.ml | 122 ++ analysis/reactive/test/ReactiveTest.ml | 1863 +---------------- analysis/reactive/test/TestHelpers.ml | 57 + analysis/reactive/test/UnionTest.ml | 136 ++ analysis/reactive/test/dune | 11 + analysis/reanalyze/ARCHITECTURE.md | 27 + analysis/reanalyze/src/Liveness.ml | 9 + analysis/reanalyze/src/ReactiveAnalysis.ml | 2 +- analysis/reanalyze/src/ReactiveDeclRefs.ml | 14 +- .../reanalyze/src/ReactiveExceptionRefs.ml | 7 +- analysis/reanalyze/src/ReactiveLiveness.ml | 33 +- analysis/reanalyze/src/ReactiveMerge.ml | 20 +- analysis/reanalyze/src/ReactiveSolver.ml | 40 +- analysis/reanalyze/src/ReactiveTypeDeps.ml | 28 +- 26 files changed, 3054 insertions(+), 3025 deletions(-) create mode 100644 analysis/reactive/Makefile create mode 100644 analysis/reactive/README.md create mode 100644 analysis/reactive/test/BatchTest.ml create mode 100644 analysis/reactive/test/FixpointBasicTest.ml create mode 100644 analysis/reactive/test/FixpointIncrementalTest.ml create mode 100644 analysis/reactive/test/FlatMapTest.ml create mode 100644 analysis/reactive/test/GlitchFreeTest.ml create mode 100644 analysis/reactive/test/IntegrationTest.ml create mode 100644 analysis/reactive/test/JoinTest.ml create mode 100644 analysis/reactive/test/TestHelpers.ml create mode 100644 analysis/reactive/test/UnionTest.ml diff --git a/analysis/reactive/Makefile b/analysis/reactive/Makefile new file mode 100644 index 0000000000..76e49736c7 --- /dev/null +++ b/analysis/reactive/Makefile @@ -0,0 +1,15 @@ +.PHONY: build test clean + +# Build the reactive library +build: + dune build src/ + +# Run all tests +test: + dune build test/ReactiveTest.exe + dune exec test/ReactiveTest.exe + +# Clean build artifacts +clean: + dune clean + diff --git a/analysis/reactive/README.md b/analysis/reactive/README.md new file mode 100644 index 0000000000..9f55e57eff --- /dev/null +++ b/analysis/reactive/README.md @@ -0,0 +1,109 @@ +# Reactive Collections Library + +A library for incremental computation using reactive collections with delta-based updates. + +## Overview + +This library provides composable reactive collections that automatically propagate changes through a computation graph. When source data changes, only the affected parts of derived collections are recomputed. + +### Key Features + +- **Delta-based updates**: Changes propagate as `Set`, `Remove`, or `Batch` deltas +- **Glitch-free semantics**: Topological scheduling ensures consistent updates +- **Composable combinators**: `flatMap`, `join`, `union`, `fixpoint` +- **Incremental fixpoint**: Efficient transitive closure with support for additions and removals + +## Usage + +```ocaml +open Reactive + +(* Create a source collection *) +let (files, emit) = source ~name:"files" () + +(* Derive collections with combinators *) +let decls = flatMap ~name:"decls" files + ~f:(fun _path data -> data.declarations) + () + +let refs = flatMap ~name:"refs" files + ~f:(fun _path data -> data.references) + ~merge:PosSet.union + () + +(* Join collections *) +let resolved = join ~name:"resolved" refs decls + ~key_of:(fun pos _ref -> pos) + ~f:(fun pos ref decl_opt -> ...) + () + +(* Compute transitive closure *) +let reachable = fixpoint ~name:"reachable" + ~init:roots + ~edges:graph + () + +(* Emit changes *) +emit (Set ("file.res", file_data)) +emit (Batch [set "a.res" data_a; set "b.res" data_b]) +``` + +## Combinators + +| Combinator | Description | +|------------|-------------| +| `source` | Create a mutable source collection | +| `flatMap` | Transform and flatten entries, with optional merge | +| `join` | Look up keys from left collection in right collection | +| `union` | Combine two collections, with optional merge for conflicts | +| `fixpoint` | Compute transitive closure incrementally | + +## Building & Testing + +```bash +# Build the library +make build + +# Run all tests +make test + +# Clean build artifacts +make clean +``` + +## Test Structure + +Tests are organized by theme: + +| File | Description | +|------|-------------| +| `FlatMapTest.ml` | FlatMap combinator tests | +| `JoinTest.ml` | Join combinator tests | +| `UnionTest.ml` | Union combinator tests | +| `FixpointBasicTest.ml` | Basic fixpoint graph traversal | +| `FixpointIncrementalTest.ml` | Incremental fixpoint updates | +| `BatchTest.ml` | Batch processing tests | +| `IntegrationTest.ml` | End-to-end file processing | +| `GlitchFreeTest.ml` | Glitch-free scheduler tests | + +## Glitch-Free Semantics + +The scheduler ensures that derived collections never see inconsistent intermediate states: + +1. **Topological levels**: Each node has a level based on its dependencies +2. **Accumulate phase**: All deltas at a level are collected before processing +3. **Propagate phase**: Nodes process accumulated deltas in level order + +This prevents issues like: +- Anti-joins seeing partial data (e.g., refs without matching decls) +- Multi-level unions causing spurious additions/removals + +## Usage in Reanalyze + +This library powers the reactive dead code analysis in reanalyze: + +- `ReactiveFileCollection`: Manages CMT file processing +- `ReactiveMerge`: Merges per-file data into global collections +- `ReactiveLiveness`: Computes live declarations via fixpoint +- `ReactiveSolver`: Generates dead code issues reactively + diff --git a/analysis/reactive/src/Reactive.ml b/analysis/reactive/src/Reactive.ml index f72825d2d8..202ef0315b 100644 --- a/analysis/reactive/src/Reactive.ml +++ b/analysis/reactive/src/Reactive.ml @@ -1,6 +1,11 @@ -(** Reactive collections for incremental computation. - - Provides composable reactive collections with delta-based updates. *) +(** Reactive V2: Accumulate-then-propagate scheduler for glitch-free semantics. + + Key design: + 1. Nodes accumulate batch deltas (don't process immediately) + 2. Scheduler visits nodes in dependency order + 3. Each node processes accumulated deltas exactly once per wave + + This eliminates glitches from multi-level dependencies. *) (** {1 Deltas} *) @@ -8,101 +13,366 @@ type ('k, 'v) delta = | Set of 'k * 'v | Remove of 'k | Batch of ('k * 'v option) list - (** Batch of updates: (key, Some value) = set, (key, None) = remove *) -(** Convenience constructors for batch *) let set k v = (k, Some v) - let remove k = (k, None) -let apply_delta tbl = function - | Set (k, v) -> Hashtbl.replace tbl k v - | Remove k -> Hashtbl.remove tbl k - | Batch entries -> - entries - |> List.iter (fun (k, v_opt) -> - match v_opt with - | Some v -> Hashtbl.replace tbl k v - | None -> Hashtbl.remove tbl k) - -let apply_deltas tbl deltas = List.iter (apply_delta tbl) deltas - -(** Convert single deltas to batch entries *) let delta_to_entries = function | Set (k, v) -> [(k, Some v)] | Remove k -> [(k, None)] | Batch entries -> entries +let merge_entries entries = + (* Deduplicate: later entries win *) + let tbl = Hashtbl.create (List.length entries) in + List.iter (fun (k, v) -> Hashtbl.replace tbl k v) entries; + Hashtbl.fold (fun k v acc -> (k, v) :: acc) tbl [] + +let count_adds_removes entries = + List.fold_left + (fun (adds, removes) (_, v) -> + match v with + | Some _ -> (adds + 1, removes) + | None -> (adds, removes + 1)) + (0, 0) entries + (** {1 Statistics} *) -type stats = {mutable updates_received: int; mutable updates_emitted: int} +type stats = { + (* Input tracking *) + mutable deltas_received: int; + (** Number of delta messages (Set/Remove/Batch) *) + mutable entries_received: int; (** Total entries after expanding batches *) + mutable adds_received: int; (** Set operations received from upstream *) + mutable removes_received: int; + (** Remove operations received from upstream *) + (* Processing tracking *) + mutable process_count: int; (** Times process() was called *) + mutable process_time_ns: int64; (** Total time in process() *) + (* Output tracking *) + mutable deltas_emitted: int; (** Number of delta messages emitted *) + mutable entries_emitted: int; (** Total entries in emitted deltas *) + mutable adds_emitted: int; (** Set operations emitted downstream *) + mutable removes_emitted: int; (** Remove operations emitted downstream *) +} -let create_stats () = {updates_received = 0; updates_emitted = 0} +let create_stats () = + { + deltas_received = 0; + entries_received = 0; + adds_received = 0; + removes_received = 0; + process_count = 0; + process_time_ns = 0L; + deltas_emitted = 0; + entries_emitted = 0; + adds_emitted = 0; + removes_emitted = 0; + } -(** {1 Reactive Collection} *) +(** Count adds and removes in a list of entries *) +let count_changes entries = + let adds = ref 0 in + let removes = ref 0 in + List.iter + (fun (_, v_opt) -> + match v_opt with + | Some _ -> incr adds + | None -> incr removes) + entries; + (!adds, !removes) + +(** {1 Node Registry} *) + +module Registry = struct + type node_info = { + name: string; + level: int; + mutable upstream: string list; + mutable downstream: string list; + mutable dirty: bool; + process: unit -> unit; (* Process accumulated deltas *) + stats: stats; + } + + let nodes : (string, node_info) Hashtbl.t = Hashtbl.create 64 + let dirty_nodes : string list ref = ref [] + + let register ~name ~level ~process ~stats = + let info = + { + name; + level; + upstream = []; + downstream = []; + dirty = false; + process; + stats; + } + in + Hashtbl.replace nodes name info; + info + + let add_edge ~from_name ~to_name = + (match Hashtbl.find_opt nodes from_name with + | Some info -> info.downstream <- to_name :: info.downstream + | None -> ()); + match Hashtbl.find_opt nodes to_name with + | Some info -> info.upstream <- from_name :: info.upstream + | None -> () + + let mark_dirty name = + match Hashtbl.find_opt nodes name with + | Some info when not info.dirty -> + info.dirty <- true; + dirty_nodes := name :: !dirty_nodes + | _ -> () + + let clear () = + Hashtbl.clear nodes; + dirty_nodes := [] + + (** Generate Mermaid diagram of the pipeline *) + let to_mermaid () = + let buf = Buffer.create 256 in + Buffer.add_string buf "graph TD\n"; + Hashtbl.iter + (fun name info -> + (* Node with level annotation *) + Buffer.add_string buf + (Printf.sprintf " %s[%s L%d]\n" name name info.level); + (* Edges *) + List.iter + (fun downstream -> + Buffer.add_string buf + (Printf.sprintf " %s --> %s\n" name downstream)) + info.downstream) + nodes; + Buffer.contents buf + + (** Print timing stats for all nodes *) + let print_stats () = + let all = Hashtbl.fold (fun _ info acc -> info :: acc) nodes [] in + let sorted = List.sort (fun a b -> compare a.level b.level) all in + Printf.eprintf "Node statistics:\n"; + Printf.eprintf " %-30s | %8s %8s %5s %5s | %8s %8s %5s %5s | %5s %8s\n" + "name" "d_recv" "e_recv" "+in" "-in" "d_emit" "e_emit" "+out" "-out" + "runs" "time_ms"; + Printf.eprintf " %s\n" (String.make 115 '-'); + List.iter + (fun info -> + let s = info.stats in + let time_ms = Int64.to_float s.process_time_ns /. 1e6 in + Printf.eprintf + " %-30s | %8d %8d %5d %5d | %8d %8d %5d %5d | %5d %8.2f\n" + (Printf.sprintf "%s (L%d)" info.name info.level) + s.deltas_received s.entries_received s.adds_received + s.removes_received s.deltas_emitted s.entries_emitted s.adds_emitted + s.removes_emitted s.process_count time_ms) + sorted +end + +(** {1 Scheduler} *) + +module Scheduler = struct + let propagating = ref false + let wave_counter = ref 0 + + let is_propagating () = !propagating + + (** Process all dirty nodes in level order *) + let propagate () = + if !propagating then + failwith "Scheduler.propagate: already propagating (nested call)" + else ( + propagating := true; + incr wave_counter; + + while !Registry.dirty_nodes <> [] do + (* Get all dirty nodes, sort by level *) + let dirty = !Registry.dirty_nodes in + Registry.dirty_nodes := []; + + let nodes_with_levels = + dirty + |> List.filter_map (fun name -> + match Hashtbl.find_opt Registry.nodes name with + | Some info -> Some (info.Registry.level, name, info) + | None -> None) + in + + let sorted = + List.sort + (fun (l1, _, _) (l2, _, _) -> compare l1 l2) + nodes_with_levels + in + + (* Find minimum level *) + match sorted with + | [] -> () + | (min_level, _, _) :: _ -> + (* Process all nodes at minimum level *) + let at_level, rest = + List.partition (fun (l, _, _) -> l = min_level) sorted + in + + (* Put remaining back in dirty list *) + List.iter + (fun (_, name, _) -> + Registry.dirty_nodes := name :: !Registry.dirty_nodes) + rest; + + (* Process nodes at this level *) + List.iter + (fun (_, _, info) -> + info.Registry.dirty <- false; + let start = Sys.time () in + info.Registry.process (); + let elapsed = Sys.time () -. start in + info.Registry.stats.process_time_ns <- + Int64.add info.Registry.stats.process_time_ns + (Int64.of_float (elapsed *. 1e9)); + info.Registry.stats.process_count <- + info.Registry.stats.process_count + 1) + at_level + done; + + propagating := false) + + let wave_count () = !wave_counter + let reset_wave_count () = wave_counter := 0 +end + +(** {1 Collection Interface} *) type ('k, 'v) t = { + name: string; subscribe: (('k, 'v) delta -> unit) -> unit; iter: ('k -> 'v -> unit) -> unit; get: 'k -> 'v option; length: unit -> int; stats: stats; + level: int; } -(** A reactive collection that can emit deltas and be read. - All collections share this interface, enabling composition. - [stats] tracks updates received/emitted for diagnostics. *) - -(** {1 Collection operations} *) let iter f t = t.iter f let get t k = t.get k let length t = t.length () let stats t = t.stats +let level t = t.level +let name t = t.name + +(** {1 Source Collection} *) + +let source ~name () = + let tbl = Hashtbl.create 64 in + let subscribers = ref [] in + let my_stats = create_stats () in + + (* Pending deltas to propagate *) + let pending = ref [] in + + let process () = + if !pending <> [] then ( + let entries = + !pending |> List.concat_map delta_to_entries |> merge_entries + in + pending := []; + if entries <> [] then ( + let num_adds, num_removes = count_changes entries in + my_stats.deltas_emitted <- my_stats.deltas_emitted + 1; + my_stats.entries_emitted <- + my_stats.entries_emitted + List.length entries; + my_stats.adds_emitted <- my_stats.adds_emitted + num_adds; + my_stats.removes_emitted <- my_stats.removes_emitted + num_removes; + let delta = Batch entries in + List.iter (fun h -> h delta) !subscribers)) + in + + let _info = Registry.register ~name ~level:0 ~process ~stats:my_stats in + + let collection = + { + name; + subscribe = (fun h -> subscribers := h :: !subscribers); + iter = (fun f -> Hashtbl.iter f tbl); + get = (fun k -> Hashtbl.find_opt tbl k); + length = (fun () -> Hashtbl.length tbl); + stats = my_stats; + level = 0; + } + in + + let emit delta = + (* Track input *) + my_stats.deltas_received <- my_stats.deltas_received + 1; + let entries = delta_to_entries delta in + my_stats.entries_received <- my_stats.entries_received + List.length entries; + let num_adds, num_removes = count_adds_removes entries in + my_stats.adds_received <- my_stats.adds_received + num_adds; + my_stats.removes_received <- my_stats.removes_received + num_removes; + + (* Apply to internal state immediately *) + (match delta with + | Set (k, v) -> Hashtbl.replace tbl k v + | Remove k -> Hashtbl.remove tbl k + | Batch entries -> + List.iter + (fun (k, v_opt) -> + match v_opt with + | Some v -> Hashtbl.replace tbl k v + | None -> Hashtbl.remove tbl k) + entries); + (* Accumulate for propagation *) + pending := delta :: !pending; + Registry.mark_dirty name; + (* If not in propagation, start one *) + if not (Scheduler.is_propagating ()) then Scheduler.propagate () + in + + (collection, emit) (** {1 FlatMap} *) -(** Transform a collection into another collection. - Each source entry maps to multiple target entries via [f]. - Optional [merge] combines values when multiple sources produce the same key. *) -let flatMap (source : ('k1, 'v1) t) ~f ?merge () : ('k2, 'v2) t = - let merge = +let flatMap ~name (src : ('k1, 'v1) t) ~f ?merge () : ('k2, 'v2) t = + let my_level = src.level + 1 in + let merge_fn = match merge with | Some m -> m | None -> fun _ v -> v in + (* Internal state *) let provenance : ('k1, 'k2 list) Hashtbl.t = Hashtbl.create 64 in let contributions : ('k2, ('k1, 'v2) Hashtbl.t) Hashtbl.t = Hashtbl.create 256 in let target : ('k2, 'v2) Hashtbl.t = Hashtbl.create 256 in - let subscribers : (('k2, 'v2) delta -> unit) list ref = ref [] in + let subscribers = ref [] in let my_stats = create_stats () in - let emit delta = - my_stats.updates_emitted <- my_stats.updates_emitted + 1; - List.iter (fun h -> h delta) !subscribers - in + (* Pending input deltas *) + let pending = ref [] in let recompute_target k2 = match Hashtbl.find_opt contributions k2 with | None -> Hashtbl.remove target k2; - Some (Remove k2) + Some (k2, None) | Some contribs when Hashtbl.length contribs = 0 -> Hashtbl.remove contributions k2; Hashtbl.remove target k2; - Some (Remove k2) + Some (k2, None) | Some contribs -> let values = Hashtbl.fold (fun _ v acc -> v :: acc) contribs [] in let merged = match values with | [] -> assert false | [v] -> v - | v :: rest -> List.fold_left merge v rest + | v :: rest -> List.fold_left merge_fn v rest in Hashtbl.replace target k2 merged; - Some (Set (k2, merged)) + Some (k2, Some merged) in let remove_source k1 = @@ -110,226 +380,155 @@ let flatMap (source : ('k1, 'v1) t) ~f ?merge () : ('k2, 'v2) t = | None -> [] | Some target_keys -> Hashtbl.remove provenance k1; - target_keys - |> List.iter (fun k2 -> - match Hashtbl.find_opt contributions k2 with - | None -> () - | Some contribs -> Hashtbl.remove contribs k1); + List.iter + (fun k2 -> + match Hashtbl.find_opt contributions k2 with + | None -> () + | Some contribs -> Hashtbl.remove contribs k1) + target_keys; target_keys in let add_source k1 entries = let target_keys = List.map fst entries in Hashtbl.replace provenance k1 target_keys; - entries - |> List.iter (fun (k2, v2) -> - let contribs = - match Hashtbl.find_opt contributions k2 with - | Some c -> c - | None -> - let c = Hashtbl.create 4 in - Hashtbl.replace contributions k2 c; - c - in - Hashtbl.replace contribs k1 v2); + List.iter + (fun (k2, v2) -> + let contribs = + match Hashtbl.find_opt contributions k2 with + | Some c -> c + | None -> + let c = Hashtbl.create 4 in + Hashtbl.replace contributions k2 c; + c + in + Hashtbl.replace contribs k1 v2) + entries; target_keys in - (* Convert delta to batch entry for output *) - let delta_to_batch_entry = function - | Set (k, v) -> (k, Some v) - | Remove k -> (k, None) - | Batch _ -> assert false (* not used for output conversion *) - in - - (* Process a single source entry, return affected target keys *) let process_entry (k1, v1_opt) = - match v1_opt with - | None -> - (* Remove *) - remove_source k1 - | Some v1 -> - (* Set *) - let old_affected = remove_source k1 in - let new_entries = f k1 v1 in - let new_affected = add_source k1 new_entries in - old_affected @ new_affected + let old_affected = remove_source k1 in + let new_affected = + match v1_opt with + | None -> [] + | Some v1 -> + let entries = f k1 v1 in + add_source k1 entries + in + let all_affected = old_affected @ new_affected in + (* Deduplicate *) + let seen = Hashtbl.create (List.length all_affected) in + List.filter_map + (fun k2 -> + if Hashtbl.mem seen k2 then None + else ( + Hashtbl.replace seen k2 (); + recompute_target k2)) + all_affected in - let handle_delta delta = - my_stats.updates_received <- my_stats.updates_received + 1; - match delta with - | Remove k1 -> - let affected = remove_source k1 in - let downstream = affected |> List.filter_map recompute_target in - List.iter emit downstream - | Set (k1, v1) -> - let all_affected = process_entry (k1, Some v1) in - let seen = Hashtbl.create (List.length all_affected) in - let downstream = - all_affected - |> List.filter_map (fun k2 -> - if Hashtbl.mem seen k2 then None - else ( - Hashtbl.replace seen k2 (); - recompute_target k2)) - in - List.iter emit downstream - | Batch entries -> - (* Process all entries, collect all affected keys *) - let all_affected = - entries |> List.concat_map (fun entry -> process_entry entry) - in - (* Deduplicate and recompute *) - let seen = Hashtbl.create (List.length all_affected) in - let downstream_entries = - all_affected - |> List.filter_map (fun k2 -> - if Hashtbl.mem seen k2 then None - else ( - Hashtbl.replace seen k2 (); - recompute_target k2 |> Option.map delta_to_batch_entry)) + let process () = + if !pending <> [] then ( + (* Track input deltas *) + my_stats.deltas_received <- + my_stats.deltas_received + List.length !pending; + let entries = + !pending |> List.concat_map delta_to_entries |> merge_entries in - (* Emit as batch if non-empty *) - if downstream_entries <> [] then emit (Batch downstream_entries) + pending := []; + my_stats.entries_received <- + my_stats.entries_received + List.length entries; + let in_adds, in_removes = count_adds_removes entries in + my_stats.adds_received <- my_stats.adds_received + in_adds; + my_stats.removes_received <- my_stats.removes_received + in_removes; + + let output_entries = entries |> List.concat_map process_entry in + if output_entries <> [] then ( + let num_adds, num_removes = count_changes output_entries in + my_stats.deltas_emitted <- my_stats.deltas_emitted + 1; + my_stats.entries_emitted <- + my_stats.entries_emitted + List.length output_entries; + my_stats.adds_emitted <- my_stats.adds_emitted + num_adds; + my_stats.removes_emitted <- my_stats.removes_emitted + num_removes; + let delta = Batch output_entries in + List.iter (fun h -> h delta) !subscribers)) in - (* Subscribe to future deltas *) - source.subscribe handle_delta; + let _info = + Registry.register ~name ~level:my_level ~process ~stats:my_stats + in + Registry.add_edge ~from_name:src.name ~to_name:name; - (* Populate from existing entries *) - source.iter (fun k v -> handle_delta (Set (k, v))); + (* Subscribe to source: just accumulate *) + src.subscribe (fun delta -> + pending := delta :: !pending; + Registry.mark_dirty name); + + (* Initialize from existing data *) + src.iter (fun k v -> + let entries = f k v in + let _ = add_source k entries in + List.iter + (fun (k2, v2) -> + let contribs = + match Hashtbl.find_opt contributions k2 with + | Some c -> c + | None -> + let c = Hashtbl.create 4 in + Hashtbl.replace contributions k2 c; + c + in + Hashtbl.replace contribs k v2; + Hashtbl.replace target k2 v2) + entries); - (* Return collection interface *) { - subscribe = (fun handler -> subscribers := handler :: !subscribers); + name; + subscribe = (fun h -> subscribers := h :: !subscribers); iter = (fun f -> Hashtbl.iter f target); get = (fun k -> Hashtbl.find_opt target k); length = (fun () -> Hashtbl.length target); stats = my_stats; - } - -(** {1 Lookup} *) - -(** Lookup a single key reactively. - Returns a collection with that single entry that updates when the - source's value at that key changes. - - This is useful for creating reactive subscriptions to specific keys. *) -let lookup (source : ('k, 'v) t) ~key : ('k, 'v) t = - let current : ('k, 'v option) Hashtbl.t = Hashtbl.create 1 in - let subscribers : (('k, 'v) delta -> unit) list ref = ref [] in - let my_stats = create_stats () in - - let emit delta = - my_stats.updates_emitted <- my_stats.updates_emitted + 1; - List.iter (fun h -> h delta) !subscribers - in - - let handle_entry (k, v_opt) = - if k = key then ( - match v_opt with - | Some v -> - Hashtbl.replace current key (Some v); - Some (key, Some v) - | None -> - Hashtbl.remove current key; - Some (key, None)) - else None - in - - let handle_delta delta = - my_stats.updates_received <- my_stats.updates_received + 1; - match delta with - | Set (k, v) when k = key -> - Hashtbl.replace current key (Some v); - emit (Set (key, v)) - | Remove k when k = key -> - Hashtbl.remove current key; - emit (Remove key) - | Batch entries -> - let relevant = entries |> List.filter_map handle_entry in - if relevant <> [] then emit (Batch relevant) - | _ -> () (* Ignore deltas for other keys *) - in - - (* Subscribe to source *) - source.subscribe handle_delta; - - (* Initialize with current value *) - (match source.get key with - | Some v -> Hashtbl.replace current key (Some v) - | None -> ()); - - { - subscribe = (fun handler -> subscribers := handler :: !subscribers); - iter = - (fun f -> - match Hashtbl.find_opt current key with - | Some (Some v) -> f key v - | _ -> ()); - get = - (fun k -> - if k = key then - match Hashtbl.find_opt current key with - | Some v -> v - | None -> None - else None); - length = - (fun () -> - match Hashtbl.find_opt current key with - | Some (Some _) -> 1 - | _ -> 0); - stats = my_stats; + level = my_level; } (** {1 Join} *) -(** Join two collections: for each entry in [left], look up a key in [right]. - - [key_of] extracts the lookup key from each left entry. - [f] combines left entry with looked-up right value (if present). - - When either collection changes, affected entries are recomputed. - This is more efficient than nested flatMap for join patterns. *) -let join (left : ('k1, 'v1) t) (right : ('k2, 'v2) t) - ~(key_of : 'k1 -> 'v1 -> 'k2) - ~(f : 'k1 -> 'v1 -> 'v2 option -> ('k3 * 'v3) list) ?merge () : ('k3, 'v3) t - = +let join ~name (left : ('k1, 'v1) t) (right : ('k2, 'v2) t) ~key_of ~f ?merge () + : ('k3, 'v3) t = + let my_level = max left.level right.level + 1 in let merge_fn = match merge with | Some m -> m | None -> fun _ v -> v in - (* Track: for each left key, which right key was looked up *) - let left_to_right_key : ('k1, 'k2) Hashtbl.t = Hashtbl.create 64 in - (* Track: for each right key, which left keys depend on it *) - let right_key_to_left_keys : ('k2, 'k1 list) Hashtbl.t = Hashtbl.create 64 in - (* Current left entries *) + + (* Internal state *) let left_entries : ('k1, 'v1) Hashtbl.t = Hashtbl.create 64 in - (* Provenance and contributions for output *) let provenance : ('k1, 'k3 list) Hashtbl.t = Hashtbl.create 64 in let contributions : ('k3, ('k1, 'v3) Hashtbl.t) Hashtbl.t = Hashtbl.create 256 in let target : ('k3, 'v3) Hashtbl.t = Hashtbl.create 256 in - let subscribers : (('k3, 'v3) delta -> unit) list ref = ref [] in + let left_to_right_key : ('k1, 'k2) Hashtbl.t = Hashtbl.create 64 in + let right_key_to_left_keys : ('k2, 'k1 list) Hashtbl.t = Hashtbl.create 64 in + let subscribers = ref [] in let my_stats = create_stats () in - let emit delta = - my_stats.updates_emitted <- my_stats.updates_emitted + 1; - List.iter (fun h -> h delta) !subscribers - in + (* Separate pending buffers for left and right *) + let left_pending = ref [] in + let right_pending = ref [] in let recompute_target k3 = match Hashtbl.find_opt contributions k3 with | None -> Hashtbl.remove target k3; - Some (Remove k3) + Some (k3, None) | Some contribs when Hashtbl.length contribs = 0 -> Hashtbl.remove contributions k3; Hashtbl.remove target k3; - Some (Remove k3) + Some (k3, None) | Some contribs -> let values = Hashtbl.fold (fun _ v acc -> v :: acc) contribs [] in let merged = @@ -339,7 +538,7 @@ let join (left : ('k1, 'v1) t) (right : ('k2, 'v2) t) | v :: rest -> List.fold_left merge_fn v rest in Hashtbl.replace target k3 merged; - Some (Set (k3, merged)) + Some (k3, Some merged) in let remove_left_contributions k1 = @@ -347,28 +546,30 @@ let join (left : ('k1, 'v1) t) (right : ('k2, 'v2) t) | None -> [] | Some target_keys -> Hashtbl.remove provenance k1; - target_keys - |> List.iter (fun k3 -> - match Hashtbl.find_opt contributions k3 with - | None -> () - | Some contribs -> Hashtbl.remove contribs k1); + List.iter + (fun k3 -> + match Hashtbl.find_opt contributions k3 with + | None -> () + | Some contribs -> Hashtbl.remove contribs k1) + target_keys; target_keys in let add_left_contributions k1 entries = let target_keys = List.map fst entries in Hashtbl.replace provenance k1 target_keys; - entries - |> List.iter (fun (k3, v3) -> - let contribs = - match Hashtbl.find_opt contributions k3 with - | Some c -> c - | None -> - let c = Hashtbl.create 4 in - Hashtbl.replace contributions k3 c; - c - in - Hashtbl.replace contribs k1 v3); + List.iter + (fun (k3, v3) -> + let contribs = + match Hashtbl.find_opt contributions k3 with + | Some c -> c + | None -> + let c = Hashtbl.create 4 in + Hashtbl.replace contributions k3 c; + c + in + Hashtbl.replace contribs k1 v3) + entries; target_keys in @@ -396,20 +597,12 @@ let join (left : ('k1, 'v1) t) (right : ('k2, 'v2) t) let right_val = right.get k2 in let new_entries = f k1 v1 right_val in let new_affected = add_left_contributions k1 new_entries in - let all_affected = old_affected @ new_affected in - let seen = Hashtbl.create (List.length all_affected) in - all_affected - |> List.filter_map (fun k3 -> - if Hashtbl.mem seen k3 then None - else ( - Hashtbl.replace seen k3 (); - recompute_target k3)) + old_affected @ new_affected in let remove_left_entry k1 = Hashtbl.remove left_entries k1; let affected = remove_left_contributions k1 in - (* Clean up tracking *) (match Hashtbl.find_opt left_to_right_key k1 with | Some k2 -> ( Hashtbl.remove left_to_right_key k1; @@ -419,769 +612,474 @@ let join (left : ('k1, 'v1) t) (right : ('k2, 'v2) t) (List.filter (fun k -> k <> k1) keys) | None -> ()) | None -> ()); - affected |> List.filter_map recompute_target + affected in - (* Convert delta to batch entry for output *) - let delta_to_batch_entry = function - | Set (k, v) -> (k, Some v) - | Remove k -> (k, None) - | Batch _ -> assert false - in + let process () = + (* Track input deltas *) + my_stats.deltas_received <- + my_stats.deltas_received + List.length !left_pending + + List.length !right_pending; - (* Process a single left entry, return list of output deltas *) - let process_left_update (k1, v1_opt) = - match v1_opt with - | Some v1 -> - Hashtbl.replace left_entries k1 v1; - process_left_entry k1 v1 - | None -> remove_left_entry k1 - in + (* Process both left and right pending *) + let left_entries_list = + !left_pending |> List.concat_map delta_to_entries |> merge_entries + in + let right_entries_list = + !right_pending |> List.concat_map delta_to_entries |> merge_entries + in + left_pending := []; + right_pending := []; + + my_stats.entries_received <- + my_stats.entries_received + + List.length left_entries_list + + List.length right_entries_list; + let left_adds, left_removes = count_adds_removes left_entries_list in + let right_adds, right_removes = count_adds_removes right_entries_list in + my_stats.adds_received <- my_stats.adds_received + left_adds + right_adds; + my_stats.removes_received <- + my_stats.removes_received + left_removes + right_removes; + + let all_affected = ref [] in + + (* Process left entries *) + List.iter + (fun (k1, v1_opt) -> + match v1_opt with + | Some v1 -> + Hashtbl.replace left_entries k1 v1; + let affected = process_left_entry k1 v1 in + all_affected := affected @ !all_affected + | None -> + let affected = remove_left_entry k1 in + all_affected := affected @ !all_affected) + left_entries_list; + + (* Process right entries: reprocess affected left entries *) + List.iter + (fun (k2, _) -> + match Hashtbl.find_opt right_key_to_left_keys k2 with + | None -> () + | Some left_keys -> + List.iter + (fun k1 -> + match Hashtbl.find_opt left_entries k1 with + | Some v1 -> + let affected = process_left_entry k1 v1 in + all_affected := affected @ !all_affected + | None -> ()) + left_keys) + right_entries_list; + + (* Deduplicate and compute outputs *) + let seen = Hashtbl.create (List.length !all_affected) in + let output_entries = + !all_affected + |> List.filter_map (fun k3 -> + if Hashtbl.mem seen k3 then None + else ( + Hashtbl.replace seen k3 (); + recompute_target k3)) + in - (* Process a right key change, return list of output deltas *) - let process_right_key k2 = - match Hashtbl.find_opt right_key_to_left_keys k2 with - | None -> [] - | Some left_keys -> - left_keys - |> List.concat_map (fun k1 -> - match Hashtbl.find_opt left_entries k1 with - | Some v1 -> process_left_entry k1 v1 - | None -> []) + if output_entries <> [] then ( + let num_adds, num_removes = count_changes output_entries in + my_stats.deltas_emitted <- my_stats.deltas_emitted + 1; + my_stats.entries_emitted <- + my_stats.entries_emitted + List.length output_entries; + my_stats.adds_emitted <- my_stats.adds_emitted + num_adds; + my_stats.removes_emitted <- my_stats.removes_emitted + num_removes; + let delta = Batch output_entries in + List.iter (fun h -> h delta) !subscribers) in - let handle_left_delta delta = - my_stats.updates_received <- my_stats.updates_received + 1; - match delta with - | Set (k1, v1) -> - Hashtbl.replace left_entries k1 v1; - let downstream = process_left_entry k1 v1 in - List.iter emit downstream - | Remove k1 -> - let downstream = remove_left_entry k1 in - List.iter emit downstream - | Batch entries -> - (* Process all left entries, collect all affected output keys *) - let all_downstream = entries |> List.concat_map process_left_update in - (* Deduplicate *) - let seen = Hashtbl.create (List.length all_downstream) in - let downstream_entries = - all_downstream - |> List.filter_map (fun d -> - let entry = delta_to_batch_entry d in - let k = fst entry in - if Hashtbl.mem seen k then None - else ( - Hashtbl.replace seen k (); - Some entry)) - in - if downstream_entries <> [] then emit (Batch downstream_entries) + let _info = + Registry.register ~name ~level:my_level ~process ~stats:my_stats in + Registry.add_edge ~from_name:left.name ~to_name:name; + Registry.add_edge ~from_name:right.name ~to_name:name; - let handle_right_delta delta = - my_stats.updates_received <- my_stats.updates_received + 1; - match delta with - | Set (k2, _) | Remove k2 -> - let downstream = process_right_key k2 in - List.iter emit downstream - | Batch entries -> - (* Collect all affected right keys, then process *) - let right_keys = - entries |> List.map (fun (k, _) -> k) |> List.sort_uniq compare - in - let all_downstream = right_keys |> List.concat_map process_right_key in - (* Deduplicate *) - let seen = Hashtbl.create (List.length all_downstream) in - let downstream_entries = - all_downstream - |> List.filter_map (fun d -> - let entry = delta_to_batch_entry d in - let k = fst entry in - if Hashtbl.mem seen k then None - else ( - Hashtbl.replace seen k (); - Some entry)) - in - if downstream_entries <> [] then emit (Batch downstream_entries) - in + (* Subscribe to sources: just accumulate *) + left.subscribe (fun delta -> + left_pending := delta :: !left_pending; + Registry.mark_dirty name); - (* Subscribe to both sources *) - left.subscribe handle_left_delta; - right.subscribe handle_right_delta; + right.subscribe (fun delta -> + right_pending := delta :: !right_pending; + Registry.mark_dirty name); - (* Initialize from existing entries *) + (* Initialize from existing data *) left.iter (fun k1 v1 -> Hashtbl.replace left_entries k1 v1; - let deltas = process_left_entry k1 v1 in - List.iter emit deltas); + let _ = process_left_entry k1 v1 in + ()); { - subscribe = (fun handler -> subscribers := handler :: !subscribers); + name; + subscribe = (fun h -> subscribers := h :: !subscribers); iter = (fun f -> Hashtbl.iter f target); get = (fun k -> Hashtbl.find_opt target k); length = (fun () -> Hashtbl.length target); stats = my_stats; + level = my_level; } (** {1 Union} *) -(** Combine two collections into one. - - Returns a collection containing all entries from both [left] and [right]. - When the same key exists in both, [merge] combines values (defaults to - preferring right). *) -let union (left : ('k, 'v) t) (right : ('k, 'v) t) ?merge () : ('k, 'v) t = +let union ~name (left : ('k, 'v) t) (right : ('k, 'v) t) ?merge () : ('k, 'v) t + = + let my_level = max left.level right.level + 1 in let merge_fn = match merge with | Some m -> m | None -> fun _ v -> v in - (* Track contributions from each side *) + + (* Internal state *) let left_values : ('k, 'v) Hashtbl.t = Hashtbl.create 64 in let right_values : ('k, 'v) Hashtbl.t = Hashtbl.create 64 in let target : ('k, 'v) Hashtbl.t = Hashtbl.create 128 in - let subscribers : (('k, 'v) delta -> unit) list ref = ref [] in + let subscribers = ref [] in let my_stats = create_stats () in - let emit delta = - my_stats.updates_emitted <- my_stats.updates_emitted + 1; - List.iter (fun h -> h delta) !subscribers - in + (* Separate pending buffers *) + let left_pending = ref [] in + let right_pending = ref [] in - let recompute_key k = + let recompute_target k = match (Hashtbl.find_opt left_values k, Hashtbl.find_opt right_values k) with | None, None -> Hashtbl.remove target k; - Some (Remove k) + Some (k, None) | Some v, None | None, Some v -> Hashtbl.replace target k v; - Some (Set (k, v)) - | Some v1, Some v2 -> - let merged = merge_fn v1 v2 in + Some (k, Some v) + | Some lv, Some rv -> + let merged = merge_fn lv rv in Hashtbl.replace target k merged; - Some (Set (k, merged)) + Some (k, Some merged) in - (* Convert delta to batch entry *) - let delta_to_batch_entry = function - | Set (k, v) -> (k, Some v) - | Remove k -> (k, None) - | Batch _ -> assert false - in + let process () = + (* Track input deltas *) + my_stats.deltas_received <- + my_stats.deltas_received + List.length !left_pending + + List.length !right_pending; - (* Process a left entry, return affected key *) - let process_left_entry (k, v_opt) = - (match v_opt with - | Some v -> Hashtbl.replace left_values k v - | None -> Hashtbl.remove left_values k); - k - in + let left_entries = + !left_pending |> List.concat_map delta_to_entries |> merge_entries + in + let right_entries = + !right_pending |> List.concat_map delta_to_entries |> merge_entries + in + left_pending := []; + right_pending := []; + + my_stats.entries_received <- + my_stats.entries_received + List.length left_entries + + List.length right_entries; + let left_adds, left_removes = count_adds_removes left_entries in + let right_adds, right_removes = count_adds_removes right_entries in + my_stats.adds_received <- my_stats.adds_received + left_adds + right_adds; + my_stats.removes_received <- + my_stats.removes_received + left_removes + right_removes; + + let all_affected = ref [] in + + (* Apply left entries *) + List.iter + (fun (k, v_opt) -> + (match v_opt with + | Some v -> Hashtbl.replace left_values k v + | None -> Hashtbl.remove left_values k); + all_affected := k :: !all_affected) + left_entries; + + (* Apply right entries *) + List.iter + (fun (k, v_opt) -> + (match v_opt with + | Some v -> Hashtbl.replace right_values k v + | None -> Hashtbl.remove right_values k); + all_affected := k :: !all_affected) + right_entries; + + (* Deduplicate and compute outputs *) + let seen = Hashtbl.create (List.length !all_affected) in + let output_entries = + !all_affected + |> List.filter_map (fun k -> + if Hashtbl.mem seen k then None + else ( + Hashtbl.replace seen k (); + recompute_target k)) + in - (* Process a right entry, return affected key *) - let process_right_entry (k, v_opt) = - (match v_opt with - | Some v -> Hashtbl.replace right_values k v - | None -> Hashtbl.remove right_values k); - k + if output_entries <> [] then ( + let num_adds, num_removes = count_changes output_entries in + my_stats.deltas_emitted <- my_stats.deltas_emitted + 1; + my_stats.entries_emitted <- + my_stats.entries_emitted + List.length output_entries; + my_stats.adds_emitted <- my_stats.adds_emitted + num_adds; + my_stats.removes_emitted <- my_stats.removes_emitted + num_removes; + let delta = Batch output_entries in + List.iter (fun h -> h delta) !subscribers) in - let handle_left_delta delta = - my_stats.updates_received <- my_stats.updates_received + 1; - match delta with - | Set (k, v) -> - Hashtbl.replace left_values k v; - let downstream = recompute_key k |> Option.to_list in - List.iter emit downstream - | Remove k -> - Hashtbl.remove left_values k; - let downstream = recompute_key k |> Option.to_list in - List.iter emit downstream - | Batch entries -> - (* Process all entries, collect affected keys *) - let affected_keys = entries |> List.map process_left_entry in - (* Deduplicate keys *) - let seen = Hashtbl.create (List.length affected_keys) in - let downstream_entries = - affected_keys - |> List.filter_map (fun k -> - if Hashtbl.mem seen k then None - else ( - Hashtbl.replace seen k (); - recompute_key k |> Option.map delta_to_batch_entry)) - in - if downstream_entries <> [] then emit (Batch downstream_entries) + let _info = + Registry.register ~name ~level:my_level ~process ~stats:my_stats in + Registry.add_edge ~from_name:left.name ~to_name:name; + Registry.add_edge ~from_name:right.name ~to_name:name; - let handle_right_delta delta = - my_stats.updates_received <- my_stats.updates_received + 1; - match delta with - | Set (k, v) -> - Hashtbl.replace right_values k v; - let downstream = recompute_key k |> Option.to_list in - List.iter emit downstream - | Remove k -> - Hashtbl.remove right_values k; - let downstream = recompute_key k |> Option.to_list in - List.iter emit downstream - | Batch entries -> - (* Process all entries, collect affected keys *) - let affected_keys = entries |> List.map process_right_entry in - (* Deduplicate keys *) - let seen = Hashtbl.create (List.length affected_keys) in - let downstream_entries = - affected_keys - |> List.filter_map (fun k -> - if Hashtbl.mem seen k then None - else ( - Hashtbl.replace seen k (); - recompute_key k |> Option.map delta_to_batch_entry)) - in - if downstream_entries <> [] then emit (Batch downstream_entries) - in + (* Subscribe to sources: just accumulate *) + left.subscribe (fun delta -> + left_pending := delta :: !left_pending; + Registry.mark_dirty name); - (* Subscribe to both sources *) - left.subscribe handle_left_delta; - right.subscribe handle_right_delta; + right.subscribe (fun delta -> + right_pending := delta :: !right_pending; + Registry.mark_dirty name); - (* Initialize from existing entries *) + (* Initialize from existing data - process left then right *) left.iter (fun k v -> Hashtbl.replace left_values k v; - ignore (recompute_key k)); + let merged = merge_fn v v in + (* self-merge for single value *) + Hashtbl.replace target k merged); right.iter (fun k v -> Hashtbl.replace right_values k v; - ignore (recompute_key k)); + (* Right takes precedence, but merge if left exists *) + let merged = + match Hashtbl.find_opt left_values k with + | Some lv -> merge_fn lv v + | None -> v + in + Hashtbl.replace target k merged); { - subscribe = (fun handler -> subscribers := handler :: !subscribers); + name; + subscribe = (fun h -> subscribers := h :: !subscribers); iter = (fun f -> Hashtbl.iter f target); get = (fun k -> Hashtbl.find_opt target k); length = (fun () -> Hashtbl.length target); stats = my_stats; + level = my_level; } (** {1 Fixpoint} *) -(** Incremental Fixpoint Computation. - - This implements the incremental fixpoint algorithm using: - - BFS for expansion (when base or edges grow) - - Well-founded derivation for contraction (when base or edges shrink) - - The fixpoint combinator maintains the least fixpoint of a monotone operator: - - F(S) = base ∪ step(S) - - where step(S) = ⋃{successors(x) | x ∈ S} - - Key insight: The rank of an element is its BFS distance from base. - Cycle members have equal ranks, so they cannot provide well-founded - support to each other, ensuring unreachable cycles are correctly removed. *) - -module Fixpoint = struct - type 'k state = { - current: ('k, unit) Hashtbl.t; (* Current fixpoint set *) - rank: ('k, int) Hashtbl.t; (* BFS distance from base *) - inv_index: ('k, 'k list) Hashtbl.t; - (* Inverse step relation: target → sources *) - base: ('k, unit) Hashtbl.t; (* Current base set *) - edges: ('k, 'k list) Hashtbl.t; (* Current edges snapshot *) - } - - let create () = - { - current = Hashtbl.create 256; - rank = Hashtbl.create 256; - inv_index = Hashtbl.create 256; - base = Hashtbl.create 64; - edges = Hashtbl.create 256; - } - - (* Inverse index helpers *) - let add_to_inv_index state ~source ~target = - let sources = - match Hashtbl.find_opt state.inv_index target with - | Some s -> s - | None -> [] - in - if not (List.mem source sources) then - Hashtbl.replace state.inv_index target (source :: sources) - - let remove_from_inv_index state ~source ~target = - match Hashtbl.find_opt state.inv_index target with - | None -> () - | Some sources -> - let filtered = List.filter (fun s -> s <> source) sources in - if filtered = [] then Hashtbl.remove state.inv_index target - else Hashtbl.replace state.inv_index target filtered - - let iter_step_inv state x f = - match Hashtbl.find_opt state.inv_index x with - | None -> () - | Some sources -> List.iter f sources +let fixpoint ~name ~(init : ('k, unit) t) ~(edges : ('k, 'k list) t) () : + ('k, unit) t = + let my_level = max init.level edges.level + 1 in - (* Get successors from edges *) - let get_successors state x = - match Hashtbl.find_opt state.edges x with - | None -> [] - | Some succs -> succs - - (* Expansion: BFS from frontier, returns list of newly added elements *) - let expand state ~frontier = - let added = ref [] in - let current_frontier = Hashtbl.create 64 in - let next_frontier = Hashtbl.create 64 in - - (* Initialize current frontier *) - List.iter (fun x -> Hashtbl.replace current_frontier x ()) frontier; - - let r = ref 0 in - - while Hashtbl.length current_frontier > 0 do - (* Add all frontier elements to current with rank r *) - Hashtbl.iter - (fun x () -> - if not (Hashtbl.mem state.current x) then ( - Hashtbl.replace state.current x (); - Hashtbl.replace state.rank x !r; - added := x :: !added)) - current_frontier; - - (* Compute next frontier: successors not yet in current *) - Hashtbl.clear next_frontier; - Hashtbl.iter - (fun x () -> - let successors = get_successors state x in - List.iter - (fun y -> - (* Update inverse index: record that x derives y *) - add_to_inv_index state ~source:x ~target:y; - (* Add to next frontier if not already in current *) - if not (Hashtbl.mem state.current y) then - Hashtbl.replace next_frontier y ()) - successors) - current_frontier; - - (* Swap frontiers *) - Hashtbl.clear current_frontier; - Hashtbl.iter - (fun x () -> Hashtbl.replace current_frontier x ()) - next_frontier; - incr r - done; - - !added - - (* Check if element has a well-founded deriver in the current set *) - let has_well_founded_deriver state x ~dying = - match Hashtbl.find_opt state.rank x with - | None -> false - | Some rx -> - let found = ref false in - iter_step_inv state x (fun y -> - if not !found then - let in_current = Hashtbl.mem state.current y in - let not_dying = not (Hashtbl.mem dying y) in - match Hashtbl.find_opt state.rank y with - | None -> () - | Some ry -> - if in_current && not_dying && ry < rx then found := true); - !found - - (* Contraction: remove elements that lost support, returns list of removed *) - let contract state ~worklist = - let dying = Hashtbl.create 64 in - let current_worklist = Hashtbl.create 64 in - - (* Initialize worklist *) - List.iter (fun x -> Hashtbl.replace current_worklist x ()) worklist; - - while Hashtbl.length current_worklist > 0 do - (* Pop an element from worklist *) - let x = - let result = ref None in - Hashtbl.iter - (fun k () -> if !result = None then result := Some k) - current_worklist; - match !result with - | None -> assert false (* worklist not empty *) - | Some k -> - Hashtbl.remove current_worklist k; - k - in + (* Internal state *) + let current : ('k, unit) Hashtbl.t = Hashtbl.create 256 in + let edge_map : ('k, 'k list) Hashtbl.t = Hashtbl.create 256 in + let subscribers = ref [] in + let my_stats = create_stats () in - (* Skip if already dying or in base *) - if Hashtbl.mem dying x || Hashtbl.mem state.base x then () - else - (* Check for well-founded deriver *) - let has_support = has_well_founded_deriver state x ~dying in + (* Separate pending buffers *) + let init_pending = ref [] in + let edges_pending = ref [] in - if not has_support then ( - (* x dies: no well-founded support *) - Hashtbl.replace dying x (); + (* Track which nodes are roots *) + let roots : ('k, unit) Hashtbl.t = Hashtbl.create 64 in - (* Find dependents: elements z such that x derives z *) - let successors = get_successors state x in - List.iter - (fun z -> - if Hashtbl.mem state.current z && not (Hashtbl.mem dying z) then - Hashtbl.replace current_worklist z ()) - successors) - done; + (* BFS helper to find all reachable from roots *) + let recompute_all () = + let new_current = Hashtbl.create (Hashtbl.length current) in + let frontier = Queue.create () in - (* Remove dying elements from current and rank *) - let removed = ref [] in + (* Start from all roots *) Hashtbl.iter - (fun x () -> - Hashtbl.remove state.current x; - Hashtbl.remove state.rank x; - removed := x :: !removed) - dying; - - !removed - - (* Apply a delta from init (base) collection *) - let apply_init_delta state delta = - match delta with - | Set (k, ()) -> - let was_in_base = Hashtbl.mem state.base k in - Hashtbl.replace state.base k (); - if was_in_base then ([], []) (* Already in base, no change *) - else - (* New base element: expand from it *) - let added = expand state ~frontier:[k] in - (added, []) - | Remove k -> - if not (Hashtbl.mem state.base k) then ([], []) - (* Not in base, no change *) - else ( - (* Mirror the verified algorithm's contraction+re-derivation pattern: - removing from base can invalidate the previously-shortest witness rank - for reachable nodes, so contraction alone can remove nodes incorrectly. - We contract first, then attempt to re-derive removed nodes via surviving - predecessors (using the inverse index), then expand. *) - Hashtbl.remove state.base k; - - let contraction_worklist = - if Hashtbl.mem state.current k then [k] else [] - in - let all_removed = - if contraction_worklist <> [] then - contract state ~worklist:contraction_worklist - else [] - in - - let expansion_frontier = ref [] in - let removed_set = Hashtbl.create (List.length all_removed) in - List.iter (fun x -> Hashtbl.replace removed_set x ()) all_removed; - - if Hashtbl.length removed_set > 0 then - Hashtbl.iter - (fun y () -> - iter_step_inv state y (fun x -> - if Hashtbl.mem state.current x then - expansion_frontier := y :: !expansion_frontier)) - removed_set; - - let all_added = - if !expansion_frontier <> [] then - expand state ~frontier:!expansion_frontier - else [] - in - - let net_removed = - List.filter (fun x -> not (Hashtbl.mem state.current x)) all_removed - in - let net_added = - List.filter (fun x -> not (Hashtbl.mem removed_set x)) all_added - in - - (net_added, net_removed)) - | Batch _ -> - (* Batch is handled at a higher level in handle_init_delta *) - ([], []) - - (* Compute edge diff between old and new successors *) - let compute_edge_diff old_succs new_succs = - let old_set = Hashtbl.create (List.length old_succs) in - List.iter (fun x -> Hashtbl.replace old_set x ()) old_succs; - let new_set = Hashtbl.create (List.length new_succs) in - List.iter (fun x -> Hashtbl.replace new_set x ()) new_succs; - - let removed = - List.filter (fun x -> not (Hashtbl.mem new_set x)) old_succs - in - let added = List.filter (fun x -> not (Hashtbl.mem old_set x)) new_succs in - (removed, added) - - (* Apply a delta from edges collection *) - let apply_edges_delta state delta = - match delta with - | Set (source, new_succs) -> - let old_succs = - match Hashtbl.find_opt state.edges source with - | None -> [] - | Some s -> s - in - Hashtbl.replace state.edges source new_succs; - - let removed_targets, added_targets = - compute_edge_diff old_succs new_succs - in - - (* Process removed edges *) - let contraction_worklist = ref [] in - List.iter - (fun target -> - remove_from_inv_index state ~source ~target; - if - Hashtbl.mem state.current source && Hashtbl.mem state.current target - then contraction_worklist := target :: !contraction_worklist) - removed_targets; - - let all_removed = - if !contraction_worklist <> [] then - contract state ~worklist:!contraction_worklist - else [] - in - - (* Process added edges *) - let expansion_frontier = ref [] in - List.iter - (fun target -> - add_to_inv_index state ~source ~target; - if - Hashtbl.mem state.current source - && not (Hashtbl.mem state.current target) - then expansion_frontier := target :: !expansion_frontier) - added_targets; - - (* Check if any removed element can be re-derived via remaining edges *) - let removed_set = Hashtbl.create (List.length all_removed) in - List.iter (fun x -> Hashtbl.replace removed_set x ()) all_removed; - - if Hashtbl.length removed_set > 0 then - Hashtbl.iter - (fun y () -> - iter_step_inv state y (fun x -> - if Hashtbl.mem state.current x then - expansion_frontier := y :: !expansion_frontier)) - removed_set; - - let all_added = - if !expansion_frontier <> [] then - expand state ~frontier:!expansion_frontier - else [] - in - - (* Compute net changes *) - let net_removed = - List.filter (fun x -> not (Hashtbl.mem state.current x)) all_removed - in - let net_added = - List.filter (fun x -> not (Hashtbl.mem removed_set x)) all_added - in - - (net_added, net_removed) - | Remove source -> - let old_succs = - match Hashtbl.find_opt state.edges source with - | None -> [] - | Some s -> s - in - Hashtbl.remove state.edges source; - - (* All edges from source are removed *) - let contraction_worklist = ref [] in - List.iter - (fun target -> - remove_from_inv_index state ~source ~target; - if - Hashtbl.mem state.current source && Hashtbl.mem state.current target - then contraction_worklist := target :: !contraction_worklist) - old_succs; - - let all_removed = - if !contraction_worklist <> [] then - contract state ~worklist:!contraction_worklist - else [] - in - - (* Check if any removed element can be re-derived via remaining edges. - This mirrors the reference implementation's "step 7" re-derivation pass: - removing an edge can invalidate the previously-shortest witness rank for a - node while preserving reachability via a longer path. Contraction alone - can incorrectly remove such nodes because their stored rank is stale/too low. *) - let expansion_frontier = ref [] in - - let removed_set = Hashtbl.create (List.length all_removed) in - List.iter (fun x -> Hashtbl.replace removed_set x ()) all_removed; - - if Hashtbl.length removed_set > 0 then - Hashtbl.iter - (fun y () -> - iter_step_inv state y (fun x -> - if Hashtbl.mem state.current x then - expansion_frontier := y :: !expansion_frontier)) - removed_set; - - let all_added = - if !expansion_frontier <> [] then - expand state ~frontier:!expansion_frontier - else [] - in - - (* Compute net changes *) - let net_removed = - List.filter (fun x -> not (Hashtbl.mem state.current x)) all_removed - in - let net_added = - List.filter (fun x -> not (Hashtbl.mem removed_set x)) all_added - in - - (net_added, net_removed) - | Batch _ -> - (* Batch is handled at a higher level in handle_edges_delta *) - ([], []) -end - -(** Compute transitive closure via incremental fixpoint. - - Starting from keys in [init], follows edges to discover all reachable keys. - - When [init] or [edges] changes, the fixpoint updates incrementally: - - Expansion: BFS from new base elements or newly reachable successors - - Contraction: Well-founded cascade removal when elements lose support *) -let fixpoint ~(init : ('k, unit) t) ~(edges : ('k, 'k list) t) () : ('k, unit) t - = - let state = Fixpoint.create () in - let subscribers : (('k, unit) delta -> unit) list ref = ref [] in - let my_stats = create_stats () in - - let emit delta = - my_stats.updates_emitted <- my_stats.updates_emitted + 1; - List.iter (fun h -> h delta) !subscribers + (fun k () -> + Hashtbl.replace new_current k (); + Queue.add k frontier) + roots; + + (* BFS *) + while not (Queue.is_empty frontier) do + let k = Queue.pop frontier in + match Hashtbl.find_opt edge_map k with + | None -> () + | Some successors -> + List.iter + (fun succ -> + if not (Hashtbl.mem new_current succ) then ( + Hashtbl.replace new_current succ (); + Queue.add succ frontier)) + successors + done; + new_current in - let emit_changes (added, removed) = - List.iter (fun k -> emit (Set (k, ()))) added; - List.iter (fun k -> emit (Remove k)) removed - in + let process () = + (* Track input deltas *) + my_stats.deltas_received <- + my_stats.deltas_received + List.length !init_pending + + List.length !edges_pending; - (* Emit changes as a batch *) - let emit_changes_batch (added, removed) = - let batch_entries = - List.map (fun k -> (k, Some ())) added - @ List.map (fun k -> (k, None)) removed + let init_entries = + !init_pending |> List.concat_map delta_to_entries |> merge_entries + in + let edges_entries = + !edges_pending |> List.concat_map delta_to_entries |> merge_entries in - if batch_entries <> [] then emit (Batch batch_entries) + init_pending := []; + edges_pending := []; + + my_stats.entries_received <- + my_stats.entries_received + List.length init_entries + + List.length edges_entries; + let init_adds, init_removes = count_adds_removes init_entries in + let edges_adds, edges_removes = count_adds_removes edges_entries in + my_stats.adds_received <- my_stats.adds_received + init_adds + edges_adds; + my_stats.removes_received <- + my_stats.removes_received + init_removes + edges_removes; + + let output_entries = ref [] in + let needs_full_recompute = ref false in + + (* Apply edge updates *) + List.iter + (fun (k, v_opt) -> + match v_opt with + | Some successors -> + let old = Hashtbl.find_opt edge_map k in + Hashtbl.replace edge_map k successors; + (* If edges changed for a current node, may need recompute *) + if Hashtbl.mem current k && old <> Some successors then + needs_full_recompute := true + | None -> + if Hashtbl.mem edge_map k then ( + Hashtbl.remove edge_map k; + if Hashtbl.mem current k then needs_full_recompute := true)) + edges_entries; + + (* Apply init updates *) + List.iter + (fun (k, v_opt) -> + match v_opt with + | Some () -> Hashtbl.replace roots k () + | None -> + if Hashtbl.mem roots k then ( + Hashtbl.remove roots k; + needs_full_recompute := true)) + init_entries; + + (* Either do incremental expansion or full recompute *) + (if !needs_full_recompute then ( + (* Full recompute: find what changed *) + let new_current = recompute_all () in + + (* Find removed entries *) + Hashtbl.iter + (fun k () -> + if not (Hashtbl.mem new_current k) then + output_entries := (k, None) :: !output_entries) + current; + + (* Find added entries *) + Hashtbl.iter + (fun k () -> + if not (Hashtbl.mem current k) then + output_entries := (k, Some ()) :: !output_entries) + new_current; + + (* Update current *) + Hashtbl.reset current; + Hashtbl.iter (fun k v -> Hashtbl.replace current k v) new_current) + else + (* Incremental: BFS from new roots *) + let frontier = Queue.create () in + + init_entries + |> List.iter (fun (k, v_opt) -> + match v_opt with + | Some () when not (Hashtbl.mem current k) -> + Hashtbl.replace current k (); + output_entries := (k, Some ()) :: !output_entries; + Queue.add k frontier + | _ -> ()); + + while not (Queue.is_empty frontier) do + let k = Queue.pop frontier in + match Hashtbl.find_opt edge_map k with + | None -> () + | Some successors -> + List.iter + (fun succ -> + if not (Hashtbl.mem current succ) then ( + Hashtbl.replace current succ (); + output_entries := (succ, Some ()) :: !output_entries; + Queue.add succ frontier)) + successors + done); + + if !output_entries <> [] then ( + let num_adds, num_removes = count_changes !output_entries in + my_stats.deltas_emitted <- my_stats.deltas_emitted + 1; + my_stats.entries_emitted <- + my_stats.entries_emitted + List.length !output_entries; + my_stats.adds_emitted <- my_stats.adds_emitted + num_adds; + my_stats.removes_emitted <- my_stats.removes_emitted + num_removes; + let delta = Batch !output_entries in + List.iter (fun h -> h delta) !subscribers) in - (* Handle init deltas *) - let handle_init_delta delta = - my_stats.updates_received <- my_stats.updates_received + 1; - match delta with - | Batch entries -> - (* Process all init entries as a batch *) - let all_added = ref [] in - let all_removed = ref [] in - entries - |> List.iter (fun (k, v_opt) -> - let d = - match v_opt with - | Some () -> Set (k, ()) - | None -> Remove k - in - let added, removed = Fixpoint.apply_init_delta state d in - all_added := added @ !all_added; - all_removed := removed @ !all_removed); - (* Deduplicate and emit as batch *) - let added_set = Hashtbl.create (List.length !all_added) in - List.iter (fun k -> Hashtbl.replace added_set k ()) !all_added; - let removed_set = Hashtbl.create (List.length !all_removed) in - List.iter (fun k -> Hashtbl.replace removed_set k ()) !all_removed; - (* Net changes: added if in added_set but not removed_set, etc. *) - let net_added = - Hashtbl.fold - (fun k () acc -> if Hashtbl.mem removed_set k then acc else k :: acc) - added_set [] - in - let net_removed = - Hashtbl.fold - (fun k () acc -> if Hashtbl.mem added_set k then acc else k :: acc) - removed_set [] - in - emit_changes_batch (net_added, net_removed) - | _ -> - let changes = Fixpoint.apply_init_delta state delta in - emit_changes changes + let _info = + Registry.register ~name ~level:my_level ~process ~stats:my_stats in + Registry.add_edge ~from_name:init.name ~to_name:name; + Registry.add_edge ~from_name:edges.name ~to_name:name; - (* Handle edges deltas *) - let handle_edges_delta delta = - my_stats.updates_received <- my_stats.updates_received + 1; - match delta with - | Batch entries -> - (* Process all edge entries as a batch *) - let all_added = ref [] in - let all_removed = ref [] in - entries - |> List.iter (fun (k, v_opt) -> - let d = - match v_opt with - | Some succs -> Set (k, succs) - | None -> Remove k - in - let added, removed = Fixpoint.apply_edges_delta state d in - all_added := added @ !all_added; - all_removed := removed @ !all_removed); - (* Deduplicate and emit as batch *) - let added_set = Hashtbl.create (List.length !all_added) in - List.iter (fun k -> Hashtbl.replace added_set k ()) !all_added; - let removed_set = Hashtbl.create (List.length !all_removed) in - List.iter (fun k -> Hashtbl.replace removed_set k ()) !all_removed; - let net_added = - Hashtbl.fold - (fun k () acc -> if Hashtbl.mem removed_set k then acc else k :: acc) - added_set [] - in - let net_removed = - Hashtbl.fold - (fun k () acc -> if Hashtbl.mem added_set k then acc else k :: acc) - removed_set [] - in - emit_changes_batch (net_added, net_removed) - | _ -> - let changes = Fixpoint.apply_edges_delta state delta in - emit_changes changes - in + (* Subscribe to sources: just accumulate *) + init.subscribe (fun delta -> + init_pending := delta :: !init_pending; + Registry.mark_dirty name); - (* Subscribe to changes *) - init.subscribe handle_init_delta; - edges.subscribe handle_edges_delta; + edges.subscribe (fun delta -> + edges_pending := delta :: !edges_pending; + Registry.mark_dirty name); (* Initialize from existing data *) - (* First, load all edges so expansion works correctly *) - edges.iter (fun k succs -> Hashtbl.replace state.edges k succs); - - (* Build inverse index for existing edges *) - Hashtbl.iter - (fun source succs -> - List.iter - (fun target -> Fixpoint.add_to_inv_index state ~source ~target) - succs) - state.edges; - - (* Then process init elements *) - let initial_frontier = ref [] in + (* First, copy edges *) + edges.iter (fun k v -> Hashtbl.replace edge_map k v); + (* Then, BFS from existing init values *) + let frontier = Queue.create () in init.iter (fun k () -> - Hashtbl.replace state.base k (); - initial_frontier := k :: !initial_frontier); - ignore (Fixpoint.expand state ~frontier:!initial_frontier); + Hashtbl.replace roots k (); + (* Track roots *) + if not (Hashtbl.mem current k) then ( + Hashtbl.replace current k (); + Queue.add k frontier)); + while not (Queue.is_empty frontier) do + let k = Queue.pop frontier in + match Hashtbl.find_opt edge_map k with + | None -> () + | Some successors -> + List.iter + (fun succ -> + if not (Hashtbl.mem current succ) then ( + Hashtbl.replace current succ (); + Queue.add succ frontier)) + successors + done; { - subscribe = (fun handler -> subscribers := handler :: !subscribers); - iter = (fun f -> Hashtbl.iter f state.current); - get = (fun k -> Hashtbl.find_opt state.current k); - length = (fun () -> Hashtbl.length state.current); + name; + subscribe = (fun h -> subscribers := h :: !subscribers); + iter = (fun f -> Hashtbl.iter f current); + get = (fun k -> Hashtbl.find_opt current k); + length = (fun () -> Hashtbl.length current); stats = my_stats; + level = my_level; } + +(** {1 Utilities} *) + +let to_mermaid () = Registry.to_mermaid () +let print_stats () = Registry.print_stats () +let reset () = Registry.clear () diff --git a/analysis/reactive/src/Reactive.mli b/analysis/reactive/src/Reactive.mli index 8ca2a9d352..9a90c53192 100644 --- a/analysis/reactive/src/Reactive.mli +++ b/analysis/reactive/src/Reactive.mli @@ -1,31 +1,11 @@ -(** Reactive collections for incremental computation. - - Provides composable reactive collections with delta-based updates. - - {2 Example: Composing collections} - - {[ - (* Create a file collection *) - let files = ReactiveFileCollection.create ~read_file ~process in - - (* Derive a declarations collection *) - let decls = Reactive.flatMap files - ~f:(fun _path data -> data.decls) - () - - (* Derive a references collection with merging *) - let refs = Reactive.flatMap decls - ~f:(fun _pos decl -> decl.refs) - ~merge:PosSet.union - () - - (* Process files - all downstream collections update automatically *) - files |> Reactive.iter (fun path _ -> - ReactiveFileCollection.process_if_changed files_internal path) - - (* Read from any collection *) - Reactive.iter (fun k v -> ...) refs - ]} *) +(** Reactive V2: Accumulate-then-propagate scheduler for glitch-free semantics. + + Key design: + 1. Nodes accumulate batch deltas (don't process immediately) + 2. Scheduler visits nodes in dependency order + 3. Each node processes accumulated deltas exactly once per wave + + This eliminates glitches from multi-level dependencies by construction. *) (** {1 Deltas} *) @@ -33,90 +13,114 @@ type ('k, 'v) delta = | Set of 'k * 'v | Remove of 'k | Batch of ('k * 'v option) list - (** Batch of updates: (key, Some value) = set, (key, None) = remove. - Batches are processed atomically and emitted as batches downstream. *) - -(** Convenience constructors for batch entries *) + (** Batch of updates: (key, Some value) = set, (key, None) = remove *) val set : 'k -> 'v -> 'k * 'v option -(** [set k v] creates a batch entry that sets key [k] to value [v] *) +(** Create a batch entry that sets a key *) val remove : 'k -> 'k * 'v option -(** [remove k] creates a batch entry that removes key [k] *) - -val apply_delta : ('k, 'v) Hashtbl.t -> ('k, 'v) delta -> unit -val apply_deltas : ('k, 'v) Hashtbl.t -> ('k, 'v) delta list -> unit +(** Create a batch entry that removes a key *) val delta_to_entries : ('k, 'v) delta -> ('k * 'v option) list -(** Convert any delta to batch entry format *) +(** Convert delta to batch entries *) (** {1 Statistics} *) type stats = { - mutable updates_received: int; (** Deltas received from upstream *) - mutable updates_emitted: int; (** Deltas emitted downstream *) + (* Input tracking *) + mutable deltas_received: int; + (** Number of delta messages (Set/Remove/Batch) *) + mutable entries_received: int; (** Total entries after expanding batches *) + mutable adds_received: int; (** Set operations received from upstream *) + mutable removes_received: int; + (** Remove operations received from upstream *) + (* Processing tracking *) + mutable process_count: int; (** Times process() was called *) + mutable process_time_ns: int64; (** Total time in process() *) + (* Output tracking *) + mutable deltas_emitted: int; (** Number of delta messages emitted *) + mutable entries_emitted: int; (** Total entries in emitted deltas *) + mutable adds_emitted: int; (** Set operations emitted downstream *) + mutable removes_emitted: int; (** Remove operations emitted downstream *) } +(** Per-node statistics for diagnostics *) val create_stats : unit -> stats -(** {1 Reactive Collection} *) +(** {1 Node Registry} *) + +module Registry : sig + type node_info + (** Information about a registered node *) + + val clear : unit -> unit + (** Clear all registered nodes *) + + val to_mermaid : unit -> string + (** Generate a Mermaid diagram of the pipeline *) + + val print_stats : unit -> unit + (** Print timing statistics for all nodes *) +end + +(** {1 Scheduler} *) + +module Scheduler : sig + val propagate : unit -> unit + (** Process all dirty nodes in topological order. + Called automatically when a source emits. *) + + val is_propagating : unit -> bool + (** Returns true if currently in a propagation wave *) + + val wave_count : unit -> int + (** Number of propagation waves executed *) + + val reset_wave_count : unit -> unit + (** Reset the wave counter *) +end + +(** {1 Collection Interface} *) type ('k, 'v) t = { + name: string; subscribe: (('k, 'v) delta -> unit) -> unit; iter: ('k -> 'v -> unit) -> unit; get: 'k -> 'v option; length: unit -> int; stats: stats; + level: int; } -(** A reactive collection that can emit deltas and be read. - All collections share this interface, enabling composition. - [stats] tracks updates received/emitted for diagnostics. *) - -(** {1 Collection operations} *) +(** A named reactive collection at a specific topological level *) val iter : ('k -> 'v -> unit) -> ('k, 'v) t -> unit -(** Iterate over entries. *) - val get : ('k, 'v) t -> 'k -> 'v option -(** Get a value by key. *) - val length : ('k, 'v) t -> int -(** Number of entries. *) - val stats : ('k, 'v) t -> stats -(** Get update statistics for this collection. *) +val level : ('k, 'v) t -> int +val name : ('k, 'v) t -> string + +(** {1 Source Collection} *) + +val source : name:string -> unit -> ('k, 'v) t * (('k, 'v) delta -> unit) +(** Create a named source collection. + Returns the collection and an emit function. + Emitting triggers propagation through the pipeline. *) -(** {1 Composition} *) +(** {1 Combinators} *) val flatMap : + name:string -> ('k1, 'v1) t -> f:('k1 -> 'v1 -> ('k2 * 'v2) list) -> ?merge:('v2 -> 'v2 -> 'v2) -> unit -> ('k2, 'v2) t -(** [flatMap source ~f ()] creates a derived collection. - - Each entry [(k1, v1)] in [source] produces entries [(k2, v2), ...] via [f k1 v1]. - When [source] changes, the derived collection updates automatically. - - Optional [merge] combines values when multiple sources produce the same key. - Defaults to last-write-wins. - - Derived collections can be further composed with [flatMap]. *) - -(** {1 Lookup} *) - -val lookup : ('k, 'v) t -> key:'k -> ('k, 'v) t -(** [lookup source ~key] creates a reactive subscription to a single key. - - Returns a collection containing at most one entry (the value at [key]). - When [source]'s value at [key] changes, the lookup collection updates. - - Useful for reactive point queries. *) - -(** {1 Join} *) +(** Transform each entry into zero or more output entries. + Optional merge function combines values for the same output key. *) val join : + name:string -> ('k1, 'v1) t -> ('k2, 'v2) t -> key_of:('k1 -> 'v1 -> 'k2) -> @@ -124,94 +128,39 @@ val join : ?merge:('v3 -> 'v3 -> 'v3) -> unit -> ('k3, 'v3) t -(** [join left right ~key_of ~f ()] joins two collections. - - For each entry [(k1, v1)] in [left]: - - Computes lookup key [k2 = key_of k1 v1] - - Looks up [k2] in [right] to get [v2_opt] - - Produces entries via [f k1 v1 v2_opt] - - When either [left] or [right] changes, affected entries are recomputed. - This is the reactive equivalent of a hash join. - - {2 Example: Exception refs lookup} - - {[ - (* exception_refs: (path, loc_from) *) - (* decl_by_path: (path, decl list) *) - let resolved = Reactive.join exception_refs decl_by_path - ~key_of:(fun path _loc -> path) - ~f:(fun path loc decls_opt -> - match decls_opt with - | Some decls -> decls |> List.map (fun d -> (d.pos, loc)) - | None -> []) - () - ]} *) - -(** {1 Union} *) +(** Join left collection with right collection. + For each left entry, looks up the key in right. + Separate left/right pending buffers ensure glitch-freedom. *) val union : - ('k, 'v) t -> ('k, 'v) t -> ?merge:('v -> 'v -> 'v) -> unit -> ('k, 'v) t -(** [union left right ?merge ()] combines two collections. - - Returns a collection containing all entries from both [left] and [right]. - When the same key exists in both collections: - - If [merge] is provided, values are combined with [merge left_val right_val] - - Otherwise, the value from [right] takes precedence - - When either collection changes, the union updates automatically. - - {2 Example: Combining reference sets} - {[ - let value_refs = ... - let type_refs = ... - let all_refs = Reactive.union value_refs type_refs ~merge:PosSet.union () - ]} *) - -(** {1 Fixpoint} *) + name:string -> + ('k, 'v) t -> + ('k, 'v) t -> + ?merge:('v -> 'v -> 'v) -> + unit -> + ('k, 'v) t +(** Combine two collections. + Optional merge function combines values for the same key. + Separate left/right pending buffers ensure glitch-freedom. *) val fixpoint : - init:('k, unit) t -> edges:('k, 'k list) t -> unit -> ('k, unit) t -(** [fixpoint ~init ~edges ()] computes transitive closure incrementally. - - Starting from keys in [init], follows edges to discover all reachable keys. - - - [init]: reactive collection of starting keys (base) - - [edges]: reactive collection mapping each key to its successor keys - - Returns: reactive collection of all reachable keys - - {b Incremental Updates:} - - When [init] or [edges] changes, the fixpoint updates efficiently: - - - {b Expansion}: When base grows or new edges are added, BFS from the - new frontier discovers newly reachable elements. O(new reachable). - - - {b Contraction}: When base shrinks or edges are removed, elements that - lost support are removed using well-founded derivation. Cycle members - have equal BFS ranks and cannot support each other, so unreachable - cycles are correctly removed. O(affected elements). - - {b Algorithm:} - - Each element has a rank (BFS distance from base). For contraction, - an element survives only if it has a "well-founded deriver" - an - element with strictly lower rank that derives it. This ensures: - - Direct base elements always survive (rank 0) - - Elements derived from survivors survive - - Cycles lose all members when disconnected from base - - {2 Example: Reachability} - {[ - let roots = ... (* keys that are initially reachable *) - let graph = ... (* key -> successor keys *) - let reachable = Reactive.fixpoint ~init:roots ~edges:graph () - ]} - - {2 Example: Dead code elimination} - {[ - let live_roots = ... (* @live annotations, external refs *) - let references = ... (* decl -> referenced decls *) - let live = Reactive.fixpoint ~init:live_roots ~edges:references () - (* Dead = all_decls - live *) - ]} *) + name:string -> + init:('k, unit) t -> + edges:('k, 'k list) t -> + unit -> + ('k, unit) t +(** Compute transitive closure. + init: initial roots + edges: k -> successors + Returns: all reachable keys from roots *) + +(** {1 Utilities} *) + +val to_mermaid : unit -> string +(** Generate Mermaid diagram of the pipeline *) + +val print_stats : unit -> unit +(** Print per-node timing statistics *) + +val reset : unit -> unit +(** Clear all registered nodes (for tests) *) diff --git a/analysis/reactive/src/ReactiveFileCollection.ml b/analysis/reactive/src/ReactiveFileCollection.ml index c995b5a598..d1641479ff 100644 --- a/analysis/reactive/src/ReactiveFileCollection.ml +++ b/analysis/reactive/src/ReactiveFileCollection.ml @@ -17,46 +17,28 @@ type ('raw, 'v) internal = { cache: (string, file_id * 'v) Hashtbl.t; read_file: string -> 'raw; process: string -> 'raw -> 'v; (* path -> raw -> value *) - mutable subscribers: ((string, 'v) Reactive.delta -> unit) list; } (** Internal state for file collection *) type ('raw, 'v) t = { internal: ('raw, 'v) internal; collection: (string, 'v) Reactive.t; + emit: (string, 'v) Reactive.delta -> unit; } (** A file collection is just a Reactive.t with some extra operations *) -let emit t delta = - t.collection.stats.updates_emitted <- t.collection.stats.updates_emitted + 1; - List.iter (fun h -> h delta) t.internal.subscribers - (** Create a new reactive file collection *) let create ~read_file ~process : ('raw, 'v) t = - let internal = - {cache = Hashtbl.create 256; read_file; process; subscribers = []} - in - let my_stats = Reactive.create_stats () in - let collection = - { - Reactive.subscribe = - (fun handler -> internal.subscribers <- handler :: internal.subscribers); - iter = - (fun f -> Hashtbl.iter (fun path (_, v) -> f path v) internal.cache); - get = - (fun path -> - match Hashtbl.find_opt internal.cache path with - | Some (_, v) -> Some v - | None -> None); - length = (fun () -> Hashtbl.length internal.cache); - stats = my_stats; - } - in - {internal; collection} + let internal = {cache = Hashtbl.create 256; read_file; process} in + let collection, emit = Reactive.source ~name:"file_collection" () in + {internal; collection; emit} (** Get the collection interface for composition *) let to_collection t : (string, 'v) Reactive.t = t.collection +(** Emit a delta *) +let emit t delta = t.emit delta + (** Process a file if changed. Emits delta to subscribers. *) let process_if_changed t path = let new_id = get_file_id path in @@ -106,7 +88,11 @@ let clear t = Hashtbl.clear t.internal.cache (** Invalidate a path *) let invalidate t path = Hashtbl.remove t.internal.cache path -let get t path = t.collection.get path +let get t path = + match Hashtbl.find_opt t.internal.cache path with + | Some (_, v) -> Some v + | None -> None + let mem t path = Hashtbl.mem t.internal.cache path -let length t = t.collection.length () -let iter f t = t.collection.iter f +let length t = Reactive.length t.collection +let iter f t = Reactive.iter f t.collection diff --git a/analysis/reactive/src/ReactiveFileCollection.mli b/analysis/reactive/src/ReactiveFileCollection.mli index 571d7fb501..b6e50b820f 100644 --- a/analysis/reactive/src/ReactiveFileCollection.mli +++ b/analysis/reactive/src/ReactiveFileCollection.mli @@ -11,7 +11,7 @@ ~process:(fun path cmt -> extract_data path cmt) (* Compose with flatMap *) - let decls = Reactive.flatMap (ReactiveFileCollection.to_collection files) + let decls = Reactive.flatMap ~name:"decls" (ReactiveFileCollection.to_collection files) ~f:(fun _path data -> data.decls) () diff --git a/analysis/reactive/test/BatchTest.ml b/analysis/reactive/test/BatchTest.ml new file mode 100644 index 0000000000..4c750d16cf --- /dev/null +++ b/analysis/reactive/test/BatchTest.ml @@ -0,0 +1,87 @@ +(** Batch processing tests *) + +open Reactive +open TestHelpers + +let test_batch_flatmap () = + reset (); + Printf.printf "=== Test: batch flatmap ===\n"; + + let source, emit = source ~name:"source" () in + let derived = + flatMap ~name:"derived" source ~f:(fun k v -> [(k ^ "_derived", v * 2)]) () + in + + (* Subscribe to track what comes out *) + let received_batches = ref 0 in + let received_entries = ref [] in + subscribe + (function + | Batch entries -> + incr received_batches; + received_entries := entries @ !received_entries + | Set (k, v) -> received_entries := [(k, Some v)] @ !received_entries + | Remove k -> received_entries := [(k, None)] @ !received_entries) + derived; + + (* Send a batch *) + emit_batch [set "a" 1; set "b" 2; set "c" 3] emit; + + Printf.printf "Received batches: %d, entries: %d\n" !received_batches + (List.length !received_entries); + assert (!received_batches = 1); + assert (List.length !received_entries = 3); + assert (get derived "a_derived" = Some 2); + assert (get derived "b_derived" = Some 4); + assert (get derived "c_derived" = Some 6); + + Printf.printf "PASSED\n\n" + +let test_batch_fixpoint () = + reset (); + Printf.printf "=== Test: batch fixpoint ===\n"; + + let init, emit_init = source ~name:"init" () in + let edges, emit_edges = source ~name:"edges" () in + + let fp = fixpoint ~name:"fp" ~init ~edges () in + + (* Track batches received *) + let batch_count = ref 0 in + let total_added = ref 0 in + subscribe + (function + | Batch entries -> + incr batch_count; + entries + |> List.iter (fun (_, v_opt) -> + match v_opt with + | Some () -> incr total_added + | None -> ()) + | Set (_, ()) -> incr total_added + | Remove _ -> ()) + fp; + + (* Set up edges first *) + emit_edges (Set ("a", ["b"; "c"])); + emit_edges (Set ("b", ["d"])); + + (* Send batch of roots *) + emit_batch [set "a" (); set "x" ()] emit_init; + + Printf.printf "Batch count: %d, total added: %d\n" !batch_count !total_added; + Printf.printf "fp length: %d\n" (length fp); + (* Should have a, b, c, d (reachable from a) and x (standalone root) *) + assert (length fp = 5); + assert (get fp "a" = Some ()); + assert (get fp "b" = Some ()); + assert (get fp "c" = Some ()); + assert (get fp "d" = Some ()); + assert (get fp "x" = Some ()); + + Printf.printf "PASSED\n\n" + +let run_all () = + Printf.printf "\n====== Batch Tests ======\n\n"; + test_batch_flatmap (); + test_batch_fixpoint () diff --git a/analysis/reactive/test/FixpointBasicTest.ml b/analysis/reactive/test/FixpointBasicTest.ml new file mode 100644 index 0000000000..b978ea9468 --- /dev/null +++ b/analysis/reactive/test/FixpointBasicTest.ml @@ -0,0 +1,212 @@ +(** Basic fixpoint graph traversal tests *) + +open Reactive + +let test_fixpoint () = + reset (); + Printf.printf "Test: fixpoint\n"; + + let init, emit_init = source ~name:"init" () in + let edges, emit_edges = source ~name:"edges" () in + + (* Set up graph: 1 -> [2, 3], 2 -> [4], 3 -> [4] *) + emit_edges (Set (1, [2; 3])); + emit_edges (Set (2, [4])); + emit_edges (Set (3, [4])); + + (* Compute fixpoint *) + let reachable = fixpoint ~name:"reachable" ~init ~edges () in + + (* Initially empty *) + Printf.printf "Initially: length=%d\n" (length reachable); + assert (length reachable = 0); + + (* Add root 1 *) + emit_init (Set (1, ())); + Printf.printf "After adding root 1: length=%d\n" (length reachable); + assert (length reachable = 4); + (* 1, 2, 3, 4 *) + assert (get reachable 1 = Some ()); + assert (get reachable 2 = Some ()); + assert (get reachable 3 = Some ()); + assert (get reachable 4 = Some ()); + assert (get reachable 5 = None); + + (* Add another root 5 with edge 5 -> [6] *) + emit_edges (Set (5, [6])); + emit_init (Set (5, ())); + Printf.printf "After adding root 5: length=%d\n" (length reachable); + assert (length reachable = 6); + + (* 1, 2, 3, 4, 5, 6 *) + + (* Remove root 1 *) + emit_init (Remove 1); + Printf.printf "After removing root 1: length=%d\n" (length reachable); + assert (length reachable = 2); + (* 5, 6 *) + assert (get reachable 1 = None); + assert (get reachable 5 = Some ()); + assert (get reachable 6 = Some ()); + + Printf.printf "PASSED\n\n" + +let test_fixpoint_basic_expansion () = + reset (); + Printf.printf "=== Test: fixpoint basic expansion ===\n"; + + let init, emit_init = source ~name:"init" () in + let edges, emit_edges = source ~name:"edges" () in + + (* Graph: a -> b -> c *) + emit_edges (Set ("a", ["b"])); + emit_edges (Set ("b", ["c"])); + + let fp = fixpoint ~name:"fp" ~init ~edges () in + + emit_init (Set ("a", ())); + + assert (length fp = 3); + assert (get fp "a" = Some ()); + assert (get fp "b" = Some ()); + assert (get fp "c" = Some ()); + assert (get fp "d" = None); + + Printf.printf "PASSED\n\n" + +let test_fixpoint_multiple_roots () = + reset (); + Printf.printf "=== Test: fixpoint multiple roots ===\n"; + + let init, emit_init = source ~name:"init" () in + let edges, emit_edges = source ~name:"edges" () in + + (* Graph: a -> b, c -> d (disconnected components) *) + emit_edges (Set ("a", ["b"])); + emit_edges (Set ("c", ["d"])); + + let fp = fixpoint ~name:"fp" ~init ~edges () in + + emit_init (Set ("a", ())); + emit_init (Set ("c", ())); + + assert (length fp = 4); + assert (get fp "a" = Some ()); + assert (get fp "b" = Some ()); + assert (get fp "c" = Some ()); + assert (get fp "d" = Some ()); + + Printf.printf "PASSED\n\n" + +let test_fixpoint_diamond () = + reset (); + Printf.printf "=== Test: fixpoint diamond ===\n"; + + let init, emit_init = source ~name:"init" () in + let edges, emit_edges = source ~name:"edges" () in + + (* Graph: a -> b, a -> c, b -> d, c -> d *) + emit_edges (Set ("a", ["b"; "c"])); + emit_edges (Set ("b", ["d"])); + emit_edges (Set ("c", ["d"])); + + let fp = fixpoint ~name:"fp" ~init ~edges () in + + emit_init (Set ("a", ())); + + assert (length fp = 4); + + Printf.printf "PASSED\n\n" + +let test_fixpoint_cycle () = + reset (); + Printf.printf "=== Test: fixpoint cycle ===\n"; + + let init, emit_init = source ~name:"init" () in + let edges, emit_edges = source ~name:"edges" () in + + (* Graph: a -> b -> c -> b (cycle from root) *) + emit_edges (Set ("a", ["b"])); + emit_edges (Set ("b", ["c"])); + emit_edges (Set ("c", ["b"])); + + let fp = fixpoint ~name:"fp" ~init ~edges () in + + emit_init (Set ("a", ())); + + assert (length fp = 3); + assert (get fp "a" = Some ()); + assert (get fp "b" = Some ()); + assert (get fp "c" = Some ()); + + Printf.printf "PASSED\n\n" + +let test_fixpoint_empty_base () = + reset (); + Printf.printf "=== Test: fixpoint empty base ===\n"; + + let init, _emit_init = source ~name:"init" () in + let edges, emit_edges = source ~name:"edges" () in + + emit_edges (Set ("a", ["b"])); + + let fp = fixpoint ~name:"fp" ~init ~edges () in + + assert (length fp = 0); + + Printf.printf "PASSED\n\n" + +let test_fixpoint_self_loop () = + reset (); + Printf.printf "=== Test: fixpoint self loop ===\n"; + + let init, emit_init = source ~name:"init" () in + let edges, emit_edges = source ~name:"edges" () in + + (* Graph: a -> a (self loop) *) + emit_edges (Set ("a", ["a"])); + + let fp = fixpoint ~name:"fp" ~init ~edges () in + + emit_init (Set ("a", ())); + + assert (length fp = 1); + assert (get fp "a" = Some ()); + + Printf.printf "PASSED\n\n" + +let test_fixpoint_existing_data () = + reset (); + Printf.printf "=== Test: fixpoint with existing data ===\n"; + + (* Create source and pre-populate *) + let init, emit_init = source ~name:"init" () in + emit_init (Set ("root", ())); + + let edges, emit_edges = source ~name:"edges" () in + emit_edges (Set ("root", ["a"; "b"])); + emit_edges (Set ("a", ["c"])); + + (* Create fixpoint - should immediately have all reachable *) + let fp = fixpoint ~name:"fp" ~init ~edges () in + + Printf.printf "Fixpoint length: %d (expected 4)\n" (length fp); + assert (length fp = 4); + (* root, a, b, c *) + assert (get fp "root" = Some ()); + assert (get fp "a" = Some ()); + assert (get fp "b" = Some ()); + assert (get fp "c" = Some ()); + + Printf.printf "PASSED\n\n" + +let run_all () = + Printf.printf "\n====== Fixpoint Basic Tests ======\n\n"; + test_fixpoint (); + test_fixpoint_basic_expansion (); + test_fixpoint_multiple_roots (); + test_fixpoint_diamond (); + test_fixpoint_cycle (); + test_fixpoint_empty_base (); + test_fixpoint_self_loop (); + test_fixpoint_existing_data () diff --git a/analysis/reactive/test/FixpointIncrementalTest.ml b/analysis/reactive/test/FixpointIncrementalTest.ml new file mode 100644 index 0000000000..e7fb6c086e --- /dev/null +++ b/analysis/reactive/test/FixpointIncrementalTest.ml @@ -0,0 +1,690 @@ +(** Incremental fixpoint update tests (add/remove base and edges) *) + +open Reactive +open TestHelpers + +let test_fixpoint_add_base () = + reset (); + Printf.printf "=== Test: fixpoint add base ===\n"; + + let init, emit_init = source ~name:"init" () in + let edges, emit_edges = source ~name:"edges" () in + + (* Graph: a -> b, c -> d *) + emit_edges (Set ("a", ["b"])); + emit_edges (Set ("c", ["d"])); + + let fp = fixpoint ~name:"fp" ~init ~edges () in + + emit_init (Set ("a", ())); + assert (length fp = 2); + + (* a, b *) + + (* Track changes via subscription *) + let added = ref [] in + let removed = ref [] in + subscribe + (function + | Set (k, ()) -> added := k :: !added + | Remove k -> removed := k :: !removed + | Batch entries -> + entries + |> List.iter (fun (k, v_opt) -> + match v_opt with + | Some () -> added := k :: !added + | None -> removed := k :: !removed)) + fp; + + emit_init (Set ("c", ())); + + Printf.printf "Added: [%s]\n" (String.concat ", " !added); + assert (List.length !added = 2); + (* c, d *) + assert (List.mem "c" !added); + assert (List.mem "d" !added); + assert (!removed = []); + assert (length fp = 4); + + Printf.printf "PASSED\n\n" + +let test_fixpoint_remove_base () = + reset (); + Printf.printf "=== Test: fixpoint remove base ===\n"; + + let init, emit_init = source ~name:"init" () in + let edges, emit_edges = source ~name:"edges" () in + + (* Graph: a -> b -> c *) + emit_edges (Set ("a", ["b"])); + emit_edges (Set ("b", ["c"])); + + let fp = fixpoint ~name:"fp" ~init ~edges () in + + emit_init (Set ("a", ())); + assert (length fp = 3); + + let removed = ref [] in + subscribe + (function + | Remove k -> removed := k :: !removed + | Batch entries -> + List.iter + (fun (k, v_opt) -> if v_opt = None then removed := k :: !removed) + entries + | _ -> ()) + fp; + + emit_init (Remove "a"); + + Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); + assert (List.length !removed = 3); + assert (length fp = 0); + + Printf.printf "PASSED\n\n" + +let test_fixpoint_add_edge () = + reset (); + Printf.printf "=== Test: fixpoint add edge ===\n"; + + let init, emit_init = source ~name:"init" () in + let edges, emit_edges = source ~name:"edges" () in + + let fp = fixpoint ~name:"fp" ~init ~edges () in + + emit_init (Set ("a", ())); + assert (length fp = 1); + + (* just a *) + let added = ref [] in + subscribe + (function + | Set (k, ()) -> added := k :: !added + | Batch entries -> + List.iter + (fun (k, v_opt) -> if v_opt = Some () then added := k :: !added) + entries + | _ -> ()) + fp; + + (* Add edge a -> b *) + emit_edges (Set ("a", ["b"])); + + Printf.printf "Added: [%s]\n" (String.concat ", " !added); + assert (List.mem "b" !added); + assert (length fp = 2); + + Printf.printf "PASSED\n\n" + +let test_fixpoint_remove_edge () = + reset (); + Printf.printf "=== Test: fixpoint remove edge ===\n"; + + let init, emit_init = source ~name:"init" () in + let edges, emit_edges = source ~name:"edges" () in + + (* Graph: a -> b -> c *) + emit_edges (Set ("a", ["b"])); + emit_edges (Set ("b", ["c"])); + + let fp = fixpoint ~name:"fp" ~init ~edges () in + + emit_init (Set ("a", ())); + assert (length fp = 3); + + let removed = ref [] in + subscribe + (function + | Remove k -> removed := k :: !removed + | Batch entries -> + List.iter + (fun (k, v_opt) -> if v_opt = None then removed := k :: !removed) + entries + | _ -> ()) + fp; + + (* Remove edge a -> b *) + emit_edges (Set ("a", [])); + + Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); + assert (List.length !removed = 2); + (* b, c *) + assert (length fp = 1); + + (* just a *) + Printf.printf "PASSED\n\n" + +let test_fixpoint_cycle_removal () = + reset (); + Printf.printf "=== Test: fixpoint cycle removal (well-founded) ===\n"; + + let init, emit_init = source ~name:"init" () in + let edges, emit_edges = source ~name:"edges" () in + + (* Graph: a -> b -> c -> b (b-c cycle reachable from a) *) + emit_edges (Set ("a", ["b"])); + emit_edges (Set ("b", ["c"])); + emit_edges (Set ("c", ["b"])); + + let fp = fixpoint ~name:"fp" ~init ~edges () in + + emit_init (Set ("a", ())); + assert (length fp = 3); + + let removed = ref [] in + subscribe + (function + | Remove k -> removed := k :: !removed + | Batch entries -> + List.iter + (fun (k, v_opt) -> if v_opt = None then removed := k :: !removed) + entries + | _ -> ()) + fp; + + (* Remove edge a -> b *) + emit_edges (Set ("a", [])); + + Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); + (* Both b and c should be removed - cycle has no well-founded support *) + assert (List.length !removed = 2); + assert (List.mem "b" !removed); + assert (List.mem "c" !removed); + assert (length fp = 1); + + (* just a *) + Printf.printf "PASSED\n\n" + +let test_fixpoint_alternative_support () = + reset (); + Printf.printf "=== Test: fixpoint alternative support ===\n"; + + let init, emit_init = source ~name:"init" () in + let edges, emit_edges = source ~name:"edges" () in + + (* Graph: a -> b, a -> c -> b + If we remove a -> b, b should survive via a -> c -> b *) + emit_edges (Set ("a", ["b"; "c"])); + emit_edges (Set ("c", ["b"])); + + let fp = fixpoint ~name:"fp" ~init ~edges () in + + emit_init (Set ("a", ())); + assert (length fp = 3); + + let removed = ref [] in + subscribe + (function + | Remove k -> removed := k :: !removed + | Batch entries -> + List.iter + (fun (k, v_opt) -> if v_opt = None then removed := k :: !removed) + entries + | _ -> ()) + fp; + + (* Remove direct edge a -> b (but keep a -> c) *) + emit_edges (Set ("a", ["c"])); + + Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); + (* b should NOT be removed - still reachable via c *) + assert (!removed = []); + assert (length fp = 3); + + Printf.printf "PASSED\n\n" + +let test_fixpoint_deltas () = + reset (); + Printf.printf "=== Test: fixpoint delta emissions ===\n"; + + let init, emit_init = source ~name:"init" () in + let edges, emit_edges = source ~name:"edges" () in + + emit_edges (Set (1, [2; 3])); + emit_edges (Set (2, [4])); + + let fp = fixpoint ~name:"fp" ~init ~edges () in + + (* Count entries, not deltas - V2 emits batches *) + let all_entries = ref [] in + subscribe + (function + | Set (k, v) -> all_entries := (k, Some v) :: !all_entries + | Remove k -> all_entries := (k, None) :: !all_entries + | Batch entries -> all_entries := entries @ !all_entries) + fp; + + (* Add root *) + emit_init (Set (1, ())); + Printf.printf "After add root: %d entries\n" (List.length !all_entries); + assert (List.length !all_entries = 4); + + (* 1, 2, 3, 4 *) + all_entries := []; + + (* Add edge 3 -> 5 *) + emit_edges (Set (3, [5])); + Printf.printf "After add edge 3->5: %d entries\n" (List.length !all_entries); + assert (List.length !all_entries = 1); + + (* 5 added *) + all_entries := []; + + (* Remove root (should remove all) *) + emit_init (Remove 1); + Printf.printf "After remove root: %d entries\n" (List.length !all_entries); + assert (List.length !all_entries = 5); + + (* 1, 2, 3, 4, 5 removed *) + Printf.printf "PASSED\n\n" + +(* Test: Remove from init but still reachable via edges *) +let test_fixpoint_remove_spurious_root () = + reset (); + Printf.printf + "=== Test: fixpoint remove spurious root (still reachable) ===\n"; + + let init, emit_init = source ~name:"init" () in + let edges, emit_edges = source ~name:"edges" () in + + let fp = fixpoint ~name:"fp" ~init ~edges () in + + (* Track all deltas *) + let added = ref [] in + let removed = ref [] in + subscribe + (function + | Set (k, ()) -> added := k :: !added + | Remove k -> removed := k :: !removed + | Batch entries -> + entries + |> List.iter (fun (k, v_opt) -> + match v_opt with + | Some () -> added := k :: !added + | None -> removed := k :: !removed)) + fp; + + (* Step 1: "b" is spuriously marked as a root *) + emit_init (Set ("b", ())); + Printf.printf "After spurious root b: fp=[%s]\n" + (let items = ref [] in + iter (fun k _ -> items := k :: !items) fp; + String.concat ", " (List.sort String.compare !items)); + assert (get fp "b" = Some ()); + + (* Step 2: The real root "root" is added *) + emit_init (Set ("root", ())); + Printf.printf "After true root: fp=[%s]\n" + (let items = ref [] in + iter (fun k _ -> items := k :: !items) fp; + String.concat ", " (List.sort String.compare !items)); + + (* Step 3: Edge root -> a is added *) + emit_edges (Set ("root", ["a"])); + Printf.printf "After edge root->a: fp=[%s]\n" + (let items = ref [] in + iter (fun k _ -> items := k :: !items) fp; + String.concat ", " (List.sort String.compare !items)); + assert (get fp "a" = Some ()); + + (* Step 4: Edge a -> b is added *) + emit_edges (Set ("a", ["b"])); + Printf.printf "After edge a->b: fp=[%s]\n" + (let items = ref [] in + iter (fun k _ -> items := k :: !items) fp; + String.concat ", " (List.sort String.compare !items)); + + assert (length fp = 3); + + added := []; + removed := []; + + (* Step 5: The spurious root "b" is REMOVED from init *) + emit_init (Remove "b"); + + Printf.printf "After removing b from init: fp=[%s]\n" + (let items = ref [] in + iter (fun k _ -> items := k :: !items) fp; + String.concat ", " (List.sort String.compare !items)); + Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); + + (* b should NOT be removed - still reachable via a *) + assert (not (List.mem "b" !removed)); + assert (get fp "b" = Some ()); + assert (length fp = 3); + + Printf.printf "PASSED\n\n" + +let test_fixpoint_remove_edge_entry_alternative_source () = + reset (); + Printf.printf + "=== Test: fixpoint remove edge entry (alternative source) ===\n"; + + let init, emit_init = source ~name:"init" () in + let edges, emit_edges = source ~name:"edges" () in + + (* Set up initial edges: a -> b, c -> b *) + emit_edges (Set ("a", ["b"])); + emit_edges (Set ("c", ["b"])); + + let fp = fixpoint ~name:"fp" ~init ~edges () in + + (* Track changes *) + let removed = ref [] in + subscribe + (function + | Remove k -> removed := k :: !removed + | Batch entries -> + List.iter + (fun (k, v_opt) -> if v_opt = None then removed := k :: !removed) + entries + | _ -> ()) + fp; + + (* Add roots a and c *) + emit_init (Set ("a", ())); + emit_init (Set ("c", ())); + + Printf.printf "Initial: fp=[%s]\n" + (let items = ref [] in + iter (fun k _ -> items := k :: !items) fp; + String.concat ", " (List.sort String.compare !items)); + + assert (length fp = 3); + + removed := []; + + (* Remove entire edge entry for "a" *) + emit_edges (Remove "a"); + + Printf.printf "After Remove edge entry 'a': fp=[%s]\n" + (let items = ref [] in + iter (fun k _ -> items := k :: !items) fp; + String.concat ", " (List.sort String.compare !items)); + Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); + + (* b should NOT be removed - still reachable via c -> b *) + assert (not (List.mem "b" !removed)); + assert (get fp "b" = Some ()); + assert (length fp = 3); + + Printf.printf "PASSED\n\n" + +let test_fixpoint_remove_edge_rederivation () = + reset (); + Printf.printf "=== Test: fixpoint remove edge (re-derivation needed) ===\n"; + + let init, emit_init = source ~name:"init" () in + let edges, emit_edges = source ~name:"edges" () in + + let fp = fixpoint ~name:"fp" ~init ~edges () in + + (* Track changes *) + let removed = ref [] in + let added = ref [] in + subscribe + (function + | Remove k -> removed := k :: !removed + | Set (k, ()) -> added := k :: !added + | Batch entries -> + entries + |> List.iter (fun (k, v_opt) -> + match v_opt with + | Some () -> added := k :: !added + | None -> removed := k :: !removed)) + fp; + + (* Add root *) + emit_init (Set ("root", ())); + + (* Build graph: root -> a -> b -> c, a -> c *) + emit_edges (Set ("root", ["a"])); + emit_edges (Set ("a", ["b"; "c"])); + emit_edges (Set ("b", ["c"])); + + Printf.printf "Initial: fp=[%s]\n" + (let items = ref [] in + iter (fun k _ -> items := k :: !items) fp; + String.concat ", " (List.sort String.compare !items)); + + assert (length fp = 4); + + removed := []; + added := []; + + (* Remove the direct edge a -> c *) + emit_edges (Set ("a", ["b"])); + + Printf.printf "After removing a->c: fp=[%s]\n" + (let items = ref [] in + iter (fun k _ -> items := k :: !items) fp; + String.concat ", " (List.sort String.compare !items)); + Printf.printf "Removed: [%s], Added: [%s]\n" + (String.concat ", " !removed) + (String.concat ", " !added); + + (* c should still be in fixpoint - reachable via root -> a -> b -> c *) + assert (get fp "c" = Some ()); + assert (length fp = 4); + + Printf.printf "PASSED\n\n" + +let test_fixpoint_remove_edge_entry_rederivation () = + reset (); + Printf.printf "=== Test: fixpoint Remove edge entry (re-derivation) ===\n"; + + let init, emit_init = source ~name:"init" () in + let edges, emit_edges = source ~name:"edges" () in + + (* Set up edges before creating fixpoint *) + emit_edges (Set ("a", ["c"])); + emit_edges (Set ("b", ["c"])); + + let fp = fixpoint ~name:"fp" ~init ~edges () in + + (* Track changes *) + let removed = ref [] in + subscribe + (function + | Remove k -> removed := k :: !removed + | Batch entries -> + List.iter + (fun (k, v_opt) -> if v_opt = None then removed := k :: !removed) + entries + | _ -> ()) + fp; + + (* Add roots a and b *) + emit_init (Set ("a", ())); + emit_init (Set ("b", ())); + + Printf.printf "Initial: fp=[%s]\n" + (let items = ref [] in + iter (fun k _ -> items := k :: !items) fp; + String.concat ", " (List.sort String.compare !items)); + + assert (length fp = 3); + + removed := []; + + (* Remove entire edge entry for "a" using Remove delta *) + emit_edges (Remove "a"); + + Printf.printf "After Remove 'a' entry: fp=[%s]\n" + (let items = ref [] in + iter (fun k _ -> items := k :: !items) fp; + String.concat ", " (List.sort String.compare !items)); + Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); + + (* c should survive - b -> c still exists *) + assert (not (List.mem "c" !removed)); + assert (get fp "c" = Some ()); + assert (length fp = 3); + + Printf.printf "PASSED\n\n" + +let test_fixpoint_remove_edge_entry_higher_rank_support () = + reset (); + Printf.printf "=== Test: fixpoint edge removal (higher rank support) ===\n"; + + let init, emit_init = source ~name:"init" () in + let edges, emit_edges = source ~name:"edges" () in + + let fp = fixpoint ~name:"fp" ~init ~edges () in + + (* Track changes *) + let removed = ref [] in + let added = ref [] in + subscribe + (function + | Remove k -> removed := k :: !removed + | Set (k, ()) -> added := k :: !added + | Batch entries -> + entries + |> List.iter (fun (k, v_opt) -> + match v_opt with + | Some () -> added := k :: !added + | None -> removed := k :: !removed)) + fp; + + (* Add root *) + emit_init (Set ("root", ())); + + (* Build graph: root -> a -> b -> c, a -> c *) + emit_edges (Set ("root", ["a"])); + emit_edges (Set ("a", ["b"; "c"])); + emit_edges (Set ("b", ["c"])); + + Printf.printf "Initial: fp=[%s]\n" + (let items = ref [] in + iter (fun k _ -> items := k :: !items) fp; + String.concat ", " (List.sort String.compare !items)); + + assert (length fp = 4); + assert (get fp "c" = Some ()); + + removed := []; + added := []; + + (* Remove direct edge a -> c, keeping a -> b *) + emit_edges (Set ("a", ["b"])); + + Printf.printf "After removing a->c: fp=[%s]\n" + (let items = ref [] in + iter (fun k _ -> items := k :: !items) fp; + String.concat ", " (List.sort String.compare !items)); + Printf.printf "Removed: [%s], Added: [%s]\n" + (String.concat ", " !removed) + (String.concat ", " !added); + + (* c should still be in fixpoint via root -> a -> b -> c *) + assert (get fp "c" = Some ()); + assert (length fp = 4); + + Printf.printf "PASSED\n\n" + +let test_fixpoint_remove_edge_entry_needs_rederivation () = + reset (); + Printf.printf + "=== Test: fixpoint Remove edge entry (needs re-derivation) ===\n"; + + let init, emit_init = source ~name:"init" () in + let edges, emit_edges = source ~name:"edges" () in + + (* Pre-populate edges so fixpoint initializes with them *) + emit_edges (Set ("r", ["a"; "b"])); + emit_edges (Set ("a", ["y"])); + emit_edges (Set ("b", ["c"])); + emit_edges (Set ("c", ["x"])); + emit_edges (Set ("x", ["y"])); + + let fp = fixpoint ~name:"fp" ~init ~edges () in + + (* Make r live *) + emit_init (Set ("r", ())); + + (* Sanity: y initially reachable via short path *) + assert (get fp "y" = Some ()); + assert (get fp "x" = Some ()); + + let removed = ref [] in + subscribe + (function + | Remove k -> removed := k :: !removed + | Batch entries -> + List.iter + (fun (k, v_opt) -> if v_opt = None then removed := k :: !removed) + entries + | _ -> ()) + fp; + + (* Remove the entire edge entry for a (removes a->y) *) + emit_edges (Remove "a"); + + Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); + + (* Correct: y is still reachable via r->b->c->x->y *) + assert (get fp "y" = Some ()); + + Printf.printf "PASSED\n\n" + +let test_fixpoint_remove_base_needs_rederivation () = + reset (); + Printf.printf + "=== Test: fixpoint Remove base element (needs re-derivation) ===\n"; + + let init, emit_init = source ~name:"init" () in + let edges, emit_edges = source ~name:"edges" () in + + (* Pre-populate edges so fixpoint initializes with them *) + emit_edges (Set ("r1", ["a"])); + emit_edges (Set ("a", ["y"])); + emit_edges (Set ("r2", ["b"])); + emit_edges (Set ("b", ["c"])); + emit_edges (Set ("c", ["x"])); + emit_edges (Set ("x", ["y"])); + + let fp = fixpoint ~name:"fp" ~init ~edges () in + + emit_init (Set ("r1", ())); + emit_init (Set ("r2", ())); + + (* Sanity: y initially reachable *) + assert (get fp "y" = Some ()); + assert (get fp "x" = Some ()); + + let removed = ref [] in + subscribe + (function + | Remove k -> removed := k :: !removed + | Batch entries -> + List.iter + (fun (k, v_opt) -> if v_opt = None then removed := k :: !removed) + entries + | _ -> ()) + fp; + + (* Remove r1 from base: y should remain via r2 path *) + emit_init (Remove "r1"); + + Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); + + assert (get fp "y" = Some ()); + Printf.printf "PASSED\n\n" + +let run_all () = + Printf.printf "\n====== Fixpoint Incremental Tests ======\n\n"; + test_fixpoint_add_base (); + test_fixpoint_remove_base (); + test_fixpoint_add_edge (); + test_fixpoint_remove_edge (); + test_fixpoint_cycle_removal (); + test_fixpoint_alternative_support (); + test_fixpoint_deltas (); + test_fixpoint_remove_spurious_root (); + test_fixpoint_remove_edge_entry_alternative_source (); + test_fixpoint_remove_edge_rederivation (); + test_fixpoint_remove_edge_entry_rederivation (); + test_fixpoint_remove_edge_entry_higher_rank_support (); + test_fixpoint_remove_edge_entry_needs_rederivation (); + test_fixpoint_remove_base_needs_rederivation () diff --git a/analysis/reactive/test/FlatMapTest.ml b/analysis/reactive/test/FlatMapTest.ml new file mode 100644 index 0000000000..b9d2050469 --- /dev/null +++ b/analysis/reactive/test/FlatMapTest.ml @@ -0,0 +1,166 @@ +(** FlatMap combinator tests *) + +open Reactive +open TestHelpers + +let test_flatmap_basic () = + reset (); + Printf.printf "=== Test: flatMap basic ===\n"; + + (* Create a simple source collection *) + let source, emit = source ~name:"source" () in + + (* Create derived collection via flatMap *) + let derived = + flatMap ~name:"derived" source + ~f:(fun key value -> + [(key * 10, value); ((key * 10) + 1, value); ((key * 10) + 2, value)]) + () + in + + (* Add entry -> derived should have 3 entries *) + emit (Set (1, "a")); + Printf.printf "After Set(1, 'a'): derived has %d entries\n" (length derived); + assert (length derived = 3); + assert (get source 1 = Some "a"); + (* Check source was updated *) + assert (get derived 10 = Some "a"); + assert (get derived 11 = Some "a"); + assert (get derived 12 = Some "a"); + + (* Add another entry *) + emit (Set (2, "b")); + Printf.printf "After Set(2, 'b'): derived has %d entries\n" (length derived); + assert (length derived = 6); + + (* Update entry *) + emit (Set (1, "A")); + Printf.printf "After Set(1, 'A'): derived has %d entries\n" (length derived); + assert (get derived 10 = Some "A"); + assert (length derived = 6); + + (* Remove entry *) + emit (Remove 1); + Printf.printf "After Remove(1): derived has %d entries\n" (length derived); + assert (length derived = 3); + assert (get derived 10 = None); + assert (get derived 20 = Some "b"); + + Printf.printf "PASSED\n\n" + +let test_flatmap_with_merge () = + reset (); + Printf.printf "=== Test: flatMap with merge ===\n"; + + let source, emit = source ~name:"source" () in + + (* Create derived with merge *) + let derived = + flatMap ~name:"derived" source + ~f:(fun _key values -> [(0, values)]) (* all contribute to key 0 *) + ~merge:IntSet.union () + in + + (* Source 1 contributes {1, 2} *) + emit (Set (1, IntSet.of_list [1; 2])); + let v = get derived 0 |> Option.get in + Printf.printf "After source 1: {%s}\n" + (IntSet.elements v |> List.map string_of_int |> String.concat ", "); + assert (IntSet.equal v (IntSet.of_list [1; 2])); + + (* Source 2 contributes {3, 4} -> should merge *) + emit (Set (2, IntSet.of_list [3; 4])); + let v = get derived 0 |> Option.get in + Printf.printf "After source 2: {%s}\n" + (IntSet.elements v |> List.map string_of_int |> String.concat ", "); + assert (IntSet.equal v (IntSet.of_list [1; 2; 3; 4])); + + (* Remove source 1 *) + emit (Remove 1); + let v = get derived 0 |> Option.get in + Printf.printf "After remove 1: {%s}\n" + (IntSet.elements v |> List.map string_of_int |> String.concat ", "); + assert (IntSet.equal v (IntSet.of_list [3; 4])); + + Printf.printf "PASSED\n\n" + +let test_composition () = + reset (); + Printf.printf "=== Test: composition (flatMap chain) ===\n"; + + (* Source: file -> list of items *) + let source, emit = source ~name:"source" () in + + (* First flatMap: file -> items *) + let items = + flatMap ~name:"items" source + ~f:(fun path items -> + List.mapi (fun i item -> (Printf.sprintf "%s:%d" path i, item)) items) + () + in + + (* Second flatMap: item -> chars *) + let chars = + flatMap ~name:"chars" items + ~f:(fun key value -> + String.to_seq value + |> Seq.mapi (fun i c -> (Printf.sprintf "%s:%d" key i, c)) + |> List.of_seq) + () + in + + (* Add file with 2 items *) + emit (Set ("file1", ["ab"; "cd"])); + Printf.printf "After file1: items=%d, chars=%d\n" (length items) + (length chars); + assert (length items = 2); + assert (length chars = 4); + + (* Add another file *) + emit (Set ("file2", ["xyz"])); + Printf.printf "After file2: items=%d, chars=%d\n" (length items) + (length chars); + assert (length items = 3); + assert (length chars = 7); + + (* Update file1 *) + emit (Set ("file1", ["a"])); + Printf.printf "After update file1: items=%d, chars=%d\n" (length items) + (length chars); + assert (length items = 2); + (* 1 from file1 + 1 from file2 *) + assert (length chars = 4); + + (* 1 from file1 + 3 from file2 *) + Printf.printf "PASSED\n\n" + +let test_flatmap_on_existing_data () = + reset (); + Printf.printf "=== Test: flatMap on collection with existing data ===\n"; + + (* Create source and add data before creating flatMap *) + let source, emit = source ~name:"source" () in + emit (Set (1, "a")); + emit (Set (2, "b")); + + Printf.printf "Source has %d entries before flatMap\n" (length source); + + (* Create flatMap AFTER source has data *) + let derived = + flatMap ~name:"derived" source ~f:(fun k v -> [(k * 10, v)]) () + in + + (* Check derived has existing data *) + Printf.printf "Derived has %d entries (expected 2)\n" (length derived); + assert (length derived = 2); + assert (get derived 10 = Some "a"); + assert (get derived 20 = Some "b"); + + Printf.printf "PASSED\n\n" + +let run_all () = + Printf.printf "\n====== FlatMap Tests ======\n\n"; + test_flatmap_basic (); + test_flatmap_with_merge (); + test_composition (); + test_flatmap_on_existing_data () diff --git a/analysis/reactive/test/GlitchFreeTest.ml b/analysis/reactive/test/GlitchFreeTest.ml new file mode 100644 index 0000000000..3954075877 --- /dev/null +++ b/analysis/reactive/test/GlitchFreeTest.ml @@ -0,0 +1,289 @@ +(** Tests for glitch-free semantics with the accumulate-then-propagate scheduler *) + +open Reactive + +type file_data = {refs: (string * string) list; decl_positions: string list} +(** Type for file data *) + +type full_file_data = { + value_refs: (string * string) list; + exception_refs: (string * string) list; + full_decls: string list; +} +(** Type for full file data *) + +(** Track all deltas received *) +let track_deltas c = + let received = ref [] in + c.subscribe (fun d -> received := d :: !received); + received + +(** Count adds and removes *) +let count_delta = function + | Set _ -> (1, 0) + | Remove _ -> (0, 1) + | Batch entries -> + List.fold_left + (fun (a, r) (_, v_opt) -> + match v_opt with + | Some _ -> (a + 1, r) + | None -> (a, r + 1)) + (0, 0) entries + +let sum_deltas deltas = + List.fold_left + (fun (ta, tr) d -> + let a, r = count_delta d in + (ta + a, tr + r)) + (0, 0) deltas + +(** Test: Same source anti-join - no removals expected *) +let test_same_source_anti_join () = + reset (); + Printf.printf "=== Test: same source anti-join ===\n"; + + let src, emit = source ~name:"source" () in + + let refs = + flatMap ~name:"refs" src ~f:(fun _file (data : file_data) -> data.refs) () + in + + let decls = + flatMap ~name:"decls" src + ~f:(fun _file (data : file_data) -> + List.map (fun pos -> (pos, ())) data.decl_positions) + () + in + + let external_refs = + join ~name:"external_refs" refs decls + ~key_of:(fun posFrom _posTo -> posFrom) + ~f:(fun _posFrom posTo decl_opt -> + match decl_opt with + | Some () -> [] + | None -> [(posTo, ())]) + ~merge:(fun () () -> ()) + () + in + + let deltas = track_deltas external_refs in + + emit + (Batch + [ + set "file1" + {refs = [("A", "X"); ("B", "Y")]; decl_positions = ["A"; "B"]}; + set "file2" {refs = [("C", "Z")]; decl_positions = []}; + ]); + + let adds, removes = sum_deltas !deltas in + Printf.printf "adds=%d, removes=%d, len=%d\n" adds removes + (length external_refs); + + assert (removes = 0); + assert (length external_refs = 1); + Printf.printf "PASSED\n\n" + +(** Test: Multi-level union - the problematic case for glitch-free *) +let test_multi_level_union () = + reset (); + Printf.printf "=== Test: multi-level union ===\n"; + + let src, emit = source ~name:"source" () in + + (* refs1: level 1 *) + let refs1 = + flatMap ~name:"refs1" src + ~f:(fun _file (data : file_data) -> + List.filter (fun (k, _) -> String.length k > 0 && k.[0] = 'D') data.refs) + () + in + + (* intermediate: level 1 *) + let intermediate = + flatMap ~name:"intermediate" src + ~f:(fun _file (data : file_data) -> + List.filter (fun (k, _) -> String.length k > 0 && k.[0] = 'I') data.refs) + () + in + + (* refs2: level 2 *) + let refs2 = flatMap ~name:"refs2" intermediate ~f:(fun k v -> [(k, v)]) () in + + (* decls: level 1 *) + let decls = + flatMap ~name:"decls" src + ~f:(fun _file (data : file_data) -> + List.map (fun pos -> (pos, ())) data.decl_positions) + () + in + + (* all_refs: union at level 3 *) + let all_refs = union ~name:"all_refs" refs1 refs2 () in + + (* external_refs: join at level 4 *) + let external_refs = + join ~name:"external_refs" all_refs decls + ~key_of:(fun posFrom _posTo -> posFrom) + ~f:(fun _posFrom posTo decl_opt -> + match decl_opt with + | Some () -> [] + | None -> [(posTo, ())]) + ~merge:(fun () () -> ()) + () + in + + let deltas = track_deltas external_refs in + + emit + (Batch + [ + set "file1" {refs = [("D1", "X"); ("I1", "Y")]; decl_positions = ["D1"]}; + ]); + + let adds, removes = sum_deltas !deltas in + Printf.printf "adds=%d, removes=%d, len=%d\n" adds removes + (length external_refs); + + assert (removes = 0); + assert (length external_refs = 1); + Printf.printf "PASSED\n\n" + +(** Test: Real pipeline simulation - mimics ReactiveLiveness *) +let test_real_pipeline_simulation () = + reset (); + Printf.printf "=== Test: real pipeline simulation ===\n"; + + let src, emit = source ~name:"source" () in + + (* decls: level 1 *) + let decls = + flatMap ~name:"decls" src + ~f:(fun _file (data : full_file_data) -> + List.map (fun pos -> (pos, ())) data.full_decls) + () + in + + (* merged_value_refs: level 1 *) + let merged_value_refs = + flatMap ~name:"merged_value_refs" src + ~f:(fun _file (data : full_file_data) -> data.value_refs) + () + in + + (* exception_refs_raw: level 1 *) + let exception_refs_raw = + flatMap ~name:"exception_refs_raw" src + ~f:(fun _file (data : full_file_data) -> data.exception_refs) + () + in + + (* exception_decls: level 2 *) + let exception_decls = + flatMap ~name:"exception_decls" decls + ~f:(fun pos () -> + if String.length pos > 0 && pos.[0] = 'E' then [(pos, ())] else []) + () + in + + (* resolved_exception_refs: join at level 3 *) + let resolved_exception_refs = + join ~name:"resolved_exception_refs" exception_refs_raw exception_decls + ~key_of:(fun path _loc -> path) + ~f:(fun path loc decl_opt -> + match decl_opt with + | Some () -> [(path, loc)] + | None -> []) + () + in + + (* resolved_refs_from: level 4 *) + let resolved_refs_from = + flatMap ~name:"resolved_refs_from" resolved_exception_refs + ~f:(fun posTo posFrom -> [(posFrom, posTo)]) + () + in + + (* value_refs_from: union at level 5 *) + let value_refs_from = + union ~name:"value_refs_from" merged_value_refs resolved_refs_from () + in + + (* external_value_refs: join at level 6 *) + let external_value_refs = + join ~name:"external_value_refs" value_refs_from decls + ~key_of:(fun posFrom _posTo -> posFrom) + ~f:(fun _posFrom posTo decl_opt -> + match decl_opt with + | Some () -> [] + | None -> [(posTo, ())]) + ~merge:(fun () () -> ()) + () + in + + let deltas = track_deltas external_value_refs in + + emit + (Batch + [ + set "file1" + { + value_refs = [("A", "X")]; + exception_refs = [("E1", "Y")]; + full_decls = ["A"; "E1"]; + }; + ]); + + let _adds, removes = sum_deltas !deltas in + Printf.printf "removes=%d, len=%d\n" removes (length external_value_refs); + + assert (removes = 0); + Printf.printf "PASSED\n\n" + +(** Test: Separate sources - removals are expected here *) +let test_separate_sources () = + reset (); + Printf.printf "=== Test: separate sources (removals expected) ===\n"; + + let refs_src, emit_refs = source ~name:"refs_source" () in + let decls_src, emit_decls = source ~name:"decls_source" () in + + let external_refs = + join ~name:"external_refs" refs_src decls_src + ~key_of:(fun posFrom _posTo -> posFrom) + ~f:(fun _posFrom posTo decl_opt -> + match decl_opt with + | Some () -> [] + | None -> [(posTo, ())]) + ~merge:(fun () () -> ()) + () + in + + let deltas = track_deltas external_refs in + + (* Refs arrive first *) + emit_refs (Batch [set "A" "X"; set "B" "Y"; set "C" "Z"]); + + let adds1, _ = sum_deltas !deltas in + Printf.printf "After refs: adds=%d, len=%d\n" adds1 (length external_refs); + + (* Decls arrive second - causes removals *) + emit_decls (Batch [set "A" (); set "B" ()]); + + let adds2, removes2 = sum_deltas !deltas in + Printf.printf "After decls: adds=%d, removes=%d, len=%d\n" adds2 removes2 + (length external_refs); + + (* With separate sources, removals are expected and correct *) + assert (removes2 = 2); + (* X and Y removed *) + assert (length external_refs = 1); + (* Only Z remains *) + Printf.printf "PASSED\n\n" + +let run_all () = + Printf.printf "\n====== Glitch-Free Tests ======\n\n"; + test_same_source_anti_join (); + test_multi_level_union (); + test_real_pipeline_simulation (); + test_separate_sources () diff --git a/analysis/reactive/test/IntegrationTest.ml b/analysis/reactive/test/IntegrationTest.ml new file mode 100644 index 0000000000..428a1b2f8e --- /dev/null +++ b/analysis/reactive/test/IntegrationTest.ml @@ -0,0 +1,85 @@ +(** End-to-end integration tests *) + +open Reactive +open TestHelpers + +let test_file_collection () = + reset (); + Printf.printf "=== Test: File collection simulation ===\n"; + + (* Simulate file processing with regular sources *) + let files, emit_file = source ~name:"files" () in + + (* file_a: hello(2), world(1) *) + (* file_b: hello(1), foo(1) *) + + (* First flatMap: aggregate word counts across files with merge *) + let word_counts = + flatMap ~name:"word_counts" files + ~f:(fun _path counts -> StringMap.bindings counts) + (* Each file contributes its word counts *) + ~merge:( + ) (* Sum counts from multiple files *) + () + in + + (* Second flatMap: filter to words with count >= 2 *) + let frequent_words = + flatMap ~name:"frequent_words" word_counts + ~f:(fun word count -> if count >= 2 then [(word, count)] else []) + () + in + + (* Simulate processing files by emitting their word counts *) + let counts_a = + StringMap.empty |> StringMap.add "hello" 2 |> StringMap.add "world" 1 + in + let counts_b = + StringMap.empty |> StringMap.add "hello" 1 |> StringMap.add "foo" 1 + in + emit_file (Set ("file_a", counts_a)); + emit_file (Set ("file_b", counts_b)); + + Printf.printf "Word counts:\n"; + iter (fun word count -> Printf.printf " %s: %d\n" word count) word_counts; + + Printf.printf "Frequent words (count >= 2):\n"; + iter (fun word count -> Printf.printf " %s: %d\n" word count) frequent_words; + + (* Verify: hello=3 (2 from a + 1 from b), world=1, foo=1 *) + assert (get word_counts "hello" = Some 3); + assert (get word_counts "world" = Some 1); + assert (get word_counts "foo" = Some 1); + assert (length word_counts = 3); + + (* Verify frequent: only "hello" with count 3 *) + assert (length frequent_words = 1); + assert (get frequent_words "hello" = Some 3); + + (* Modify file_a: now hello(1), world(2) *) + Printf.printf "\nModifying file_a...\n"; + let counts_a' = + StringMap.empty |> StringMap.add "hello" 1 |> StringMap.add "world" 2 + in + emit_file (Set ("file_a", counts_a')); + + Printf.printf "Word counts after modification:\n"; + iter (fun word count -> Printf.printf " %s: %d\n" word count) word_counts; + + Printf.printf "Frequent words after modification:\n"; + iter (fun word count -> Printf.printf " %s: %d\n" word count) frequent_words; + + (* Verify: hello=2 (1 from a + 1 from b), world=2, foo=1 *) + assert (get word_counts "hello" = Some 2); + assert (get word_counts "world" = Some 2); + assert (get word_counts "foo" = Some 1); + + (* Verify frequent: hello=2, world=2 *) + assert (length frequent_words = 2); + assert (get frequent_words "hello" = Some 2); + assert (get frequent_words "world" = Some 2); + + Printf.printf "PASSED\n\n" + +let run_all () = + Printf.printf "\n====== Integration Tests ======\n\n"; + test_file_collection () diff --git a/analysis/reactive/test/JoinTest.ml b/analysis/reactive/test/JoinTest.ml new file mode 100644 index 0000000000..70c4eb6136 --- /dev/null +++ b/analysis/reactive/test/JoinTest.ml @@ -0,0 +1,122 @@ +(** Join combinator tests *) + +open Reactive + +let test_join () = + reset (); + Printf.printf "=== Test: join (reactive lookup/join) ===\n"; + + (* Left collection: exception refs (path -> loc_from) *) + let left, emit_left = source ~name:"left" () in + + (* Right collection: decl index (path -> decl_pos) *) + let right, emit_right = source ~name:"right" () in + + (* Join: for each (path, loc_from) in left, look up path in right *) + let joined = + join ~name:"joined" left right + ~key_of:(fun path _loc_from -> path) + ~f:(fun _path loc_from decl_pos_opt -> + match decl_pos_opt with + | Some decl_pos -> + (* Produce (decl_pos, loc_from) pairs *) + [(decl_pos, loc_from)] + | None -> []) + () + in + + (* Initially empty *) + assert (length joined = 0); + + (* Add declaration at path "A" with pos 100 *) + emit_right (Set ("A", 100)); + Printf.printf "After right Set(A, 100): joined=%d\n" (length joined); + assert (length joined = 0); + + (* No left entries yet *) + + (* Add exception ref at path "A" from loc 1 *) + emit_left (Set ("A", 1)); + Printf.printf "After left Set(A, 1): joined=%d\n" (length joined); + assert (length joined = 1); + assert (get joined 100 = Some 1); + + (* decl_pos 100 -> loc_from 1 *) + + (* Add another exception ref at path "B" (no matching decl) *) + emit_left (Set ("B", 2)); + Printf.printf "After left Set(B, 2): joined=%d (B has no decl)\n" + (length joined); + assert (length joined = 1); + + (* Add declaration for path "B" *) + emit_right (Set ("B", 200)); + Printf.printf "After right Set(B, 200): joined=%d\n" (length joined); + assert (length joined = 2); + assert (get joined 200 = Some 2); + + (* Update right: change B's decl_pos *) + emit_right (Set ("B", 201)); + Printf.printf "After right Set(B, 201): joined=%d\n" (length joined); + assert (length joined = 2); + assert (get joined 200 = None); + (* Old key gone *) + assert (get joined 201 = Some 2); + + (* New key has the value *) + + (* Remove left entry A *) + emit_left (Remove "A"); + Printf.printf "After left Remove(A): joined=%d\n" (length joined); + assert (length joined = 1); + assert (get joined 100 = None); + + Printf.printf "PASSED\n\n" + +let test_join_with_merge () = + reset (); + Printf.printf "=== Test: join with merge ===\n"; + + (* Multiple left entries can map to same right key *) + let left, emit_left = source ~name:"left" () in + let right, emit_right = source ~name:"right" () in + + (* Join with merge: all entries produce to key 0 *) + let joined = + join ~name:"joined" left right + ~key_of:(fun _id path -> path) (* Look up by path *) + ~f:(fun _id _path value_opt -> + match value_opt with + | Some v -> [(0, v)] (* All contribute to key 0 *) + | None -> []) + ~merge:( + ) (* Sum values *) + () + in + + emit_right (Set ("X", 10)); + emit_left (Set (1, "X")); + emit_left (Set (2, "X")); + + Printf.printf "Two entries looking up X (value 10): sum=%d\n" + (get joined 0 |> Option.value ~default:0); + assert (get joined 0 = Some 20); + + (* 10 + 10 *) + emit_right (Set ("X", 5)); + Printf.printf "After right changes to 5: sum=%d\n" + (get joined 0 |> Option.value ~default:0); + assert (get joined 0 = Some 10); + + (* 5 + 5 *) + emit_left (Remove 1); + Printf.printf "After removing one left entry: sum=%d\n" + (get joined 0 |> Option.value ~default:0); + assert (get joined 0 = Some 5); + + (* Only one left *) + Printf.printf "PASSED\n\n" + +let run_all () = + Printf.printf "\n====== Join Tests ======\n\n"; + test_join (); + test_join_with_merge () diff --git a/analysis/reactive/test/ReactiveTest.ml b/analysis/reactive/test/ReactiveTest.ml index aeb777cdd2..e94162f2b1 100644 --- a/analysis/reactive/test/ReactiveTest.ml +++ b/analysis/reactive/test/ReactiveTest.ml @@ -1,1854 +1,13 @@ -(** Tests for Reactive collections *) - -open Reactive - -(** {1 Helper functions} *) - -let read_lines path = - let ic = open_in path in - let lines = ref [] in - (try - while true do - lines := input_line ic :: !lines - done - with End_of_file -> ()); - close_in ic; - List.rev !lines - -let write_lines path lines = - let oc = open_out path in - List.iter (fun line -> output_string oc (line ^ "\n")) lines; - close_out oc - -(** {1 Tests} *) - -let test_flatmap_basic () = - Printf.printf "=== Test: flatMap basic ===\n"; - - (* Create a simple source collection *) - let data : (int, string) Hashtbl.t = Hashtbl.create 16 in - let subscribers : ((int, string) delta -> unit) list ref = ref [] in - - let source : (int, string) t = - { - subscribe = (fun h -> subscribers := h :: !subscribers); - iter = (fun f -> Hashtbl.iter f data); - get = (fun k -> Hashtbl.find_opt data k); - length = (fun () -> Hashtbl.length data); - stats = create_stats (); - } - in - - let emit delta = - apply_delta data delta; - List.iter (fun h -> h delta) !subscribers - in - - (* Create derived collection via flatMap *) - let derived = - flatMap source - ~f:(fun key value -> - [(key * 10, value); ((key * 10) + 1, value); ((key * 10) + 2, value)]) - () - in - - (* Add entry -> derived should have 3 entries *) - emit (Set (1, "a")); - Printf.printf "After Set(1, 'a'): derived has %d entries\n" (length derived); - assert (length derived = 3); - assert (get derived 10 = Some "a"); - assert (get derived 11 = Some "a"); - assert (get derived 12 = Some "a"); - - (* Add another entry *) - emit (Set (2, "b")); - Printf.printf "After Set(2, 'b'): derived has %d entries\n" (length derived); - assert (length derived = 6); - - (* Update entry *) - emit (Set (1, "A")); - Printf.printf "After Set(1, 'A'): derived has %d entries\n" (length derived); - assert (get derived 10 = Some "A"); - assert (length derived = 6); - - (* Remove entry *) - emit (Remove 1); - Printf.printf "After Remove(1): derived has %d entries\n" (length derived); - assert (length derived = 3); - assert (get derived 10 = None); - assert (get derived 20 = Some "b"); - - Printf.printf "PASSED\n\n" - -module IntSet = Set.Make (Int) - -let test_flatmap_with_merge () = - Printf.printf "=== Test: flatMap with merge ===\n"; - - let data : (int, IntSet.t) Hashtbl.t = Hashtbl.create 16 in - let subscribers : ((int, IntSet.t) delta -> unit) list ref = ref [] in - - let source : (int, IntSet.t) t = - { - subscribe = (fun h -> subscribers := h :: !subscribers); - iter = (fun f -> Hashtbl.iter f data); - get = (fun k -> Hashtbl.find_opt data k); - length = (fun () -> Hashtbl.length data); - stats = create_stats (); - } - in - - let emit delta = - apply_delta data delta; - List.iter (fun h -> h delta) !subscribers - in - - (* Create derived with merge *) - let derived = - flatMap source - ~f:(fun _key values -> [(0, values)]) (* all contribute to key 0 *) - ~merge:IntSet.union () - in - - (* Source 1 contributes {1, 2} *) - emit (Set (1, IntSet.of_list [1; 2])); - let v = get derived 0 |> Option.get in - Printf.printf "After source 1: {%s}\n" - (IntSet.elements v |> List.map string_of_int |> String.concat ", "); - assert (IntSet.equal v (IntSet.of_list [1; 2])); - - (* Source 2 contributes {3, 4} -> should merge *) - emit (Set (2, IntSet.of_list [3; 4])); - let v = get derived 0 |> Option.get in - Printf.printf "After source 2: {%s}\n" - (IntSet.elements v |> List.map string_of_int |> String.concat ", "); - assert (IntSet.equal v (IntSet.of_list [1; 2; 3; 4])); - - (* Remove source 1 *) - emit (Remove 1); - let v = get derived 0 |> Option.get in - Printf.printf "After remove 1: {%s}\n" - (IntSet.elements v |> List.map string_of_int |> String.concat ", "); - assert (IntSet.equal v (IntSet.of_list [3; 4])); - - Printf.printf "PASSED\n\n" - -let test_composition () = - Printf.printf "=== Test: composition (flatMap chain) ===\n"; - - (* Source: file -> list of items *) - let data : (string, string list) Hashtbl.t = Hashtbl.create 16 in - let subscribers : ((string, string list) delta -> unit) list ref = ref [] in - - let source : (string, string list) t = - { - subscribe = (fun h -> subscribers := h :: !subscribers); - iter = (fun f -> Hashtbl.iter f data); - get = (fun k -> Hashtbl.find_opt data k); - length = (fun () -> Hashtbl.length data); - stats = create_stats (); - } - in - - let emit delta = - apply_delta data delta; - List.iter (fun h -> h delta) !subscribers - in - - (* First flatMap: file -> items *) - let items = - flatMap source - ~f:(fun path items -> - List.mapi (fun i item -> (Printf.sprintf "%s:%d" path i, item)) items) - () - in - - (* Second flatMap: item -> chars *) - let chars = - flatMap items - ~f:(fun key value -> - String.to_seq value - |> Seq.mapi (fun i c -> (Printf.sprintf "%s:%d" key i, c)) - |> List.of_seq) - () - in - - (* Add file with 2 items *) - emit (Set ("file1", ["ab"; "cd"])); - Printf.printf "After file1: items=%d, chars=%d\n" (length items) - (length chars); - assert (length items = 2); - assert (length chars = 4); - - (* Add another file *) - emit (Set ("file2", ["xyz"])); - Printf.printf "After file2: items=%d, chars=%d\n" (length items) - (length chars); - assert (length items = 3); - assert (length chars = 7); - - (* Update file1 *) - emit (Set ("file1", ["a"])); - Printf.printf "After update file1: items=%d, chars=%d\n" (length items) - (length chars); - assert (length items = 2); - (* 1 from file1 + 1 from file2 *) - assert (length chars = 4); - - (* 1 from file1 + 3 from file2 *) - Printf.printf "PASSED\n\n" - -let test_flatmap_on_existing_data () = - Printf.printf "=== Test: flatMap on collection with existing data ===\n"; - - (* Create source with data already in it *) - let data : (int, string) Hashtbl.t = Hashtbl.create 16 in - Hashtbl.add data 1 "a"; - Hashtbl.add data 2 "b"; - - let subscribers : ((int, string) delta -> unit) list ref = ref [] in - - let source : (int, string) t = - { - subscribe = (fun h -> subscribers := h :: !subscribers); - iter = (fun f -> Hashtbl.iter f data); - get = (fun k -> Hashtbl.find_opt data k); - length = (fun () -> Hashtbl.length data); - stats = create_stats (); - } - in - - Printf.printf "Source has %d entries before flatMap\n" (length source); - - (* Create flatMap AFTER source has data *) - let derived = flatMap source ~f:(fun k v -> [(k * 10, v)]) () in - - (* Check derived has existing data *) - Printf.printf "Derived has %d entries (expected 2)\n" (length derived); - assert (length derived = 2); - assert (get derived 10 = Some "a"); - assert (get derived 20 = Some "b"); - - Printf.printf "PASSED\n\n" - -module StringMap = Map.Make (String) - -let test_file_collection () = - Printf.printf "=== Test: ReactiveFileCollection + composition ===\n"; - - (* Create temp files with words *) - let temp_dir = Filename.get_temp_dir_name () in - let file_a = Filename.concat temp_dir "reactive_test_a.txt" in - let file_b = Filename.concat temp_dir "reactive_test_b.txt" in - - (* file_a: hello(2), world(1) *) - write_lines file_a ["hello world"; "hello"]; - (* file_b: hello(1), foo(1) *) - write_lines file_b ["hello foo"]; - - (* Create file collection: file -> word count map *) - let files = - ReactiveFileCollection.create ~read_file:read_lines - ~process:(fun _path lines -> - (* Count words within this file *) - let counts = ref StringMap.empty in - lines - |> List.iter (fun line -> - String.split_on_char ' ' line - |> List.iter (fun word -> - let c = - StringMap.find_opt word !counts - |> Option.value ~default:0 - in - counts := StringMap.add word (c + 1) !counts)); - !counts) - in - - (* First flatMap: aggregate word counts across files with merge *) - let word_counts = - Reactive.flatMap - (ReactiveFileCollection.to_collection files) - ~f:(fun _path counts -> StringMap.bindings counts) - (* Each file contributes its word counts *) - ~merge:( + ) (* Sum counts from multiple files *) - () - in - - (* Second flatMap: filter to words with count >= 2 *) - let frequent_words = - Reactive.flatMap word_counts - ~f:(fun word count -> if count >= 2 then [(word, count)] else []) - () - in - - (* Process files *) - ReactiveFileCollection.process_files files [file_a; file_b]; - - Printf.printf "Word counts:\n"; - word_counts - |> Reactive.iter (fun word count -> Printf.printf " %s: %d\n" word count); - - Printf.printf "Frequent words (count >= 2):\n"; - frequent_words - |> Reactive.iter (fun word count -> Printf.printf " %s: %d\n" word count); - - (* Verify: hello=3 (2 from a + 1 from b), world=1, foo=1 *) - assert (Reactive.get word_counts "hello" = Some 3); - assert (Reactive.get word_counts "world" = Some 1); - assert (Reactive.get word_counts "foo" = Some 1); - assert (Reactive.length word_counts = 3); - - (* Verify frequent: only "hello" with count 3 *) - assert (Reactive.length frequent_words = 1); - assert (Reactive.get frequent_words "hello" = Some 3); - - (* Modify file_a: now hello(1), world(2) *) - Printf.printf "\nModifying file_a...\n"; - write_lines file_a ["world world"; "hello"]; - ReactiveFileCollection.process_files files [file_a]; - - Printf.printf "Word counts after modification:\n"; - Reactive.iter - (fun word count -> Printf.printf " %s: %d\n" word count) - word_counts; - - Printf.printf "Frequent words after modification:\n"; - Reactive.iter - (fun word count -> Printf.printf " %s: %d\n" word count) - frequent_words; - - (* Verify: hello=2 (1 from a + 1 from b), world=2, foo=1 *) - assert (Reactive.get word_counts "hello" = Some 2); - assert (Reactive.get word_counts "world" = Some 2); - assert (Reactive.get word_counts "foo" = Some 1); - - (* Verify frequent: hello=2, world=2 *) - assert (Reactive.length frequent_words = 2); - assert (Reactive.get frequent_words "hello" = Some 2); - assert (Reactive.get frequent_words "world" = Some 2); - - (* Cleanup *) - Sys.remove file_a; - Sys.remove file_b; - - Printf.printf "PASSED\n\n" - -let test_lookup () = - Printf.printf "=== Test: lookup (reactive single-key subscription) ===\n"; - - let data : (string, int) Hashtbl.t = Hashtbl.create 16 in - let subscribers : ((string, int) delta -> unit) list ref = ref [] in - - let source : (string, int) t = - { - subscribe = (fun h -> subscribers := h :: !subscribers); - iter = (fun f -> Hashtbl.iter f data); - get = (fun k -> Hashtbl.find_opt data k); - length = (fun () -> Hashtbl.length data); - stats = create_stats (); - } - in - - let emit delta = - apply_delta data delta; - List.iter (fun h -> h delta) !subscribers - in - - (* Create lookup for key "foo" *) - let foo_lookup = lookup source ~key:"foo" in - - (* Initially empty *) - assert (length foo_lookup = 0); - assert (get foo_lookup "foo" = None); - - (* Set foo=42 *) - emit (Set ("foo", 42)); - Printf.printf "After Set(foo, 42): lookup has %d entries\n" - (length foo_lookup); - assert (length foo_lookup = 1); - assert (get foo_lookup "foo" = Some 42); - - (* Set bar=100 (different key, lookup shouldn't change) *) - emit (Set ("bar", 100)); - Printf.printf "After Set(bar, 100): lookup still has %d entries\n" - (length foo_lookup); - assert (length foo_lookup = 1); - assert (get foo_lookup "foo" = Some 42); - - (* Update foo=99 *) - emit (Set ("foo", 99)); - Printf.printf "After Set(foo, 99): lookup value updated\n"; - assert (get foo_lookup "foo" = Some 99); - - (* Track subscription updates *) - let updates = ref [] in - foo_lookup.subscribe (fun delta -> updates := delta :: !updates); - - emit (Set ("foo", 1)); - emit (Set ("bar", 2)); - emit (Remove "foo"); - - Printf.printf - "Subscription received %d updates (expected 2: Set+Remove for foo)\n" - (List.length !updates); - assert (List.length !updates = 2); - - Printf.printf "PASSED\n\n" - -let test_join () = - Printf.printf "=== Test: join (reactive lookup/join) ===\n"; - - (* Left collection: exception refs (path -> loc_from) *) - let left_data : (string, int) Hashtbl.t = Hashtbl.create 16 in - let left_subs : ((string, int) delta -> unit) list ref = ref [] in - let left : (string, int) t = - { - subscribe = (fun h -> left_subs := h :: !left_subs); - iter = (fun f -> Hashtbl.iter f left_data); - get = (fun k -> Hashtbl.find_opt left_data k); - length = (fun () -> Hashtbl.length left_data); - stats = create_stats (); - } - in - let emit_left delta = - apply_delta left_data delta; - List.iter (fun h -> h delta) !left_subs - in - - (* Right collection: decl index (path -> decl_pos) *) - let right_data : (string, int) Hashtbl.t = Hashtbl.create 16 in - let right_subs : ((string, int) delta -> unit) list ref = ref [] in - let right : (string, int) t = - { - subscribe = (fun h -> right_subs := h :: !right_subs); - iter = (fun f -> Hashtbl.iter f right_data); - get = (fun k -> Hashtbl.find_opt right_data k); - length = (fun () -> Hashtbl.length right_data); - stats = create_stats (); - } - in - let emit_right delta = - apply_delta right_data delta; - List.iter (fun h -> h delta) !right_subs - in - - (* Join: for each (path, loc_from) in left, look up path in right *) - let joined = - join left right - ~key_of:(fun path _loc_from -> path) - ~f:(fun _path loc_from decl_pos_opt -> - match decl_pos_opt with - | Some decl_pos -> - (* Produce (decl_pos, loc_from) pairs *) - [(decl_pos, loc_from)] - | None -> []) - () - in - - (* Initially empty *) - assert (length joined = 0); - - (* Add declaration at path "A" with pos 100 *) - emit_right (Set ("A", 100)); - Printf.printf "After right Set(A, 100): joined=%d\n" (length joined); - assert (length joined = 0); - - (* No left entries yet *) - - (* Add exception ref at path "A" from loc 1 *) - emit_left (Set ("A", 1)); - Printf.printf "After left Set(A, 1): joined=%d\n" (length joined); - assert (length joined = 1); - assert (get joined 100 = Some 1); - - (* decl_pos 100 -> loc_from 1 *) - - (* Add another exception ref at path "B" (no matching decl) *) - emit_left (Set ("B", 2)); - Printf.printf "After left Set(B, 2): joined=%d (B has no decl)\n" - (length joined); - assert (length joined = 1); - - (* Add declaration for path "B" *) - emit_right (Set ("B", 200)); - Printf.printf "After right Set(B, 200): joined=%d\n" (length joined); - assert (length joined = 2); - assert (get joined 200 = Some 2); - - (* Update right: change B's decl_pos *) - emit_right (Set ("B", 201)); - Printf.printf "After right Set(B, 201): joined=%d\n" (length joined); - assert (length joined = 2); - assert (get joined 200 = None); - (* Old key gone *) - assert (get joined 201 = Some 2); - - (* New key has the value *) - - (* Remove left entry A *) - emit_left (Remove "A"); - Printf.printf "After left Remove(A): joined=%d\n" (length joined); - assert (length joined = 1); - assert (get joined 100 = None); - - Printf.printf "PASSED\n\n" - -let test_join_with_merge () = - Printf.printf "=== Test: join with merge ===\n"; - - (* Multiple left entries can map to same right key *) - let left_data : (int, string) Hashtbl.t = Hashtbl.create 16 in - let left_subs : ((int, string) delta -> unit) list ref = ref [] in - let left : (int, string) t = - { - subscribe = (fun h -> left_subs := h :: !left_subs); - iter = (fun f -> Hashtbl.iter f left_data); - get = (fun k -> Hashtbl.find_opt left_data k); - length = (fun () -> Hashtbl.length left_data); - stats = create_stats (); - } - in - let emit_left delta = - apply_delta left_data delta; - List.iter (fun h -> h delta) !left_subs - in - - let right_data : (string, int) Hashtbl.t = Hashtbl.create 16 in - let right_subs : ((string, int) delta -> unit) list ref = ref [] in - let right : (string, int) t = - { - subscribe = (fun h -> right_subs := h :: !right_subs); - iter = (fun f -> Hashtbl.iter f right_data); - get = (fun k -> Hashtbl.find_opt right_data k); - length = (fun () -> Hashtbl.length right_data); - stats = create_stats (); - } - in - let emit_right delta = - apply_delta right_data delta; - List.iter (fun h -> h delta) !right_subs - in - - (* Join with merge: all entries produce to key 0 *) - let joined = - join left right - ~key_of:(fun _id path -> path) (* Look up by path *) - ~f:(fun _id _path value_opt -> - match value_opt with - | Some v -> [(0, v)] (* All contribute to key 0 *) - | None -> []) - ~merge:( + ) (* Sum values *) - () - in - - emit_right (Set ("X", 10)); - emit_left (Set (1, "X")); - emit_left (Set (2, "X")); - - Printf.printf "Two entries looking up X (value 10): sum=%d\n" - (get joined 0 |> Option.value ~default:0); - assert (get joined 0 = Some 20); - - (* 10 + 10 *) - emit_right (Set ("X", 5)); - Printf.printf "After right changes to 5: sum=%d\n" - (get joined 0 |> Option.value ~default:0); - assert (get joined 0 = Some 10); - - (* 5 + 5 *) - emit_left (Remove 1); - Printf.printf "After removing one left entry: sum=%d\n" - (get joined 0 |> Option.value ~default:0); - assert (get joined 0 = Some 5); - - (* Only one left *) - Printf.printf "PASSED\n\n" - -(* Test union *) -let test_union_basic () = - Printf.printf "=== Test: union basic ===\n"; - - (* Left collection *) - let left_data : (string, int) Hashtbl.t = Hashtbl.create 16 in - let left_subs : ((string, int) delta -> unit) list ref = ref [] in - let left : (string, int) t = - { - subscribe = (fun h -> left_subs := h :: !left_subs); - iter = (fun f -> Hashtbl.iter f left_data); - get = (fun k -> Hashtbl.find_opt left_data k); - length = (fun () -> Hashtbl.length left_data); - stats = create_stats (); - } - in - let emit_left delta = - apply_delta left_data delta; - List.iter (fun h -> h delta) !left_subs - in - - (* Right collection *) - let right_data : (string, int) Hashtbl.t = Hashtbl.create 16 in - let right_subs : ((string, int) delta -> unit) list ref = ref [] in - let right : (string, int) t = - { - subscribe = (fun h -> right_subs := h :: !right_subs); - iter = (fun f -> Hashtbl.iter f right_data); - get = (fun k -> Hashtbl.find_opt right_data k); - length = (fun () -> Hashtbl.length right_data); - stats = create_stats (); - } - in - let emit_right delta = - apply_delta right_data delta; - List.iter (fun h -> h delta) !right_subs - in - - (* Create union without merge (right takes precedence) *) - let combined = Reactive.union left right () in - - (* Initially empty *) - assert (length combined = 0); - - (* Add to left *) - emit_left (Set ("a", 1)); - Printf.printf "After left Set(a, 1): combined=%d\n" (length combined); - assert (length combined = 1); - assert (get combined "a" = Some 1); - - (* Add different key to right *) - emit_right (Set ("b", 2)); - Printf.printf "After right Set(b, 2): combined=%d\n" (length combined); - assert (length combined = 2); - assert (get combined "a" = Some 1); - assert (get combined "b" = Some 2); - - (* Add same key to right (should override left) *) - emit_right (Set ("a", 10)); - Printf.printf "After right Set(a, 10): combined a=%d\n" - (get combined "a" |> Option.value ~default:(-1)); - assert (length combined = 2); - assert (get combined "a" = Some 10); - - (* Right takes precedence *) - - (* Remove from right (left value should show through) *) - emit_right (Remove "a"); - Printf.printf "After right Remove(a): combined a=%d\n" - (get combined "a" |> Option.value ~default:(-1)); - assert (get combined "a" = Some 1); - - (* Left shows through *) - - (* Remove from left *) - emit_left (Remove "a"); - Printf.printf "After left Remove(a): combined=%d\n" (length combined); - assert (length combined = 1); - assert (get combined "a" = None); - assert (get combined "b" = Some 2); - - Printf.printf "PASSED\n\n" - -let test_union_with_merge () = - Printf.printf "=== Test: union with merge ===\n"; - - (* Left collection *) - let left_data : (string, IntSet.t) Hashtbl.t = Hashtbl.create 16 in - let left_subs : ((string, IntSet.t) delta -> unit) list ref = ref [] in - let left : (string, IntSet.t) t = - { - subscribe = (fun h -> left_subs := h :: !left_subs); - iter = (fun f -> Hashtbl.iter f left_data); - get = (fun k -> Hashtbl.find_opt left_data k); - length = (fun () -> Hashtbl.length left_data); - stats = create_stats (); - } - in - let emit_left delta = - apply_delta left_data delta; - List.iter (fun h -> h delta) !left_subs - in - - (* Right collection *) - let right_data : (string, IntSet.t) Hashtbl.t = Hashtbl.create 16 in - let right_subs : ((string, IntSet.t) delta -> unit) list ref = ref [] in - let right : (string, IntSet.t) t = - { - subscribe = (fun h -> right_subs := h :: !right_subs); - iter = (fun f -> Hashtbl.iter f right_data); - get = (fun k -> Hashtbl.find_opt right_data k); - length = (fun () -> Hashtbl.length right_data); - stats = create_stats (); - } - in - let emit_right delta = - apply_delta right_data delta; - List.iter (fun h -> h delta) !right_subs - in - - (* Create union with set union as merge *) - let combined = Reactive.union left right ~merge:IntSet.union () in - - (* Add to left: key "x" -> {1, 2} *) - emit_left (Set ("x", IntSet.of_list [1; 2])); - let v = get combined "x" |> Option.get in - Printf.printf "After left Set(x, {1,2}): {%s}\n" - (IntSet.elements v |> List.map string_of_int |> String.concat ", "); - assert (IntSet.equal v (IntSet.of_list [1; 2])); - - (* Add to right: key "x" -> {3, 4} (should merge) *) - emit_right (Set ("x", IntSet.of_list [3; 4])); - let v = get combined "x" |> Option.get in - Printf.printf "After right Set(x, {3,4}): {%s}\n" - (IntSet.elements v |> List.map string_of_int |> String.concat ", "); - assert (IntSet.equal v (IntSet.of_list [1; 2; 3; 4])); - - (* Update left: key "x" -> {1, 5} *) - emit_left (Set ("x", IntSet.of_list [1; 5])); - let v = get combined "x" |> Option.get in - Printf.printf "After left update to {1,5}: {%s}\n" - (IntSet.elements v |> List.map string_of_int |> String.concat ", "); - assert (IntSet.equal v (IntSet.of_list [1; 3; 4; 5])); - - (* Remove right *) - emit_right (Remove "x"); - let v = get combined "x" |> Option.get in - Printf.printf "After right Remove(x): {%s}\n" - (IntSet.elements v |> List.map string_of_int |> String.concat ", "); - assert (IntSet.equal v (IntSet.of_list [1; 5])); - - Printf.printf "PASSED\n\n" - -let test_union_existing_data () = - Printf.printf "=== Test: union on collections with existing data ===\n"; - - (* Create collections with existing data *) - let left_data : (int, string) Hashtbl.t = Hashtbl.create 16 in - Hashtbl.add left_data 1 "a"; - Hashtbl.add left_data 2 "b"; - let left_subs : ((int, string) delta -> unit) list ref = ref [] in - let left : (int, string) t = - { - subscribe = (fun h -> left_subs := h :: !left_subs); - iter = (fun f -> Hashtbl.iter f left_data); - get = (fun k -> Hashtbl.find_opt left_data k); - length = (fun () -> Hashtbl.length left_data); - stats = create_stats (); - } - in - - let right_data : (int, string) Hashtbl.t = Hashtbl.create 16 in - Hashtbl.add right_data 2 "B"; - (* Overlaps with left *) - Hashtbl.add right_data 3 "c"; - let right_subs : ((int, string) delta -> unit) list ref = ref [] in - let right : (int, string) t = - { - subscribe = (fun h -> right_subs := h :: !right_subs); - iter = (fun f -> Hashtbl.iter f right_data); - get = (fun k -> Hashtbl.find_opt right_data k); - length = (fun () -> Hashtbl.length right_data); - stats = create_stats (); - } - in - - (* Create union after both have data *) - let combined = Reactive.union left right () in - - Printf.printf "Union has %d entries (expected 3)\n" (length combined); - assert (length combined = 3); - assert (get combined 1 = Some "a"); - (* Only in left *) - assert (get combined 2 = Some "B"); - (* Right takes precedence *) - assert (get combined 3 = Some "c"); - - (* Only in right *) - Printf.printf "PASSED\n\n" - -(* Helper to create mutable reactive collections for testing *) -let create_mutable_collection () = - let tbl = Hashtbl.create 16 in - let subscribers = ref [] in - let my_stats = Reactive.create_stats () in - let emit delta = - my_stats.updates_emitted <- my_stats.updates_emitted + 1; - Reactive.apply_delta tbl delta; - List.iter (fun h -> h delta) !subscribers - in - let collection : ('k, 'v) Reactive.t = - { - subscribe = (fun h -> subscribers := h :: !subscribers); - iter = (fun f -> Hashtbl.iter f tbl); - get = (fun k -> Hashtbl.find_opt tbl k); - length = (fun () -> Hashtbl.length tbl); - stats = my_stats; - } - in - (collection, emit, tbl) - -(* Test fixpoint basic *) -let test_fixpoint () = - Printf.printf "Test: fixpoint\n"; - - let init, emit_init, _init_tbl = create_mutable_collection () in - let edges, emit_edges, edges_tbl = create_mutable_collection () in - - (* Set up graph: 1 -> [2, 3], 2 -> [4], 3 -> [4] *) - Hashtbl.replace edges_tbl 1 [2; 3]; - Hashtbl.replace edges_tbl 2 [4]; - Hashtbl.replace edges_tbl 3 [4]; - - (* Compute fixpoint *) - let reachable = Reactive.fixpoint ~init ~edges () in - - (* Initially empty *) - Printf.printf "Initially: length=%d\n" (Reactive.length reachable); - assert (Reactive.length reachable = 0); - - (* Add root 1 *) - emit_init (Set (1, ())); - Printf.printf "After adding root 1: length=%d\n" (Reactive.length reachable); - assert (Reactive.length reachable = 4); - (* 1, 2, 3, 4 *) - assert (Reactive.get reachable 1 = Some ()); - assert (Reactive.get reachable 2 = Some ()); - assert (Reactive.get reachable 3 = Some ()); - assert (Reactive.get reachable 4 = Some ()); - assert (Reactive.get reachable 5 = None); - - (* Add another root 5 with edge 5 -> [6] *) - emit_edges (Set (5, [6])); - emit_init (Set (5, ())); - Printf.printf "After adding root 5: length=%d\n" (Reactive.length reachable); - assert (Reactive.length reachable = 6); - - (* 1, 2, 3, 4, 5, 6 *) - - (* Remove root 1 *) - emit_init (Remove 1); - Printf.printf "After removing root 1: length=%d\n" (Reactive.length reachable); - assert (Reactive.length reachable = 2); - (* 5, 6 *) - assert (Reactive.get reachable 1 = None); - assert (Reactive.get reachable 5 = Some ()); - assert (Reactive.get reachable 6 = Some ()); - - Printf.printf "PASSED\n\n" - -(* Test: Basic Expansion *) -let test_fixpoint_basic_expansion () = - Printf.printf "=== Test: fixpoint basic expansion ===\n"; - - let init, emit_init, _ = create_mutable_collection () in - let edges, _, edges_tbl = create_mutable_collection () in - - (* Graph: a -> b -> c *) - Hashtbl.replace edges_tbl "a" ["b"]; - Hashtbl.replace edges_tbl "b" ["c"]; - - let fp = Reactive.fixpoint ~init ~edges () in - - emit_init (Set ("a", ())); - - assert (Reactive.length fp = 3); - assert (Reactive.get fp "a" = Some ()); - assert (Reactive.get fp "b" = Some ()); - assert (Reactive.get fp "c" = Some ()); - assert (Reactive.get fp "d" = None); - - Printf.printf "PASSED\n\n" - -(* Test: Multiple Roots *) -let test_fixpoint_multiple_roots () = - Printf.printf "=== Test: fixpoint multiple roots ===\n"; - - let init, emit_init, _ = create_mutable_collection () in - let edges, _, edges_tbl = create_mutable_collection () in - - (* Graph: a -> b, c -> d (disconnected components) *) - Hashtbl.replace edges_tbl "a" ["b"]; - Hashtbl.replace edges_tbl "c" ["d"]; - - let fp = Reactive.fixpoint ~init ~edges () in - - emit_init (Set ("a", ())); - emit_init (Set ("c", ())); - - assert (Reactive.length fp = 4); - assert (Reactive.get fp "a" = Some ()); - assert (Reactive.get fp "b" = Some ()); - assert (Reactive.get fp "c" = Some ()); - assert (Reactive.get fp "d" = Some ()); - - Printf.printf "PASSED\n\n" - -(* Test: Diamond Graph *) -let test_fixpoint_diamond () = - Printf.printf "=== Test: fixpoint diamond ===\n"; - - let init, emit_init, _ = create_mutable_collection () in - let edges, _, edges_tbl = create_mutable_collection () in - - (* Graph: a -> b, a -> c, b -> d, c -> d *) - Hashtbl.replace edges_tbl "a" ["b"; "c"]; - Hashtbl.replace edges_tbl "b" ["d"]; - Hashtbl.replace edges_tbl "c" ["d"]; - - let fp = Reactive.fixpoint ~init ~edges () in - - emit_init (Set ("a", ())); - - assert (Reactive.length fp = 4); - - Printf.printf "PASSED\n\n" - -(* Test: Cycle *) -let test_fixpoint_cycle () = - Printf.printf "=== Test: fixpoint cycle ===\n"; - - let init, emit_init, _ = create_mutable_collection () in - let edges, _, edges_tbl = create_mutable_collection () in - - (* Graph: a -> b -> c -> b (cycle from root) *) - Hashtbl.replace edges_tbl "a" ["b"]; - Hashtbl.replace edges_tbl "b" ["c"]; - Hashtbl.replace edges_tbl "c" ["b"]; - - let fp = Reactive.fixpoint ~init ~edges () in - - emit_init (Set ("a", ())); - - assert (Reactive.length fp = 3); - assert (Reactive.get fp "a" = Some ()); - assert (Reactive.get fp "b" = Some ()); - assert (Reactive.get fp "c" = Some ()); - - Printf.printf "PASSED\n\n" - -(* Test: Add Base Element *) -let test_fixpoint_add_base () = - Printf.printf "=== Test: fixpoint add base ===\n"; - - let init, emit_init, _ = create_mutable_collection () in - let edges, _, edges_tbl = create_mutable_collection () in - - (* Graph: a -> b, c -> d *) - Hashtbl.replace edges_tbl "a" ["b"]; - Hashtbl.replace edges_tbl "c" ["d"]; - - let fp = Reactive.fixpoint ~init ~edges () in - - emit_init (Set ("a", ())); - assert (Reactive.length fp = 2); - - (* a, b *) - - (* Track changes via subscription *) - let added = ref [] in - let removed = ref [] in - fp.subscribe (function - | Set (k, ()) -> added := k :: !added - | Remove k -> removed := k :: !removed - | Batch entries -> - entries - |> List.iter (fun (k, v_opt) -> - match v_opt with - | Some () -> added := k :: !added - | None -> removed := k :: !removed)); - - emit_init (Set ("c", ())); - - Printf.printf "Added: [%s]\n" (String.concat ", " !added); - assert (List.length !added = 2); - (* c, d *) - assert (List.mem "c" !added); - assert (List.mem "d" !added); - assert (!removed = []); - assert (Reactive.length fp = 4); - - Printf.printf "PASSED\n\n" - -(* Test: Remove Base Element *) -let test_fixpoint_remove_base () = - Printf.printf "=== Test: fixpoint remove base ===\n"; - - let init, emit_init, _ = create_mutable_collection () in - let edges, _, edges_tbl = create_mutable_collection () in - - (* Graph: a -> b -> c *) - Hashtbl.replace edges_tbl "a" ["b"]; - Hashtbl.replace edges_tbl "b" ["c"]; - - let fp = Reactive.fixpoint ~init ~edges () in - - emit_init (Set ("a", ())); - assert (Reactive.length fp = 3); - - let removed = ref [] in - fp.subscribe (function - | Remove k -> removed := k :: !removed - | _ -> ()); - - emit_init (Remove "a"); - - Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); - assert (List.length !removed = 3); - assert (Reactive.length fp = 0); - - Printf.printf "PASSED\n\n" - -(* Test: Add Edge *) -let test_fixpoint_add_edge () = - Printf.printf "=== Test: fixpoint add edge ===\n"; - - let init, emit_init, _ = create_mutable_collection () in - let edges, emit_edges, _ = create_mutable_collection () in - - let fp = Reactive.fixpoint ~init ~edges () in - - emit_init (Set ("a", ())); - assert (Reactive.length fp = 1); - - (* just a *) - let added = ref [] in - fp.subscribe (function - | Set (k, ()) -> added := k :: !added - | _ -> ()); - - (* Add edge a -> b *) - emit_edges (Set ("a", ["b"])); - - Printf.printf "Added: [%s]\n" (String.concat ", " !added); - assert (List.mem "b" !added); - assert (Reactive.length fp = 2); - - Printf.printf "PASSED\n\n" - -(* Test: Remove Edge *) -let test_fixpoint_remove_edge () = - Printf.printf "=== Test: fixpoint remove edge ===\n"; - - let init, emit_init, _ = create_mutable_collection () in - let edges, emit_edges, edges_tbl = create_mutable_collection () in - - (* Graph: a -> b -> c *) - Hashtbl.replace edges_tbl "a" ["b"]; - Hashtbl.replace edges_tbl "b" ["c"]; - - let fp = Reactive.fixpoint ~init ~edges () in - - emit_init (Set ("a", ())); - assert (Reactive.length fp = 3); - - let removed = ref [] in - fp.subscribe (function - | Remove k -> removed := k :: !removed - | _ -> ()); - - (* Remove edge a -> b *) - emit_edges (Set ("a", [])); - - Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); - assert (List.length !removed = 2); - (* b, c *) - assert (Reactive.length fp = 1); - - (* just a *) - Printf.printf "PASSED\n\n" - -(* Test: Cycle Removal (Well-Founded Derivation) *) -let test_fixpoint_cycle_removal () = - Printf.printf "=== Test: fixpoint cycle removal (well-founded) ===\n"; - - let init, emit_init, _ = create_mutable_collection () in - let edges, emit_edges, edges_tbl = create_mutable_collection () in - - (* Graph: a -> b -> c -> b (b-c cycle reachable from a) *) - Hashtbl.replace edges_tbl "a" ["b"]; - Hashtbl.replace edges_tbl "b" ["c"]; - Hashtbl.replace edges_tbl "c" ["b"]; - - let fp = Reactive.fixpoint ~init ~edges () in - - emit_init (Set ("a", ())); - assert (Reactive.length fp = 3); - - let removed = ref [] in - fp.subscribe (function - | Remove k -> removed := k :: !removed - | _ -> ()); - - (* Remove edge a -> b *) - emit_edges (Set ("a", [])); - - Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); - (* Both b and c should be removed - cycle has no well-founded support *) - assert (List.length !removed = 2); - assert (List.mem "b" !removed); - assert (List.mem "c" !removed); - assert (Reactive.length fp = 1); - - (* just a *) - Printf.printf "PASSED\n\n" - -(* Test: Alternative Support Keeps Element Alive *) -let test_fixpoint_alternative_support () = - Printf.printf "=== Test: fixpoint alternative support ===\n"; - - let init, emit_init, _ = create_mutable_collection () in - let edges, emit_edges, edges_tbl = create_mutable_collection () in - - (* Graph: a -> b, a -> c -> b - If we remove a -> b, b should survive via a -> c -> b *) - Hashtbl.replace edges_tbl "a" ["b"; "c"]; - Hashtbl.replace edges_tbl "c" ["b"]; - - let fp = Reactive.fixpoint ~init ~edges () in - - emit_init (Set ("a", ())); - assert (Reactive.length fp = 3); - - let removed = ref [] in - fp.subscribe (function - | Remove k -> removed := k :: !removed - | _ -> ()); - - (* Remove direct edge a -> b (but keep a -> c) *) - emit_edges (Set ("a", ["c"])); - - Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); - (* b should NOT be removed - still reachable via c *) - assert (!removed = []); - assert (Reactive.length fp = 3); - - Printf.printf "PASSED\n\n" - -(* Test: Empty Base *) -let test_fixpoint_empty_base () = - Printf.printf "=== Test: fixpoint empty base ===\n"; - - let init, _, _ = create_mutable_collection () in - let edges, _, edges_tbl = create_mutable_collection () in - - Hashtbl.replace edges_tbl "a" ["b"]; - - let fp = Reactive.fixpoint ~init ~edges () in - - assert (Reactive.length fp = 0); - - Printf.printf "PASSED\n\n" - -(* Test: Self Loop *) -let test_fixpoint_self_loop () = - Printf.printf "=== Test: fixpoint self loop ===\n"; - - let init, emit_init, _ = create_mutable_collection () in - let edges, _, edges_tbl = create_mutable_collection () in - - (* Graph: a -> a (self loop) *) - Hashtbl.replace edges_tbl "a" ["a"]; - - let fp = Reactive.fixpoint ~init ~edges () in - - emit_init (Set ("a", ())); - - assert (Reactive.length fp = 1); - assert (Reactive.get fp "a" = Some ()); - - Printf.printf "PASSED\n\n" - -(* Test: Delta emissions for incremental updates *) -let test_fixpoint_deltas () = - Printf.printf "=== Test: fixpoint delta emissions ===\n"; - - let init, emit_init, _ = create_mutable_collection () in - let edges, emit_edges, edges_tbl = create_mutable_collection () in - - Hashtbl.replace edges_tbl 1 [2; 3]; - Hashtbl.replace edges_tbl 2 [4]; - - let fp = Reactive.fixpoint ~init ~edges () in - - let all_deltas = ref [] in - fp.subscribe (fun d -> all_deltas := d :: !all_deltas); - - (* Add root *) - emit_init (Set (1, ())); - Printf.printf "After add root: %d deltas\n" (List.length !all_deltas); - assert (List.length !all_deltas = 4); - - (* 1, 2, 3, 4 *) - all_deltas := []; - - (* Add edge 3 -> 5 *) - emit_edges (Set (3, [5])); - Printf.printf "After add edge 3->5: %d deltas\n" (List.length !all_deltas); - assert (List.length !all_deltas = 1); - - (* 5 added *) - all_deltas := []; - - (* Remove root (should remove all) *) - emit_init (Remove 1); - Printf.printf "After remove root: %d deltas\n" (List.length !all_deltas); - assert (List.length !all_deltas = 5); - - (* 1, 2, 3, 4, 5 removed *) - Printf.printf "PASSED\n\n" - -(* Test: Remove from init but still reachable via edges - This test reproduces a real bug found in the dead code analysis: - - A reference arrives before its declaration exists - - The reference target is incorrectly marked as "externally referenced" (a root) - - When the declaration arrives, the target is removed from roots - - But it should remain live because it's reachable from other live nodes - - Graph: root (true root) -> a -> b - Scenario: - 1. Initially, b is spuriously in init (before we know it has a declaration) - 2. Later, b is removed from init (when declaration is discovered) - 3. Bug: b incorrectly removed from fixpoint - 4. Correct: b should stay live (reachable via root -> a -> b) *) -let test_fixpoint_remove_spurious_root () = - Printf.printf - "=== Test: fixpoint remove spurious root (still reachable) ===\n"; - - let init, emit_init, _ = create_mutable_collection () in - let edges, emit_edges, _ = create_mutable_collection () in - - let fp = Reactive.fixpoint ~init ~edges () in - - (* Track all deltas *) - let added = ref [] in - let removed = ref [] in - fp.subscribe (function - | Set (k, ()) -> added := k :: !added - | Remove k -> removed := k :: !removed - | Batch entries -> - entries - |> List.iter (fun (k, v_opt) -> - match v_opt with - | Some () -> added := k :: !added - | None -> removed := k :: !removed)); - - (* Step 1: "b" is spuriously marked as a root - (in the real bug, this happens when a reference arrives before its declaration) *) - emit_init (Set ("b", ())); - Printf.printf "After spurious root b: fp=[%s]\n" - (let items = ref [] in - fp.iter (fun k _ -> items := k :: !items); - String.concat ", " (List.sort String.compare !items)); - assert (Reactive.get fp "b" = Some ()); - - (* Step 2: The real root "root" is added *) - emit_init (Set ("root", ())); - Printf.printf "After true root: fp=[%s]\n" - (let items = ref [] in - fp.iter (fun k _ -> items := k :: !items); - String.concat ", " (List.sort String.compare !items)); - - (* Step 3: Edge root -> a is added *) - emit_edges (Set ("root", ["a"])); - Printf.printf "After edge root->a: fp=[%s]\n" - (let items = ref [] in - fp.iter (fun k _ -> items := k :: !items); - String.concat ", " (List.sort String.compare !items)); - assert (Reactive.get fp "a" = Some ()); - - (* Step 4: Edge a -> b is added *) - emit_edges (Set ("a", ["b"])); - Printf.printf "After edge a->b: fp=[%s]\n" - (let items = ref [] in - fp.iter (fun k _ -> items := k :: !items); - String.concat ", " (List.sort String.compare !items)); - - (* At this point: root, a, b are all in fixpoint *) - assert (Reactive.length fp = 3); - - (* Clear tracked changes *) - added := []; - removed := []; - - (* Step 5: The spurious root "b" is REMOVED from init - (in real bug, this happens when declaration for b is discovered, - showing b is NOT externally referenced - just referenced by a) - - BUG: b gets removed from fixpoint - CORRECT: b should stay because it's still reachable via root -> a -> b *) - emit_init (Remove "b"); - - Printf.printf "After removing b from init: fp=[%s]\n" - (let items = ref [] in - fp.iter (fun k _ -> items := k :: !items); - String.concat ", " (List.sort String.compare !items)); - Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); - - (* b should NOT be removed - still reachable via a *) - assert (not (List.mem "b" !removed)); - assert (Reactive.get fp "b" = Some ()); - assert (Reactive.length fp = 3); - - Printf.printf "PASSED\n\n" - -(* Test: Remove entire edge entry but target still reachable via other source - - This tests the `Remove source` case in apply_edges_delta. - When an entire edge entry is removed, targets may still be reachable - via edges from OTHER sources. - - Graph: a -> b, c -> b (b has two derivations) - Scenario: - 1. a and c are roots - 2. Both derive b - 3. Remove the entire edge entry for "a" (Remove "a" from edges) - 4. b should stay (still reachable via c -> b) *) -let test_fixpoint_remove_edge_entry_alternative_source () = - Printf.printf - "=== Test: fixpoint remove edge entry (alternative source) ===\n"; - - let init, emit_init, _ = create_mutable_collection () in - let edges, emit_edges, edges_tbl = create_mutable_collection () in - - (* Set up initial edges: a -> b, c -> b *) - Hashtbl.replace edges_tbl "a" ["b"]; - Hashtbl.replace edges_tbl "c" ["b"]; - - let fp = Reactive.fixpoint ~init ~edges () in - - (* Track changes *) - let removed = ref [] in - fp.subscribe (function - | Remove k -> removed := k :: !removed - | _ -> ()); - - (* Add roots a and c *) - emit_init (Set ("a", ())); - emit_init (Set ("c", ())); - - Printf.printf "Initial: fp=[%s]\n" - (let items = ref [] in - fp.iter (fun k _ -> items := k :: !items); - String.concat ", " (List.sort String.compare !items)); - - (* Should have a, b, c *) - assert (Reactive.length fp = 3); - assert (Reactive.get fp "a" = Some ()); - assert (Reactive.get fp "b" = Some ()); - assert (Reactive.get fp "c" = Some ()); - - removed := []; - - (* Remove entire edge entry for "a" *) - emit_edges (Remove "a"); - - Printf.printf "After Remove edge entry 'a': fp=[%s]\n" - (let items = ref [] in - fp.iter (fun k _ -> items := k :: !items); - String.concat ", " (List.sort String.compare !items)); - Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); - - (* b should NOT be removed - still reachable via c -> b *) - assert (not (List.mem "b" !removed)); - assert (Reactive.get fp "b" = Some ()); - assert (Reactive.length fp = 3); - - Printf.printf "PASSED\n\n" - -(* Test: Remove edge entry - target reachable via higher-ranked source - - This is the subtle case where re-derivation check matters. - When contraction removes an element, but a surviving source with - HIGHER rank still points to it, well-founded check fails but - re-derivation should save it. - - Graph: root -> a -> b -> c, a -> c (c reachable via b and directly from a) - Key: c is first reached via a (rank 1), not via b (rank 2) - When we remove a->c edge, c still has b->c, but rank[b] >= rank[c] - so well-founded check fails. But c should be re-derived from b. -*) -let test_fixpoint_remove_edge_rederivation () = - Printf.printf "=== Test: fixpoint remove edge (re-derivation needed) ===\n"; - - let init, emit_init, _ = create_mutable_collection () in - let edges, emit_edges, _ = create_mutable_collection () in - - let fp = Reactive.fixpoint ~init ~edges () in - - (* Track changes *) - let removed = ref [] in - let added = ref [] in - fp.subscribe (function - | Remove k -> removed := k :: !removed - | Set (k, ()) -> added := k :: !added - | Batch entries -> - entries - |> List.iter (fun (k, v_opt) -> - match v_opt with - | Some () -> added := k :: !added - | None -> removed := k :: !removed)); - - (* Add root *) - emit_init (Set ("root", ())); - - (* Build graph: root -> a -> b -> c, a -> c *) - emit_edges (Set ("root", ["a"])); - emit_edges (Set ("a", ["b"; "c"])); - (* a reaches both b and c *) - emit_edges (Set ("b", ["c"])); - - (* b also reaches c *) - Printf.printf "Initial: fp=[%s]\n" - (let items = ref [] in - fp.iter (fun k _ -> items := k :: !items); - String.concat ", " (List.sort String.compare !items)); - - (* Should have root, a, b, c *) - assert (Reactive.length fp = 4); - - (* Check ranks: root=0, a=1, b=2, c=2 (reached from a at level 2) *) - (* Actually c could be rank 2 (from a) or rank 3 (from b) - depends on BFS order *) - removed := []; - added := []; - - (* Remove the direct edge a -> c *) - emit_edges (Set ("a", ["b"])); - - (* a now only reaches b *) - Printf.printf "After removing a->c: fp=[%s]\n" - (let items = ref [] in - fp.iter (fun k _ -> items := k :: !items); - String.concat ", " (List.sort String.compare !items)); - Printf.printf "Removed: [%s], Added: [%s]\n" - (String.concat ", " !removed) - (String.concat ", " !added); - - (* c should still be in fixpoint - reachable via root -> a -> b -> c *) - assert (Reactive.get fp "c" = Some ()); - assert (Reactive.length fp = 4); - - Printf.printf "PASSED\n\n" - -(* Test: Remove edge ENTRY (not Set) - re-derivation case - - This specifically tests the `Remove source` case which uses emit_edges (Remove ...) - rather than emit_edges (Set (..., [])). - - Graph: a -> c, b -> c (c reachable from both) - BFS: a (0), b (0), c (1) - Key: c has rank 1, both a and b have rank 0 - - When we remove the entire edge entry for "a" via Remove (not Set), - c loses derivation from a but should survive via b -> c. - - With equal ranks, well-founded check should still find b as support. -*) -let test_fixpoint_remove_edge_entry_rederivation () = - Printf.printf "=== Test: fixpoint Remove edge entry (re-derivation) ===\n"; - - let init, emit_init, _ = create_mutable_collection () in - let edges, emit_edges, edges_tbl = create_mutable_collection () in - - (* Set up edges before creating fixpoint *) - Hashtbl.replace edges_tbl "a" ["c"]; - Hashtbl.replace edges_tbl "b" ["c"]; - - let fp = Reactive.fixpoint ~init ~edges () in - - (* Track changes *) - let removed = ref [] in - fp.subscribe (function - | Remove k -> removed := k :: !removed - | _ -> ()); - - (* Add roots a and b *) - emit_init (Set ("a", ())); - emit_init (Set ("b", ())); - - Printf.printf "Initial: fp=[%s]\n" - (let items = ref [] in - fp.iter (fun k _ -> items := k :: !items); - String.concat ", " (List.sort String.compare !items)); - - assert (Reactive.length fp = 3); - - removed := []; - - (* Remove entire edge entry for "a" using Remove delta *) - emit_edges (Remove "a"); - - Printf.printf "After Remove 'a' entry: fp=[%s]\n" - (let items = ref [] in - fp.iter (fun k _ -> items := k :: !items); - String.concat ", " (List.sort String.compare !items)); - Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); - - (* c should survive - b -> c still exists *) - assert (not (List.mem "c" !removed)); - assert (Reactive.get fp "c" = Some ()); - assert (Reactive.length fp = 3); - - Printf.printf "PASSED\n\n" - -(* Test: Remove edge entry - surviving predecessor has HIGHER rank - - This is the critical case where re-derivation is needed. - When well-founded check fails (rank[predecessor] >= rank[target]), - the target dies. But if the predecessor is surviving, target - should be re-derived with new rank. - - Graph: root -> a -> c, root -> b, b -> c - BFS order matters here: - - Level 0: root - - Level 1: a, b (both from root) - - Level 2: c (from a, since a is processed first) - - c has rank 2, b has rank 1 - inv_index[c] = [a, b] - - When we remove "a" entry: - - c goes into contraction - - inv_index[c] = [b] - - rank[b] = 1 < rank[c] = 2, so b provides support! - - Hmm, this still finds support because b has lower rank. - - Let me try: root -> a -> b -> c, a -> c - - Level 0: root - - Level 1: a - - Level 2: b, c (both from a, same level) - - c has rank 2, b has rank 2 - When we remove a->c edge (not entry), c goes into contraction - inv_index[c] = [b] (after a removed), rank[b] = rank[c] = 2 - NO support (2 < 2 is false), c dies - - But b -> c exists! c should be re-derived with rank 3. - - This is the Set case, not Remove. But the logic should be similar. -*) -let test_fixpoint_remove_edge_entry_higher_rank_support () = - Printf.printf "=== Test: fixpoint edge removal (higher rank support) ===\n"; - - let init, emit_init, _ = create_mutable_collection () in - let edges, emit_edges, _ = create_mutable_collection () in - - let fp = Reactive.fixpoint ~init ~edges () in - - (* Track changes *) - let removed = ref [] in - let added = ref [] in - fp.subscribe (function - | Remove k -> removed := k :: !removed - | Set (k, ()) -> added := k :: !added - | Batch entries -> - entries - |> List.iter (fun (k, v_opt) -> - match v_opt with - | Some () -> added := k :: !added - | None -> removed := k :: !removed)); - - (* Add root *) - emit_init (Set ("root", ())); - - (* Build graph: root -> a -> b -> c, a -> c *) - emit_edges (Set ("root", ["a"])); - emit_edges (Set ("a", ["b"; "c"])); - (* a reaches both b and c at same level *) - emit_edges (Set ("b", ["c"])); - - (* b also reaches c *) - Printf.printf "Initial: fp=[%s]\n" - (let items = ref [] in - fp.iter (fun k _ -> items := k :: !items); - String.concat ", " (List.sort String.compare !items)); - - assert (Reactive.length fp = 4); - assert (Reactive.get fp "c" = Some ()); - - removed := []; - added := []; - - (* Remove direct edge a -> c, keeping a -> b *) - emit_edges (Set ("a", ["b"])); - - Printf.printf "After removing a->c: fp=[%s]\n" - (let items = ref [] in - fp.iter (fun k _ -> items := k :: !items); - String.concat ", " (List.sort String.compare !items)); - Printf.printf "Removed: [%s], Added: [%s]\n" - (String.concat ", " !removed) - (String.concat ", " !added); - - (* c should still be in fixpoint via root -> a -> b -> c *) - (* The re-derivation check should save c even though rank[b] >= rank[c] *) - assert (Reactive.get fp "c" = Some ()); - assert (Reactive.length fp = 4); - - Printf.printf "PASSED\n\n" - -(* Test: Remove edge ENTRY (Remove source) where re-derivation is required. - - This is the classic counterexample: two paths to y, remove the shorter one. - Without the re-derivation step (reference implementation step 7), contraction - can incorrectly remove y because its stored rank is too low. - - Graph: - r -> a -> y (short path) - r -> b -> c -> x -> y (long path) - - Scenario: - 1) init = {r} - 2) y is reachable (via a->y) - 3) Remove edge entry for a (emit_edges (Remove "a")), which deletes the short path - 4) y must remain reachable via x->y - - Expected: y stays in fixpoint - Bug: y is removed if `apply_edges_delta` Remove-case lacks re-derivation. *) -let test_fixpoint_remove_edge_entry_needs_rederivation () = - Printf.printf - "=== Test: fixpoint Remove edge entry (needs re-derivation) ===\n"; - - let init, emit_init, _ = create_mutable_collection () in - let edges, emit_edges, edges_tbl = create_mutable_collection () in - - (* Pre-populate edges so fixpoint initializes with them *) - Hashtbl.replace edges_tbl "r" ["a"; "b"]; - Hashtbl.replace edges_tbl "a" ["y"]; - Hashtbl.replace edges_tbl "b" ["c"]; - Hashtbl.replace edges_tbl "c" ["x"]; - Hashtbl.replace edges_tbl "x" ["y"]; - - let fp = Reactive.fixpoint ~init ~edges () in - - (* Make r live *) - emit_init (Set ("r", ())); - - (* Sanity: y initially reachable via short path *) - assert (Reactive.get fp "y" = Some ()); - assert (Reactive.get fp "x" = Some ()); - - let removed = ref [] in - fp.subscribe (function - | Remove k -> removed := k :: !removed - | _ -> ()); - - (* Remove the entire edge entry for a (removes a->y) *) - emit_edges (Remove "a"); - - Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); - - (* Correct: y is still reachable via r->b->c->x->y *) - assert (Reactive.get fp "y" = Some ()); - - Printf.printf "PASSED\n\n" - -(* Test: Remove BASE element where re-derivation is required. - - Same shape as the verified counterexample, but triggered by removing a base element. - Verified algorithm applies the re-derivation step after contraction for removed-from-base too. - - Two roots r1 and r2. - Paths to y: - r1 -> a -> y (short path, determines initial rank(y)) - r2 -> b -> c -> x -> y (long path, still reachable after removing r1) - - Scenario: - 1) init = {r1, r2} - 2) y is reachable (via r1->a->y) - 3) Remove r1 from init (emit_init (Remove "r1")), which deletes the short witness - 4) y must remain reachable via r2->...->y *) -let test_fixpoint_remove_base_needs_rederivation () = - Printf.printf - "=== Test: fixpoint Remove base element (needs re-derivation) ===\n"; - - let init, emit_init, _ = create_mutable_collection () in - let edges, _emit_edges, edges_tbl = create_mutable_collection () in - - (* Pre-populate edges so fixpoint initializes with them *) - Hashtbl.replace edges_tbl "r1" ["a"]; - Hashtbl.replace edges_tbl "a" ["y"]; - Hashtbl.replace edges_tbl "r2" ["b"]; - Hashtbl.replace edges_tbl "b" ["c"]; - Hashtbl.replace edges_tbl "c" ["x"]; - Hashtbl.replace edges_tbl "x" ["y"]; - - let fp = Reactive.fixpoint ~init ~edges () in - - emit_init (Set ("r1", ())); - emit_init (Set ("r2", ())); - - (* Sanity: y initially reachable *) - assert (Reactive.get fp "y" = Some ()); - assert (Reactive.get fp "x" = Some ()); - - let removed = ref [] in - fp.subscribe (function - | Remove k -> removed := k :: !removed - | _ -> ()); - - (* Remove r1 from base: y should remain via r2 path *) - emit_init (Remove "r1"); - - Printf.printf "Removed: [%s]\n" (String.concat ", " !removed); - - assert (Reactive.get fp "y" = Some ()); - Printf.printf "PASSED\n\n" - -(* Test: Pre-existing data in init and edges *) -let test_fixpoint_existing_data () = - Printf.printf "=== Test: fixpoint with existing data ===\n"; - - (* Create with pre-existing data *) - let init_tbl = Hashtbl.create 16 in - Hashtbl.replace init_tbl "root" (); - let init_subs = ref [] in - let init : (string, unit) Reactive.t = - { - subscribe = (fun h -> init_subs := h :: !init_subs); - iter = (fun f -> Hashtbl.iter f init_tbl); - get = (fun k -> Hashtbl.find_opt init_tbl k); - length = (fun () -> Hashtbl.length init_tbl); - stats = Reactive.create_stats (); - } - in - - let edges_tbl = Hashtbl.create 16 in - Hashtbl.replace edges_tbl "root" ["a"; "b"]; - Hashtbl.replace edges_tbl "a" ["c"]; - let edges_subs = ref [] in - let edges : (string, string list) Reactive.t = - { - subscribe = (fun h -> edges_subs := h :: !edges_subs); - iter = (fun f -> Hashtbl.iter f edges_tbl); - get = (fun k -> Hashtbl.find_opt edges_tbl k); - length = (fun () -> Hashtbl.length edges_tbl); - stats = Reactive.create_stats (); - } - in - - (* Create fixpoint - should immediately have all reachable *) - let fp = Reactive.fixpoint ~init ~edges () in - - Printf.printf "Fixpoint length: %d (expected 4)\n" (Reactive.length fp); - assert (Reactive.length fp = 4); - (* root, a, b, c *) - assert (Reactive.get fp "root" = Some ()); - assert (Reactive.get fp "a" = Some ()); - assert (Reactive.get fp "b" = Some ()); - assert (Reactive.get fp "c" = Some ()); - - Printf.printf "PASSED\n\n" - -(* ==================== Batch Tests ==================== *) - -let test_batch_flatmap () = - Printf.printf "=== Test: batch flatmap ===\n"; - - let source, emit, _ = create_mutable_collection () in - let derived = - Reactive.flatMap source ~f:(fun k v -> [(k ^ "_derived", v * 2)]) () - in - - (* Subscribe to track what comes out *) - let received_batches = ref 0 in - let received_entries = ref [] in - derived.subscribe (function - | Batch entries -> - incr received_batches; - received_entries := entries @ !received_entries - | Set (k, v) -> received_entries := [(k, Some v)] @ !received_entries - | Remove k -> received_entries := [(k, None)] @ !received_entries); - - (* Send a batch *) - emit (Batch [Reactive.set "a" 1; Reactive.set "b" 2; Reactive.set "c" 3]); - - Printf.printf "Received batches: %d, entries: %d\n" !received_batches - (List.length !received_entries); - assert (!received_batches = 1); - assert (List.length !received_entries = 3); - assert (Reactive.get derived "a_derived" = Some 2); - assert (Reactive.get derived "b_derived" = Some 4); - assert (Reactive.get derived "c_derived" = Some 6); - - Printf.printf "PASSED\n\n" - -let test_batch_fixpoint () = - Printf.printf "=== Test: batch fixpoint ===\n"; - - let init, emit_init, _ = create_mutable_collection () in - let edges, emit_edges, _ = create_mutable_collection () in - - let fp = Reactive.fixpoint ~init ~edges () in - - (* Track batches received *) - let batch_count = ref 0 in - let total_added = ref 0 in - fp.subscribe (function - | Batch entries -> - incr batch_count; - entries - |> List.iter (fun (_, v_opt) -> - match v_opt with - | Some () -> incr total_added - | None -> ()) - | Set (_, ()) -> incr total_added - | Remove _ -> ()); - - (* Set up edges first *) - emit_edges (Set ("a", ["b"; "c"])); - emit_edges (Set ("b", ["d"])); - - (* Send batch of roots *) - emit_init (Batch [Reactive.set "a" (); Reactive.set "x" ()]); - - Printf.printf "Batch count: %d, total added: %d\n" !batch_count !total_added; - Printf.printf "fp length: %d\n" (Reactive.length fp); - (* Should have a, b, c, d (reachable from a) and x (standalone root) *) - assert (Reactive.length fp = 5); - assert (Reactive.get fp "a" = Some ()); - assert (Reactive.get fp "b" = Some ()); - assert (Reactive.get fp "c" = Some ()); - assert (Reactive.get fp "d" = Some ()); - assert (Reactive.get fp "x" = Some ()); - - Printf.printf "PASSED\n\n" +(** Main test driver for Reactive tests *) let () = - Printf.printf "\n====== Reactive Collection Tests ======\n\n"; - test_flatmap_basic (); - test_flatmap_with_merge (); - test_composition (); - test_flatmap_on_existing_data (); - test_file_collection (); - test_lookup (); - test_join (); - test_join_with_merge (); - test_union_basic (); - test_union_with_merge (); - test_union_existing_data (); - test_fixpoint (); - (* Incremental fixpoint tests *) - test_fixpoint_basic_expansion (); - test_fixpoint_multiple_roots (); - test_fixpoint_diamond (); - test_fixpoint_cycle (); - test_fixpoint_add_base (); - test_fixpoint_remove_base (); - test_fixpoint_add_edge (); - test_fixpoint_remove_edge (); - test_fixpoint_cycle_removal (); - test_fixpoint_alternative_support (); - test_fixpoint_empty_base (); - test_fixpoint_self_loop (); - test_fixpoint_deltas (); - test_fixpoint_existing_data (); - test_fixpoint_remove_spurious_root (); - test_fixpoint_remove_edge_entry_alternative_source (); - test_fixpoint_remove_edge_rederivation (); - test_fixpoint_remove_edge_entry_rederivation (); - test_fixpoint_remove_edge_entry_higher_rank_support (); - test_fixpoint_remove_edge_entry_needs_rederivation (); - test_fixpoint_remove_base_needs_rederivation (); - (* Batch tests *) - test_batch_flatmap (); - test_batch_fixpoint (); - Printf.printf "All tests passed!\n" + Printf.printf "\n====== Reactive Collection Tests ======\n"; + FlatMapTest.run_all (); + JoinTest.run_all (); + UnionTest.run_all (); + FixpointBasicTest.run_all (); + FixpointIncrementalTest.run_all (); + BatchTest.run_all (); + IntegrationTest.run_all (); + GlitchFreeTest.run_all (); + Printf.printf "\nAll tests passed!\n" diff --git a/analysis/reactive/test/TestHelpers.ml b/analysis/reactive/test/TestHelpers.ml new file mode 100644 index 0000000000..54067172fe --- /dev/null +++ b/analysis/reactive/test/TestHelpers.ml @@ -0,0 +1,57 @@ +(** Shared test helpers for Reactive tests *) + +open Reactive + +(** {1 Compatibility helpers} *) + +(* V2's emit takes deltas, not tuples. These helpers adapt tuple-style calls. *) +let[@warning "-32"] emit_kv emit (k, v_opt) = + match v_opt with + | Some v -> emit (Set (k, v)) + | None -> emit (Remove k) + +(* subscribe takes collection first in V2, but we want handler first for compatibility *) +let subscribe handler t = t.subscribe handler + +(* emit_batch: emit a batch delta to a source *) +let emit_batch entries emit_fn = emit_fn (Batch entries) + +(* Helper to track added/removed across all delta types *) +let[@warning "-32"] track_changes () = + let added = ref [] in + let removed = ref [] in + let handler = function + | Set (k, _) -> added := k :: !added + | Remove k -> removed := k :: !removed + | Batch entries -> + List.iter + (fun (k, v_opt) -> + match v_opt with + | Some _ -> added := k :: !added + | None -> removed := k :: !removed) + entries + in + (added, removed, handler) + +(** {1 File helpers} *) + +let[@warning "-32"] read_lines path = + let ic = open_in path in + let lines = ref [] in + (try + while true do + lines := input_line ic :: !lines + done + with End_of_file -> ()); + close_in ic; + List.rev !lines + +let[@warning "-32"] write_lines path lines = + let oc = open_out path in + List.iter (fun line -> output_string oc (line ^ "\n")) lines; + close_out oc + +(** {1 Common set modules} *) + +module IntSet = Set.Make (Int) +module StringMap = Map.Make (String) diff --git a/analysis/reactive/test/UnionTest.ml b/analysis/reactive/test/UnionTest.ml new file mode 100644 index 0000000000..c532180389 --- /dev/null +++ b/analysis/reactive/test/UnionTest.ml @@ -0,0 +1,136 @@ +(** Union combinator tests *) + +open Reactive +open TestHelpers + +let test_union_basic () = + reset (); + Printf.printf "=== Test: union basic ===\n"; + + (* Left collection *) + let left, emit_left = source ~name:"left" () in + + (* Right collection *) + let right, emit_right = source ~name:"right" () in + + (* Create union without merge (right takes precedence) *) + let combined = union ~name:"combined" left right () in + + (* Initially empty *) + assert (length combined = 0); + + (* Add to left *) + emit_left (Set ("a", 1)); + Printf.printf "After left Set(a, 1): combined=%d\n" (length combined); + assert (length combined = 1); + assert (get combined "a" = Some 1); + + (* Add different key to right *) + emit_right (Set ("b", 2)); + Printf.printf "After right Set(b, 2): combined=%d\n" (length combined); + assert (length combined = 2); + assert (get combined "a" = Some 1); + assert (get combined "b" = Some 2); + + (* Add same key to right (should override left) *) + emit_right (Set ("a", 10)); + Printf.printf "After right Set(a, 10): combined a=%d\n" + (get combined "a" |> Option.value ~default:(-1)); + assert (length combined = 2); + assert (get combined "a" = Some 10); + + (* Right takes precedence *) + + (* Remove from right (left value should show through) *) + emit_right (Remove "a"); + Printf.printf "After right Remove(a): combined a=%d\n" + (get combined "a" |> Option.value ~default:(-1)); + assert (get combined "a" = Some 1); + + (* Left shows through *) + + (* Remove from left *) + emit_left (Remove "a"); + Printf.printf "After left Remove(a): combined=%d\n" (length combined); + assert (length combined = 1); + assert (get combined "a" = None); + assert (get combined "b" = Some 2); + + Printf.printf "PASSED\n\n" + +let test_union_with_merge () = + reset (); + Printf.printf "=== Test: union with merge ===\n"; + + (* Left collection *) + let left, emit_left = source ~name:"left" () in + + (* Right collection *) + let right, emit_right = source ~name:"right" () in + + (* Create union with set union as merge *) + let combined = union ~name:"combined" left right ~merge:IntSet.union () in + + (* Add to left: key "x" -> {1, 2} *) + emit_left (Set ("x", IntSet.of_list [1; 2])); + let v = get combined "x" |> Option.get in + Printf.printf "After left Set(x, {1,2}): {%s}\n" + (IntSet.elements v |> List.map string_of_int |> String.concat ", "); + assert (IntSet.equal v (IntSet.of_list [1; 2])); + + (* Add to right: key "x" -> {3, 4} (should merge) *) + emit_right (Set ("x", IntSet.of_list [3; 4])); + let v = get combined "x" |> Option.get in + Printf.printf "After right Set(x, {3,4}): {%s}\n" + (IntSet.elements v |> List.map string_of_int |> String.concat ", "); + assert (IntSet.equal v (IntSet.of_list [1; 2; 3; 4])); + + (* Update left: key "x" -> {1, 5} *) + emit_left (Set ("x", IntSet.of_list [1; 5])); + let v = get combined "x" |> Option.get in + Printf.printf "After left update to {1,5}: {%s}\n" + (IntSet.elements v |> List.map string_of_int |> String.concat ", "); + assert (IntSet.equal v (IntSet.of_list [1; 3; 4; 5])); + + (* Remove right *) + emit_right (Remove "x"); + let v = get combined "x" |> Option.get in + Printf.printf "After right Remove(x): {%s}\n" + (IntSet.elements v |> List.map string_of_int |> String.concat ", "); + assert (IntSet.equal v (IntSet.of_list [1; 5])); + + Printf.printf "PASSED\n\n" + +let test_union_existing_data () = + reset (); + Printf.printf "=== Test: union on collections with existing data ===\n"; + + (* Create collections with existing data *) + let left, emit_left = source ~name:"left" () in + emit_left (Set (1, "a")); + emit_left (Set (2, "b")); + + let right, emit_right = source ~name:"right" () in + emit_right (Set (2, "B")); + (* Overlaps with left *) + emit_right (Set (3, "c")); + + (* Create union after both have data *) + let combined = union ~name:"combined" left right () in + + Printf.printf "Union has %d entries (expected 3)\n" (length combined); + assert (length combined = 3); + assert (get combined 1 = Some "a"); + (* Only in left *) + assert (get combined 2 = Some "B"); + (* Right takes precedence *) + assert (get combined 3 = Some "c"); + + (* Only in right *) + Printf.printf "PASSED\n\n" + +let run_all () = + Printf.printf "\n====== Union Tests ======\n\n"; + test_union_basic (); + test_union_with_merge (); + test_union_existing_data () diff --git a/analysis/reactive/test/dune b/analysis/reactive/test/dune index 22584c8578..cd8fe3ad9c 100644 --- a/analysis/reactive/test/dune +++ b/analysis/reactive/test/dune @@ -1,3 +1,14 @@ (executable (name ReactiveTest) + (modules + ReactiveTest + TestHelpers + FlatMapTest + JoinTest + UnionTest + FixpointBasicTest + FixpointIncrementalTest + BatchTest + IntegrationTest + GlitchFreeTest) (libraries reactive)) diff --git a/analysis/reanalyze/ARCHITECTURE.md b/analysis/reanalyze/ARCHITECTURE.md index c6a440e472..5511cc4788 100644 --- a/analysis/reanalyze/ARCHITECTURE.md +++ b/analysis/reanalyze/ARCHITECTURE.md @@ -159,6 +159,33 @@ The reactive layer (`analysis/reactive/`) provides delta-based incremental updat | `lookup` | Single-key subscription | | `ReactiveFileCollection` | File-backed collection with change detection | +### Glitch-Free Semantics via Topological Scheduling + +The reactive system implements **glitch-free propagation** using a topological scheduler. This ensures derived collections always see consistent parent states, similar to SKStore's approach. + +**How it works:** +1. Each collection has a `level` (topological order): + - Source collections (e.g., `ReactiveFileCollection`) have `level = 0` + - Derived collections have `level = max(source levels) + 1` +2. When a delta propagates, emissions are **scheduled by level** +3. The scheduler processes all pending updates in level order +4. This ensures: sources complete → level 1 → level 2 → ... → observers + +**Example ordering:** +``` +Files (level 0) → file_data (level 1) → decls (level 2) → live (level 3) → issues (level 4) +``` + +When a batch of file changes arrives: +1. All level 1 collections update first +2. Then level 2, etc. +3. A join never sees one parent updated while the other is stale + +The `Reactive.Scheduler` module provides: +- `schedule ~level ~f` - Queue a thunk at a given level +- `is_propagating ()` - Check if in propagation phase +- Automatic propagation when scheduling outside of propagation + ### Fully Reactive Analysis Pipeline The reactive pipeline computes issues directly from source files with **zero recomputation on cache hits**: diff --git a/analysis/reanalyze/src/Liveness.ml b/analysis/reanalyze/src/Liveness.ml index 5c6059d72d..ea6542f008 100644 --- a/analysis/reanalyze/src/Liveness.ml +++ b/analysis/reanalyze/src/Liveness.ml @@ -123,6 +123,7 @@ let build_decl_refs_index ~(decl_store : DeclarationStore.t) let compute_forward ~debug ~(decl_store : DeclarationStore.t) ~(refs : References.t) ~(ann_store : AnnotationStore.t) : live_reason PosHash.t = + let t0 = Unix.gettimeofday () in let live = PosHash.create 256 in let worklist = Queue.create () in let root_count = ref 0 in @@ -216,6 +217,14 @@ let compute_forward ~debug ~(decl_store : DeclarationStore.t) Log_.item "@. %d declarations marked live via propagation@.@." !propagated_count; + let t1 = Unix.gettimeofday () in + if !Cli.timing then + Printf.eprintf + " Liveness.compute_forward: %.3fms (roots=%d, propagated=%d, live=%d)\n\ + %!" + ((t1 -. t0) *. 1000.0) + !root_count !propagated_count (PosHash.length live); + live (** Check if a position is live according to forward-computed liveness *) diff --git a/analysis/reanalyze/src/ReactiveAnalysis.ml b/analysis/reanalyze/src/ReactiveAnalysis.ml index fb0b4056e0..f29b6d04df 100644 --- a/analysis/reanalyze/src/ReactiveAnalysis.ml +++ b/analysis/reanalyze/src/ReactiveAnalysis.ml @@ -125,7 +125,7 @@ let length (collection : t) = ReactiveFileCollection.length collection Returns (path, file_data option) suitable for ReactiveMerge. *) let to_file_data_collection (collection : t) : (string, DceFileProcessing.file_data option) Reactive.t = - Reactive.flatMap + Reactive.flatMap ~name:"file_data_collection" (ReactiveFileCollection.to_collection collection) ~f:(fun path result_opt -> match result_opt with diff --git a/analysis/reanalyze/src/ReactiveDeclRefs.ml b/analysis/reanalyze/src/ReactiveDeclRefs.ml index 21031638fd..4698cbce2b 100644 --- a/analysis/reanalyze/src/ReactiveDeclRefs.ml +++ b/analysis/reanalyze/src/ReactiveDeclRefs.ml @@ -14,7 +14,7 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) (Lexing.position, PosSet.t * PosSet.t) Reactive.t = (* Group declarations by file *) let decls_by_file : (string, (Lexing.position * Decl.t) list) Reactive.t = - Reactive.flatMap decls + Reactive.flatMap ~name:"decl_refs.decls_by_file" decls ~f:(fun pos decl -> [(pos.Lexing.pos_fname, [(pos, decl)])]) ~merge:( @ ) () in @@ -28,7 +28,8 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) (* For each ref, find which decl(s) contain it and output (decl_pos, targets) *) let value_decl_refs : (Lexing.position, PosSet.t) Reactive.t = - Reactive.join value_refs_from decls_by_file + Reactive.join ~name:"decl_refs.value_decl_refs" value_refs_from + decls_by_file ~key_of:(fun posFrom _targets -> posFrom.Lexing.pos_fname) ~f:(fun posFrom targets decls_opt -> match decls_opt with @@ -42,7 +43,8 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) in let type_decl_refs : (Lexing.position, PosSet.t) Reactive.t = - Reactive.join type_refs_from decls_by_file + Reactive.join ~name:"decl_refs.type_decl_refs" type_refs_from + decls_by_file ~key_of:(fun posFrom _targets -> posFrom.Lexing.pos_fname) ~f:(fun posFrom targets decls_opt -> match decls_opt with @@ -58,7 +60,7 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) (* Combine value and type refs into (value_targets, type_targets) pairs. Use join to combine, with decls as the base to ensure all decls are present. *) let with_value_refs : (Lexing.position, PosSet.t) Reactive.t = - Reactive.join decls value_decl_refs + Reactive.join ~name:"decl_refs.with_value_refs" decls value_decl_refs ~key_of:(fun pos _decl -> pos) ~f:(fun pos _decl refs_opt -> [(pos, Option.value refs_opt ~default:PosSet.empty)]) @@ -66,7 +68,7 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) in let with_type_refs : (Lexing.position, PosSet.t) Reactive.t = - Reactive.join decls type_decl_refs + Reactive.join ~name:"decl_refs.with_type_refs" decls type_decl_refs ~key_of:(fun pos _decl -> pos) ~f:(fun pos _decl refs_opt -> [(pos, Option.value refs_opt ~default:PosSet.empty)]) @@ -74,7 +76,7 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) in (* Combine into final (value_targets, type_targets) pairs *) - Reactive.join with_value_refs with_type_refs + Reactive.join ~name:"decl_refs.combined" with_value_refs with_type_refs ~key_of:(fun pos _value_targets -> pos) ~f:(fun pos value_targets type_targets_opt -> let type_targets = Option.value type_targets_opt ~default:PosSet.empty in diff --git a/analysis/reanalyze/src/ReactiveExceptionRefs.ml b/analysis/reanalyze/src/ReactiveExceptionRefs.ml index 2775ad88b9..c696d30c5b 100644 --- a/analysis/reanalyze/src/ReactiveExceptionRefs.ml +++ b/analysis/reanalyze/src/ReactiveExceptionRefs.ml @@ -26,7 +26,7 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) ~(exception_refs : (DcePath.t, Location.t) Reactive.t) : t = (* Step 1: Index exception declarations by path *) let exception_decls = - Reactive.flatMap decls + Reactive.flatMap ~name:"exc_refs.exception_decls" decls ~f:(fun _pos (decl : Decl.t) -> match decl.Decl.declKind with | Exception -> @@ -44,7 +44,8 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) (* Step 2: Join exception_refs with exception_decls *) let resolved_refs = - Reactive.join exception_refs exception_decls + Reactive.join ~name:"exc_refs.resolved_refs" exception_refs + exception_decls ~key_of:(fun path _loc_from -> path) ~f:(fun _path loc_from loc_to_opt -> match loc_to_opt with @@ -60,7 +61,7 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) (* Step 3: Create refs_from direction by inverting *) let resolved_refs_from = - Reactive.flatMap resolved_refs + Reactive.flatMap ~name:"exc_refs.resolved_refs_from" resolved_refs ~f:(fun posTo posFromSet -> PosSet.elements posFromSet |> List.map (fun posFrom -> (posFrom, PosSet.singleton posTo))) diff --git a/analysis/reanalyze/src/ReactiveLiveness.ml b/analysis/reanalyze/src/ReactiveLiveness.ml index e4ad3be69f..4322bd0992 100644 --- a/analysis/reanalyze/src/ReactiveLiveness.ml +++ b/analysis/reanalyze/src/ReactiveLiveness.ml @@ -19,14 +19,14 @@ let create ~(merged : ReactiveMerge.t) : t = (* Combine value refs using union: per-file refs + exception refs *) let value_refs_from : (Lexing.position, PosSet.t) Reactive.t = - Reactive.union merged.value_refs_from + Reactive.union ~name:"liveness.value_refs_from" merged.value_refs_from merged.exception_refs.resolved_refs_from ~merge:PosSet.union () in (* Combine type refs using union: per-file refs + type deps from ReactiveTypeDeps *) let type_refs_from : (Lexing.position, PosSet.t) Reactive.t = - Reactive.union merged.type_refs_from merged.type_deps.all_type_refs_from - ~merge:PosSet.union () + Reactive.union ~name:"liveness.type_refs_from" merged.type_refs_from + merged.type_deps.all_type_refs_from ~merge:PosSet.union () in (* Step 1: Build decl_refs_index - maps decl -> (value_targets, type_targets) *) @@ -36,7 +36,7 @@ let create ~(merged : ReactiveMerge.t) : t = (* Step 2: Convert to edges format for fixpoint: decl -> successor list *) let edges : (Lexing.position, Lexing.position list) Reactive.t = - Reactive.flatMap decl_refs_index + Reactive.flatMap ~name:"liveness.edges" decl_refs_index ~f:(fun pos (value_targets, type_targets) -> let all_targets = PosSet.union value_targets type_targets in [(pos, PosSet.elements all_targets)]) @@ -55,7 +55,7 @@ let create ~(merged : ReactiveMerge.t) : t = We use join to explicitly track the dependency on decls. When a decl at position P arrives, any ref with posFrom=P will be reprocessed. *) let external_value_refs : (Lexing.position, unit) Reactive.t = - Reactive.join value_refs_from decls + Reactive.join ~name:"liveness.external_value_refs" value_refs_from decls ~key_of:(fun posFrom _targets -> posFrom) ~f:(fun _posFrom targets decl_opt -> match decl_opt with @@ -70,7 +70,7 @@ let create ~(merged : ReactiveMerge.t) : t = in let external_type_refs : (Lexing.position, unit) Reactive.t = - Reactive.join type_refs_from decls + Reactive.join ~name:"liveness.external_type_refs" type_refs_from decls ~key_of:(fun posFrom _targets -> posFrom) ~f:(fun _posFrom targets decl_opt -> match decl_opt with @@ -85,14 +85,15 @@ let create ~(merged : ReactiveMerge.t) : t = in let externally_referenced : (Lexing.position, unit) Reactive.t = - Reactive.union external_value_refs external_type_refs + Reactive.union ~name:"liveness.externally_referenced" external_value_refs + external_type_refs ~merge:(fun () () -> ()) () in (* Compute annotated roots: decls with @live or @genType *) let annotated_roots : (Lexing.position, unit) Reactive.t = - Reactive.join decls annotations + Reactive.join ~name:"liveness.annotated_roots" decls annotations ~key_of:(fun pos _decl -> pos) ~f:(fun pos _decl ann_opt -> match ann_opt with @@ -105,23 +106,29 @@ let create ~(merged : ReactiveMerge.t) : t = (* Combine all roots *) let all_roots : (Lexing.position, unit) Reactive.t = - Reactive.union annotated_roots externally_referenced + Reactive.union ~name:"liveness.all_roots" annotated_roots + externally_referenced ~merge:(fun () () -> ()) () in (* Step 4: Compute fixpoint - all reachable positions from roots *) - let live = Reactive.fixpoint ~init:all_roots ~edges () in + let live = + Reactive.fixpoint ~name:"liveness.live" ~init:all_roots ~edges () + in {live; edges; roots = all_roots} (** Print reactive collection update statistics *) let print_stats ~(t : t) : unit = let print name (c : _ Reactive.t) = let s = Reactive.stats c in - Printf.eprintf " %s: recv=%d emit=%d len=%d\n" name s.updates_received - s.updates_emitted (Reactive.length c) + Printf.eprintf + " %s: recv=%d/%d +%d -%d | emit=%d/%d +%d -%d | runs=%d len=%d\n" name + s.deltas_received s.entries_received s.adds_received s.removes_received + s.deltas_emitted s.entries_emitted s.adds_emitted s.removes_emitted + s.process_count (Reactive.length c) in - Printf.eprintf "ReactiveLiveness collection stats:\n"; + Printf.eprintf "ReactiveLiveness stats (recv=d/e/+/- emit=d/e/+/- runs):\n"; print "roots" t.roots; print "edges" t.edges; print "live (fixpoint)" t.live diff --git a/analysis/reanalyze/src/ReactiveMerge.ml b/analysis/reanalyze/src/ReactiveMerge.ml index 7eeab160ec..f06ce9f21a 100644 --- a/analysis/reanalyze/src/ReactiveMerge.ml +++ b/analysis/reanalyze/src/ReactiveMerge.ml @@ -21,11 +21,11 @@ type t = { (** {1 Creation} *) -let create (source : (string, DceFileProcessing.file_data option) Reactive.t) : - t = +let create (source : (string, DceFileProcessing.file_data option) Reactive.t) + : t = (* Declarations: (pos, Decl.t) with last-write-wins *) let decls = - Reactive.flatMap source + Reactive.flatMap ~name:"decls" source ~f:(fun _path file_data_opt -> match file_data_opt with | None -> [] @@ -36,7 +36,7 @@ let create (source : (string, DceFileProcessing.file_data option) Reactive.t) : (* Annotations: (pos, annotated_as) with last-write-wins *) let annotations = - Reactive.flatMap source + Reactive.flatMap ~name:"annotations" source ~f:(fun _path file_data_opt -> match file_data_opt with | None -> [] @@ -48,7 +48,7 @@ let create (source : (string, DceFileProcessing.file_data option) Reactive.t) : (* Value refs_from: (posFrom, PosSet of targets) with PosSet.union merge *) let value_refs_from = - Reactive.flatMap source + Reactive.flatMap ~name:"value_refs_from" source ~f:(fun _path file_data_opt -> match file_data_opt with | None -> [] @@ -60,7 +60,7 @@ let create (source : (string, DceFileProcessing.file_data option) Reactive.t) : (* Type refs_from: (posFrom, PosSet of targets) with PosSet.union merge *) let type_refs_from = - Reactive.flatMap source + Reactive.flatMap ~name:"type_refs_from" source ~f:(fun _path file_data_opt -> match file_data_opt with | None -> [] @@ -72,7 +72,7 @@ let create (source : (string, DceFileProcessing.file_data option) Reactive.t) : (* Cross-file items: (path, CrossFileItems.t) with merge by concatenation *) let cross_file_items = - Reactive.flatMap source + Reactive.flatMap ~name:"cross_file_items" source ~f:(fun path file_data_opt -> match file_data_opt with | None -> [] @@ -93,7 +93,7 @@ let create (source : (string, DceFileProcessing.file_data option) Reactive.t) : (* File deps map: (from_file, FileSet of to_files) with FileSet.union merge *) let file_deps_map = - Reactive.flatMap source + Reactive.flatMap ~name:"file_deps_map" source ~f:(fun _path file_data_opt -> match file_data_opt with | None -> [] @@ -104,7 +104,7 @@ let create (source : (string, DceFileProcessing.file_data option) Reactive.t) : (* Files set: (source_path, ()) - just track which source files exist *) let files = - Reactive.flatMap source + Reactive.flatMap ~name:"files" source ~f:(fun _cmt_path file_data_opt -> match file_data_opt with | None -> [] @@ -119,7 +119,7 @@ let create (source : (string, DceFileProcessing.file_data option) Reactive.t) : (* Extract exception_refs from cross_file_items for ReactiveExceptionRefs *) let exception_refs_collection = - Reactive.flatMap cross_file_items + Reactive.flatMap ~name:"exception_refs_collection" cross_file_items ~f:(fun _path items -> items.CrossFileItems.exception_refs |> List.map (fun (r : CrossFileItems.exception_ref) -> diff --git a/analysis/reanalyze/src/ReactiveSolver.ml b/analysis/reanalyze/src/ReactiveSolver.ml index 8ce15e7706..7e224e50fc 100644 --- a/analysis/reanalyze/src/ReactiveSolver.ml +++ b/analysis/reanalyze/src/ReactiveSolver.ml @@ -50,12 +50,13 @@ let decl_module_name (decl : Decl.t) : Name.t = let create ~(decls : (Lexing.position, Decl.t) Reactive.t) ~(live : (Lexing.position, unit) Reactive.t) - ~(annotations : (Lexing.position, FileAnnotations.annotated_as) Reactive.t) + ~(annotations : + (Lexing.position, FileAnnotations.annotated_as) Reactive.t) ~(value_refs_from : (Lexing.position, PosSet.t) Reactive.t option) ~(config : DceConfig.t) : t = (* dead_decls = decls where NOT in live (reactive join) *) let dead_decls = - Reactive.join decls live + Reactive.join ~name:"solver.dead_decls" decls live ~key_of:(fun pos _decl -> pos) ~f:(fun pos decl live_opt -> match live_opt with @@ -66,7 +67,7 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) (* live_decls = decls where in live (reactive join) *) let live_decls = - Reactive.join decls live + Reactive.join ~name:"solver.live_decls" decls live ~key_of:(fun pos _decl -> pos) ~f:(fun pos decl live_opt -> match live_opt with @@ -79,23 +80,26 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) let dead_modules = if not config.DceConfig.run.transitive then (* Dead modules only reported in transitive mode *) - Reactive.flatMap dead_decls ~f:(fun _ _ -> []) () + Reactive.flatMap ~name:"solver.dead_modules_empty" dead_decls + ~f:(fun _ _ -> []) + () else (* modules_with_dead: (moduleName, loc) for each module with dead decls *) let modules_with_dead = - Reactive.flatMap dead_decls + Reactive.flatMap ~name:"solver.modules_with_dead" dead_decls ~f:(fun _pos decl -> [(decl_module_name decl, decl.moduleLoc)]) ~merge:(fun loc1 _loc2 -> loc1) (* keep first location *) () in (* modules_with_live: (moduleName, ()) for each module with live decls *) let modules_with_live = - Reactive.flatMap live_decls + Reactive.flatMap ~name:"solver.modules_with_live" live_decls ~f:(fun _pos decl -> [(decl_module_name decl, ())]) () in (* Anti-join: modules in dead but not in live *) - Reactive.join modules_with_dead modules_with_live + Reactive.join ~name:"solver.dead_modules" modules_with_dead + modules_with_live ~key_of:(fun modName _loc -> modName) ~f:(fun modName loc live_opt -> match live_opt with @@ -106,7 +110,7 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) (* Reactive per-file grouping of dead declarations *) let dead_decls_by_file = - Reactive.flatMap dead_decls + Reactive.flatMap ~name:"solver.dead_decls_by_file" dead_decls ~f:(fun _pos decl -> [(decl.pos.Lexing.pos_fname, [decl])]) ~merge:(fun decls1 decls2 -> decls1 @ decls2) () @@ -119,7 +123,7 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) | None -> None | Some refs_from -> Some - (Reactive.flatMap refs_from + (Reactive.flatMap ~name:"solver.value_refs_from_by_file" refs_from ~f:(fun posFrom posToSet -> [(posFrom.Lexing.pos_fname, [(posFrom, posToSet)])]) ~merge:(fun refs1 refs2 -> refs1 @ refs2) @@ -133,7 +137,7 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) hasRefBelow now uses per-file refs: O(file_refs) instead of O(total_refs). *) let issues_by_file = - Reactive.flatMap dead_decls_by_file + Reactive.flatMap ~name:"solver.issues_by_file" dead_decls_by_file ~f:(fun file decls -> (* Track modules that have reported values *) let modules_with_values : (Name.t, unit) Hashtbl.t = Hashtbl.create 8 in @@ -186,7 +190,7 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) (* Reactive incorrect @dead: live decls with @dead annotation *) let incorrect_dead_decls = - Reactive.join live_decls annotations + Reactive.join ~name:"solver.incorrect_dead_decls" live_decls annotations ~key_of:(fun pos _decl -> pos) ~f:(fun pos decl ann_opt -> match ann_opt with @@ -197,7 +201,7 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) (* Reactive modules_with_reported: modules that have at least one reported dead value *) let modules_with_reported = - Reactive.flatMap issues_by_file + Reactive.flatMap ~name:"solver.modules_with_reported" issues_by_file ~f:(fun _file (_issues, modules_list) -> List.map (fun m -> (m, ())) modules_list) () @@ -205,7 +209,8 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) (* Reactive dead module issues: dead_modules joined with modules_with_reported *) let dead_module_issues = - Reactive.join dead_modules modules_with_reported + Reactive.join ~name:"solver.dead_module_issues" dead_modules + modules_with_reported ~key_of:(fun moduleName _loc -> moduleName) ~f:(fun moduleName loc has_reported_opt -> match has_reported_opt with @@ -336,10 +341,13 @@ let stats ~(t : t) : int * int = let print_stats ~(t : t) : unit = let print name (c : _ Reactive.t) = let s = Reactive.stats c in - Printf.eprintf " %s: recv=%d emit=%d len=%d\n" name s.updates_received - s.updates_emitted (Reactive.length c) + Printf.eprintf + " %s: recv=%d/%d +%d -%d | emit=%d/%d +%d -%d | runs=%d len=%d\n" name + s.deltas_received s.entries_received s.adds_received s.removes_received + s.deltas_emitted s.entries_emitted s.adds_emitted s.removes_emitted + s.process_count (Reactive.length c) in - Printf.eprintf "ReactiveSolver collection stats:\n"; + Printf.eprintf "ReactiveSolver stats (recv=d/e/+/- emit=d/e/+/- runs):\n"; print "dead_decls" t.dead_decls; print "live_decls" t.live_decls; print "dead_modules" t.dead_modules; diff --git a/analysis/reanalyze/src/ReactiveTypeDeps.ml b/analysis/reanalyze/src/ReactiveTypeDeps.ml index 1988fb86f9..5fd0694405 100644 --- a/analysis/reanalyze/src/ReactiveTypeDeps.ml +++ b/analysis/reanalyze/src/ReactiveTypeDeps.ml @@ -49,7 +49,7 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) ~(report_types_dead_only_in_interface : bool) : t = (* Step 1: Index decls by path *) let decl_by_path = - Reactive.flatMap decls + Reactive.flatMap ~name:"type_deps.decl_by_path" decls ~f:(fun _pos decl -> match decl_to_info decl with | Some info -> [(info.path, [info])] @@ -59,7 +59,7 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) (* Step 2: Same-path refs - connect all decls at the same path *) let same_path_refs = - Reactive.flatMap decl_by_path + Reactive.flatMap ~name:"type_deps.same_path_refs" decl_by_path ~f:(fun _path decls -> match decls with | [] | [_] -> [] @@ -81,7 +81,7 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) (* Step 3: Cross-file refs - connect impl decls to intf decls *) (* First, extract impl decls that need to look up intf *) let impl_decls = - Reactive.flatMap decls + Reactive.flatMap ~name:"type_deps.impl_decls" decls ~f:(fun _pos decl -> match decl_to_info decl with | Some info when not info.is_interface -> ( @@ -102,7 +102,7 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) Original: extendTypeDependencies loc loc1 where loc=impl, loc1=intf adds posTo=impl, posFrom=intf *) let impl_to_intf_refs = - Reactive.join impl_decls decl_by_path + Reactive.join ~name:"type_deps.impl_to_intf_refs" impl_decls decl_by_path ~key_of:(fun _pos (_, intf_path1, _) -> intf_path1) ~f:(fun _pos (info, _intf_path1, _intf_path2) intf_decls_opt -> match intf_decls_opt with @@ -119,7 +119,7 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) (* Second join for path2 fallback *) let impl_needing_path2 = - Reactive.join impl_decls decl_by_path + Reactive.join ~name:"type_deps.impl_needing_path2" impl_decls decl_by_path ~key_of:(fun _pos (_, intf_path1, _) -> intf_path1) ~f:(fun pos (info, _intf_path1, intf_path2) intf_decls_opt -> match intf_decls_opt with @@ -129,7 +129,8 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) in let impl_to_intf_refs_path2 = - Reactive.join impl_needing_path2 decl_by_path + Reactive.join ~name:"type_deps.impl_to_intf_refs_path2" impl_needing_path2 + decl_by_path ~key_of:(fun _pos (_, intf_path2) -> intf_path2) ~f:(fun _pos (info, _) intf_decls_opt -> match intf_decls_opt with @@ -148,7 +149,7 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) The intf->impl code in original only runs when isInterface=true, and the lookup is for finding the impl. *) let intf_decls = - Reactive.flatMap decls + Reactive.flatMap ~name:"type_deps.intf_decls" decls ~f:(fun _pos decl -> match decl_to_info decl with | Some info when info.is_interface -> ( @@ -164,7 +165,7 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) in let intf_to_impl_refs = - Reactive.join intf_decls decl_by_path + Reactive.join ~name:"type_deps.intf_to_impl_refs" intf_decls decl_by_path ~key_of:(fun _pos (_, impl_path) -> impl_path) ~f:(fun _pos (intf_info, _) impl_decls_opt -> match impl_decls_opt with @@ -206,15 +207,18 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) (* Combine all refs_to sources using union *) let combined_refs_to = let u1 = - Reactive.union same_path_refs cross_file_refs ~merge:PosSet.union () + Reactive.union ~name:"type_deps.u1" same_path_refs cross_file_refs + ~merge:PosSet.union () in let u2 = - Reactive.union u1 impl_to_intf_refs_path2 ~merge:PosSet.union () + Reactive.union ~name:"type_deps.u2" u1 impl_to_intf_refs_path2 + ~merge:PosSet.union () in - Reactive.union u2 intf_to_impl_refs ~merge:PosSet.union () + Reactive.union ~name:"type_deps.combined_refs_to" u2 intf_to_impl_refs + ~merge:PosSet.union () in (* Invert the combined refs_to to refs_from *) - Reactive.flatMap combined_refs_to + Reactive.flatMap ~name:"type_deps.all_type_refs_from" combined_refs_to ~f:(fun posTo posFromSet -> PosSet.elements posFromSet |> List.map (fun posFrom -> (posFrom, PosSet.singleton posTo))) From 87c6dff3e4114fca72f4705d51616d87b1dba256 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Wed, 17 Dec 2025 11:00:51 +0100 Subject: [PATCH 36/45] Add -mermaid flag and update reactive pipeline diagram - Add -mermaid CLI flag to output auto-generated Mermaid diagram - Update manual diagram to show all nodes (no hidden intermediate nodes) - Use clearer names (less abbreviated, multi-line where needed) - Regenerate SVG --- .../reanalyze/diagrams/reactive-pipeline.mmd | 240 +++++++++++------- .../reanalyze/diagrams/reactive-pipeline.svg | 2 +- analysis/reanalyze/src/Cli.ml | 3 + analysis/reanalyze/src/Reanalyze.ml | 3 + 4 files changed, 159 insertions(+), 89 deletions(-) diff --git a/analysis/reanalyze/diagrams/reactive-pipeline.mmd b/analysis/reanalyze/diagrams/reactive-pipeline.mmd index a086482f46..4387813813 100644 --- a/analysis/reanalyze/diagrams/reactive-pipeline.mmd +++ b/analysis/reanalyze/diagrams/reactive-pipeline.mmd @@ -1,113 +1,177 @@ %%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#e8f4fd', 'primaryTextColor': '#1a1a1a', 'primaryBorderColor': '#4a90d9', 'lineColor': '#4a90d9', 'secondaryColor': '#f0f7e6', 'tertiaryColor': '#fff5e6'}}}%% flowchart TB subgraph FileLayer["File Layer"] - RFC[("RFC")] + file_collection[("file_collection")] end subgraph FileData["Per-File Data"] - FD["FD"] + file_data[file_data] + files[files] + file_deps[file_deps] end subgraph Extracted["Extracted (ReactiveMerge)"] - DECLS["D"] - ANNOT["A"] - VREFS["VR"] - TREFS["TR"] - CFI["CFI"] + decls[decls] + annotations[annotations] + value_refs[value_refs] + type_refs[type_refs] + cross_file[cross_file
items] end subgraph TypeDeps["ReactiveTypeDeps"] - DBP["DBP"] - ATR["ATR"] + decl_by_path[decl_by_path] + intf_decls[intf_decls] + impl_decls[impl_decls] + intf_to_impl[intf→impl] + impl_to_intf[impl→intf] + same_path[same_path] + combined_refs[combined
refs_to] + all_type_refs[all_type
refs_from] end - subgraph ExcDeps["ReactiveExceptionRefs"] - EXCREF["ER"] - EXCDECL["ED"] - RESOLVED["RR"] + subgraph ExcRefs["ReactiveExceptionRefs"] + exc_collection[exception
refs] + exc_decls[exception
decls] + resolved_refs[resolved
refs] + resolved_from[resolved
refs_from] end subgraph DeclRefs["ReactiveDeclRefs"] - DR["DR"] + decls_by_file[decls
by_file] + value_decl_refs[value
decl_refs] + type_decl_refs[type
decl_refs] + with_value[with_value
refs] + with_type[with_type
refs] + combined[combined] end subgraph Liveness["ReactiveLiveness"] - ROOTS["roots"] - EDGES["edges"] - FP["fixpoint"] - LIVE["LIVE"] + value_refs_from[value
refs_from] + type_refs_from[type
refs_from] + ext_value_refs[external
value_refs] + ext_type_refs[external
type_refs] + ext_referenced[externally
referenced] + annotated_roots[annotated
roots] + all_roots[all_roots] + edges[edges] + live[live
fixpoint] end subgraph Solver["ReactiveSolver"] - DEAD_DECLS["dead_decls"] - LIVE_DECLS["live_decls"] - DEAD_MODULES["dead_modules"] - DEAD_BY_FILE["dead_by_file"] - REFS_BY_FILE["refs_by_file"] - ISSUES_BY_FILE["issues_by_file"] - INCORRECT["incorrect_dead"] - MOD_REPORTED["mod_reported"] - MOD_ISSUES["mod_issues"] + dead_decls[dead_decls] + live_decls[live_decls] + modules_dead[modules
with_dead] + modules_live[modules
with_live] + dead_modules[dead
modules] + dead_by_file[dead
by_file] + issues_by_file[issues
by_file] + incorrect_dead[incorrect
dead] + modules_reported[modules
reported] + module_issues[module
issues] end subgraph Report["Report (iter only)"] OUTPUT[("REPORT")] end - RFC -->|"process"| FD - FD -->|"flatMap"| DECLS - FD -->|"flatMap"| ANNOT - FD -->|"flatMap"| VREFS - FD -->|"flatMap"| TREFS - FD -->|"flatMap"| CFI - - DECLS -->|"flatMap"| DBP - DBP -->|"union+flatMap"| ATR - - CFI -->|"flatMap"| EXCREF - DECLS -->|"flatMap"| EXCDECL - EXCREF -->|"join"| RESOLVED - EXCDECL -->|"join"| RESOLVED - - DECLS --> DR - VREFS --> DR - TREFS --> DR - ATR --> DR - RESOLVED --> DR - - DECLS --> ROOTS - ANNOT --> ROOTS - - DR -->|"flatMap"| EDGES - ROOTS --> FP - EDGES --> FP - FP -->|"fixpoint"| LIVE - - DECLS -->|"join (NOT in live)"| DEAD_DECLS - LIVE -->|"join"| DEAD_DECLS - DECLS -->|"join (IN live)"| LIVE_DECLS - LIVE -->|"join"| LIVE_DECLS - - DEAD_DECLS -->|"flatMap (anti-join)"| DEAD_MODULES - LIVE_DECLS -->|"flatMap (anti-join)"| DEAD_MODULES - - DEAD_DECLS -->|"flatMap by file"| DEAD_BY_FILE - VREFS -->|"flatMap by file"| REFS_BY_FILE - - DEAD_BY_FILE -->|"flatMap"| ISSUES_BY_FILE - ANNOT -->|"filter"| ISSUES_BY_FILE - REFS_BY_FILE -->|"hasRefBelow"| ISSUES_BY_FILE - - LIVE_DECLS -->|"join (@dead)"| INCORRECT - ANNOT -->|"join (@dead)"| INCORRECT - - ISSUES_BY_FILE -->|"flatMap"| MOD_REPORTED - DEAD_MODULES -->|"join"| MOD_ISSUES - MOD_REPORTED -->|"join"| MOD_ISSUES - - ISSUES_BY_FILE -->|"iter"| OUTPUT - INCORRECT -->|"iter"| OUTPUT - MOD_ISSUES -->|"iter"| OUTPUT + %% File Layer + file_collection -->|process| file_data + file_data --> files + file_data --> file_deps + file_data --> decls + file_data --> annotations + file_data --> value_refs + file_data --> type_refs + file_data --> cross_file + + %% TypeDeps + decls --> decl_by_path + decls --> intf_decls + decls --> impl_decls + decl_by_path --> intf_to_impl + decl_by_path --> impl_to_intf + decl_by_path --> same_path + intf_decls --> intf_to_impl + impl_decls --> impl_to_intf + intf_to_impl --> combined_refs + impl_to_intf --> combined_refs + same_path --> combined_refs + combined_refs --> all_type_refs + + %% ExceptionRefs + cross_file --> exc_collection + decls --> exc_decls + exc_collection --> resolved_refs + exc_decls --> resolved_refs + resolved_refs --> resolved_from + + %% DeclRefs + decls --> decls_by_file + decls --> with_value + decls --> with_type + decls_by_file --> value_decl_refs + decls_by_file --> type_decl_refs + value_decl_refs --> with_value + type_decl_refs --> with_type + with_value --> combined + with_type --> combined + + %% Liveness inputs + value_refs --> value_refs_from + type_refs --> type_refs_from + all_type_refs --> type_refs_from + resolved_from --> value_refs_from + + %% Liveness external refs + decls --> ext_value_refs + decls --> ext_type_refs + value_refs_from --> ext_value_refs + type_refs_from --> ext_type_refs + value_refs_from --> value_decl_refs + type_refs_from --> type_decl_refs + ext_value_refs --> ext_referenced + ext_type_refs --> ext_referenced + + %% Liveness roots + decls --> annotated_roots + annotations --> annotated_roots + ext_referenced --> all_roots + annotated_roots --> all_roots + + %% Liveness fixpoint + combined --> edges + all_roots --> live + edges --> live + + %% Solver partition + decls --> dead_decls + decls --> live_decls + live --> dead_decls + live --> live_decls + + %% Solver modules + dead_decls --> modules_dead + live_decls --> modules_live + modules_dead --> dead_modules + modules_live --> dead_modules + + %% Solver issues + dead_decls --> dead_by_file + dead_by_file --> issues_by_file + + %% Solver incorrect @dead + live_decls --> incorrect_dead + annotations --> incorrect_dead + + %% Solver module issues + issues_by_file --> modules_reported + dead_modules --> module_issues + modules_reported --> module_issues + + %% Output + issues_by_file -->|iter| OUTPUT + incorrect_dead -->|iter| OUTPUT + module_issues -->|iter| OUTPUT classDef fileLayer fill:#e8f4fd,stroke:#4a90d9,stroke-width:2px classDef extracted fill:#f0f7e6,stroke:#6b8e23,stroke-width:2px @@ -118,11 +182,11 @@ flowchart TB classDef solver fill:#ffe6f0,stroke:#cc6699,stroke-width:2px classDef output fill:#e6ffe6,stroke:#2e8b2e,stroke-width:2px - class RFC,FD fileLayer - class DECLS,ANNOT,VREFS,TREFS,CFI extracted - class DBP,ATR typeDeps - class EXCREF,EXCDECL,RESOLVED excDeps - class DR declRefs - class ROOTS,EDGES,FP,LIVE liveness - class DEAD_DECLS,LIVE_DECLS,DEAD_MODULES,DEAD_BY_FILE,REFS_BY_FILE,ISSUES_BY_FILE,INCORRECT,MOD_REPORTED,MOD_ISSUES solver + class file_collection,file_data,files,file_deps fileLayer + class decls,annotations,value_refs,type_refs,cross_file extracted + class decl_by_path,intf_decls,impl_decls,intf_to_impl,impl_to_intf,same_path,combined_refs,all_type_refs typeDeps + class exc_collection,exc_decls,resolved_refs,resolved_from excDeps + class decls_by_file,value_decl_refs,type_decl_refs,with_value,with_type,combined declRefs + class value_refs_from,type_refs_from,ext_value_refs,ext_type_refs,ext_referenced,annotated_roots,all_roots,edges,live liveness + class dead_decls,live_decls,modules_dead,modules_live,dead_modules,dead_by_file,issues_by_file,incorrect_dead,modules_reported,module_issues solver class OUTPUT output diff --git a/analysis/reanalyze/diagrams/reactive-pipeline.svg b/analysis/reanalyze/diagrams/reactive-pipeline.svg index 2eb988d960..570b15bcc0 100644 --- a/analysis/reanalyze/diagrams/reactive-pipeline.svg +++ b/analysis/reanalyze/diagrams/reactive-pipeline.svg @@ -1 +1 @@ -

Report (iter only)

ReactiveSolver

ReactiveLiveness

ReactiveDeclRefs

ReactiveExceptionRefs

ReactiveTypeDeps

Extracted (ReactiveMerge)

Per-File Data

File Layer

process

flatMap

flatMap

flatMap

flatMap

flatMap

flatMap

union+flatMap

flatMap

flatMap

join

join

flatMap

fixpoint

join (NOT in live)

join

join (IN live)

join

flatMap (anti-join)

flatMap (anti-join)

flatMap by file

flatMap by file

flatMap

filter

hasRefBelow

join (@dead)

join (@dead)

flatMap

join

join

iter

iter

iter

RFC

FD

D

A

VR

TR

CFI

DBP

ATR

ER

ED

RR

DR

roots

edges

fixpoint

LIVE

dead_decls

live_decls

dead_modules

dead_by_file

refs_by_file

issues_by_file

incorrect_dead

mod_reported

mod_issues

REPORT

\ No newline at end of file +

Report (iter only)

ReactiveSolver

ReactiveLiveness

ReactiveDeclRefs

ReactiveExceptionRefs

ReactiveTypeDeps

Extracted (ReactiveMerge)

Per-File Data

File Layer

process

iter

iter

iter

file_collection

file_data

files

file_deps

decls

annotations

value_refs

type_refs

cross_file
items

decl_by_path

intf_decls

impl_decls

intf→impl

impl→intf

same_path

combined
refs_to

all_type
refs_from

exception
refs

exception
decls

resolved
refs

resolved
refs_from

decls
by_file

value
decl_refs

type
decl_refs

with_value
refs

with_type
refs

combined

value
refs_from

type
refs_from

external
value_refs

external
type_refs

externally
referenced

annotated
roots

all_roots

edges

live
fixpoint

dead_decls

live_decls

modules
with_dead

modules
with_live

dead
modules

dead
by_file

issues
by_file

incorrect
dead

modules
reported

module
issues

REPORT

\ No newline at end of file diff --git a/analysis/reanalyze/src/Cli.ml b/analysis/reanalyze/src/Cli.ml index d8ce55db9d..499e370ff9 100644 --- a/analysis/reanalyze/src/Cli.ml +++ b/analysis/reanalyze/src/Cli.ml @@ -33,3 +33,6 @@ let reactive = ref false (* number of analysis runs (for benchmarking reactive mode) *) let runs = ref 1 + +(* output mermaid diagram of reactive pipeline *) +let mermaid = ref false diff --git a/analysis/reanalyze/src/Reanalyze.ml b/analysis/reanalyze/src/Reanalyze.ml index 97ebcade5b..91c923efb1 100644 --- a/analysis/reanalyze/src/Reanalyze.ml +++ b/analysis/reanalyze/src/Reanalyze.ml @@ -419,6 +419,8 @@ let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge | Some liveness -> ReactiveLiveness.print_stats ~t:liveness | None -> ()); ReactiveSolver.print_stats ~t:solver); + if !Cli.mermaid then + Printf.eprintf "\n%s\n" (Reactive.to_mermaid ()); Some (AnalysisResult.add_issues AnalysisResult.empty all_issues) | None -> (* Non-reactive path: use old solver with optional args *) @@ -645,6 +647,7 @@ let cli () = "n Process files in parallel using n domains (0 = sequential, default; \ -1 = auto-detect cores)" ); ("-timing", Set Cli.timing, "Report internal timing of analysis phases"); + ("-mermaid", Set Cli.mermaid, "Output Mermaid diagram of reactive pipeline"); ( "-reactive", Set Cli.reactive, "Use reactive analysis (caches processed file_data, skips unchanged \ From a438152704a25354898aec522420b5990db40ce0 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Wed, 17 Dec 2025 11:04:17 +0100 Subject: [PATCH 37/45] Update ARCHITECTURE.md to reflect current reactive system - Remove lookup combinator (was deleted) - Add source and Batch delta type - Update scheduler description to accumulate-then-propagate design - Replace ASCII diagram with reference to Mermaid diagram - Add Stats Tracking section documenting -timing output - Update Reactive module description --- analysis/reanalyze/ARCHITECTURE.md | 129 ++++++++++------------------- 1 file changed, 43 insertions(+), 86 deletions(-) diff --git a/analysis/reanalyze/ARCHITECTURE.md b/analysis/reanalyze/ARCHITECTURE.md index 5511cc4788..f89b7d104e 100644 --- a/analysis/reanalyze/ARCHITECTURE.md +++ b/analysis/reanalyze/ARCHITECTURE.md @@ -151,40 +151,40 @@ The reactive layer (`analysis/reactive/`) provides delta-based incremental updat | `subscribe` | Register for delta notifications | | `iter` | Iterate current entries | | `get` | Lookup by key | -| `delta` | Change notification: `Set (key, value)` or `Remove key` | +| `delta` | Change notification: `Set (k, v)`, `Remove k`, or `Batch [(k, v option); ...]` | +| `source` | Create a mutable source collection with emit function | | `flatMap` | Transform collection, optionally merge same-key values | | `join` | Hash join two collections (left join behavior) | | `union` | Combine two collections, optionally merge same-key values | | `fixpoint` | Transitive closure: `init + edges → reachable` | -| `lookup` | Single-key subscription | | `ReactiveFileCollection` | File-backed collection with change detection | ### Glitch-Free Semantics via Topological Scheduling -The reactive system implements **glitch-free propagation** using a topological scheduler. This ensures derived collections always see consistent parent states, similar to SKStore's approach. +The reactive system implements **glitch-free propagation** using an accumulate-then-propagate scheduler. This ensures derived collections always see consistent parent states, similar to SKStore's approach. **How it works:** -1. Each collection has a `level` (topological order): - - Source collections (e.g., `ReactiveFileCollection`) have `level = 0` - - Derived collections have `level = max(source levels) + 1` -2. When a delta propagates, emissions are **scheduled by level** -3. The scheduler processes all pending updates in level order -4. This ensures: sources complete → level 1 → level 2 → ... → observers +1. Each node has a `level` (topological order): + - Source collections have `level = 0` + - Derived collections have `level = max(parent levels) + 1` +2. Each combinator **accumulates** incoming deltas in pending buffers +3. The scheduler visits dirty nodes in level order and calls `process()` +4. Each node processes **once per wave** with complete input from all parents **Example ordering:** ``` -Files (level 0) → file_data (level 1) → decls (level 2) → live (level 3) → issues (level 4) +file_collection (L0) → file_data (L1) → decls (L2) → live (L14) → dead_decls (L15) ``` When a batch of file changes arrives: -1. All level 1 collections update first -2. Then level 2, etc. -3. A join never sees one parent updated while the other is stale +1. Deltas accumulate in pending buffers (no immediate processing) +2. Scheduler processes level 0, then level 1, etc. +3. A join processes only after **both** parents have updated -The `Reactive.Scheduler` module provides: -- `schedule ~level ~f` - Queue a thunk at a given level -- `is_propagating ()` - Check if in propagation phase -- Automatic propagation when scheduling outside of propagation +The `Reactive.Registry` and `Reactive.Scheduler` modules provide: +- Named nodes with stats tracking (use `-timing` flag to see stats) +- `to_mermaid()` - Generate pipeline diagram (use `-mermaid` flag) +- `print_stats()` - Show per-node timing and delta counts ### Fully Reactive Analysis Pipeline @@ -235,74 +235,16 @@ Files → file_data → decls, annotations, refs → live (fixpoint) → dead/li ![Reactive Pipeline](diagrams/reactive-pipeline.svg) -``` -┌───────────────────────────────────────────────────────────────────────────────────┐ -│ REACTIVE ANALYSIS PIPELINE │ -│ │ -│ ┌──────────┐ │ -│ │ .cmt │ │ -│ │ files │ │ -│ └────┬─────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────────────┐ │ -│ │ ReactiveFileCollection│ File change detection + caching │ -│ │ file_data │ │ -│ └────┬─────────────────┘ │ -│ │ flatMap │ -│ ▼ │ -│ ┌──────────────────────┐ │ -│ │ ReactiveMerge │ Derives collections from file_data │ -│ │ ┌──────┐ ┌────────┐ │ │ -│ │ │decls │ │ refs │ │ │ -│ │ └──┬───┘ └───┬────┘ │ │ -│ │ │ ┌──────┴─────┐ │ │ -│ │ │ │annotations │ │ │ -│ │ │ └──────┬─────┘ │ │ -│ └────┼─────────┼───────┘ │ -│ │ │ │ -│ │ ▼ │ -│ │ ┌─────────────────────┐ │ -│ │ │ ReactiveLiveness │ roots + edges → live (fixpoint) │ -│ │ │ ┌──────┐ ┌──────┐ │ │ -│ │ │ │roots │→│ live │ │ │ -│ │ │ └──────┘ └──┬───┘ │ │ -│ │ └──────────────┼──────┘ │ -│ │ │ │ -│ ▼ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────────────────┐ │ -│ │ ReactiveSolver │ │ -│ │ │ │ -│ │ decls ──┬──► dead_decls ──┬──► dead_decls_by_file ──► issues_by_file │ │ -│ │ │ │ │ │ │ │ │ -│ │ live ───┤ │ │ │ ▼ │ │ -│ │ │ ▼ │ │ modules_with_reported │ │ -│ │ │ dead_modules ─┼────────────┼──────────────┬─────────┘ │ │ -│ │ │ ↑ │ │ │ │ │ -│ │ └──► live_decls ──┼────────────┘ ▼ │ │ -│ │ │ │ dead_module_issues │ │ -│ │ │ │ │ │ -│ │ annotations ─────┼────────┴──► incorrect_dead_decls │ │ -│ │ │ │ │ -│ │ value_refs_from ─┴──► refs_by_file (used by issues_by_file for hasRefBelow)│ │ -│ │ │ │ -│ │ (Optional args: TODO - not yet reactive, ~8-14ms) │ │ -│ └─────────────────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────────────────┐ │ -│ │ REPORT │ │ -│ │ │ │ -│ │ collect_issues iterates pre-computed reactive collections: │ │ -│ │ - incorrect_dead_decls (~0.04ms) │ │ -│ │ - issues_by_file (~0.6ms) │ │ -│ │ - dead_module_issues (~0.06ms) │ │ -│ │ │ │ -│ │ Total dead_code solving: ~0.7ms on cache hit │ │ -│ └─────────────────────────────────────────────────────────────────────────────┘ │ -│ │ -└───────────────────────────────────────────────────────────────────────────────────┘ -``` +The Mermaid diagram above shows all 44 reactive nodes. Key stages: + +1. **File Layer**: `file_collection` → `file_data` → extracted collections +2. **TypeDeps**: `decl_by_path` → interface/implementation refs → `all_type_refs` +3. **ExceptionRefs**: `cross_file` → `resolved_refs` → `resolved_from` +4. **DeclRefs**: Combines value/type refs → `combined` edges +5. **Liveness**: `annotated_roots` + `externally_referenced` → `all_roots` + `edges` → `live` (fixpoint) +6. **Solver**: `decls` + `live` → `dead_decls`/`live_decls` → per-file issues → module issues + +Use `-mermaid` flag to generate the current pipeline diagram from code. ### Delta Propagation @@ -334,7 +276,7 @@ No joins are recomputed, no fixpoints are re-run - the reactive collections are | Module | Responsibility | |--------|---------------| -| `Reactive` | Core primitives: `flatMap`, `join`, `union`, `fixpoint`, delta types | +| `Reactive` | Core primitives: `source`, `flatMap`, `join`, `union`, `fixpoint`, `Scheduler`, `Registry` | | `ReactiveFileCollection` | File-backed collection with change detection | | `ReactiveAnalysis` | CMT processing with file caching | | `ReactiveMerge` | Derives decls, annotations, refs from file_data | @@ -344,6 +286,21 @@ No joins are recomputed, no fixpoints are re-run - the reactive collections are | `ReactiveLiveness` | Computes live positions via reactive fixpoint | | `ReactiveSolver` | Computes dead_decls and issues via reactive joins | +### Stats Tracking + +Use `-timing` flag to see per-node statistics: + +| Stat | Description | +|------|-------------| +| `d_recv` | Deltas received (Set/Remove/Batch messages) | +| `e_recv` | Entries received (after batch expansion) | +| `+in` / `-in` | Adds/removes received from upstream | +| `d_emit` | Deltas emitted downstream | +| `e_emit` | Entries in emitted deltas | +| `+out` / `-out` | Adds/removes emitted (non-zero `-out` indicates churn) | +| `runs` | Times the node's `process()` was called | +| `time_ms` | Cumulative processing time | + --- ## Testing From fcb834e42e9a1fcdcadec1337303b7b10d1cf25b Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Wed, 17 Dec 2025 11:12:52 +0100 Subject: [PATCH 38/45] Simplify reactive pipeline diagram with clearer names - Use readable names instead of abbreviations (decls not D, etc.) - Keep high-level abstraction (~25 nodes, hides internal details) - Document -mermaid flag for full 44-node auto-generated diagram - Regenerate SVG --- analysis/reanalyze/ARCHITECTURE.md | 7 +- .../reanalyze/diagrams/reactive-pipeline.mmd | 183 ++++++------------ .../reanalyze/diagrams/reactive-pipeline.svg | 2 +- 3 files changed, 62 insertions(+), 130 deletions(-) diff --git a/analysis/reanalyze/ARCHITECTURE.md b/analysis/reanalyze/ARCHITECTURE.md index f89b7d104e..3f7862f970 100644 --- a/analysis/reanalyze/ARCHITECTURE.md +++ b/analysis/reanalyze/ARCHITECTURE.md @@ -235,7 +235,12 @@ Files → file_data → decls, annotations, refs → live (fixpoint) → dead/li ![Reactive Pipeline](diagrams/reactive-pipeline.svg) -The Mermaid diagram above shows all 44 reactive nodes. Key stages: +This is a high-level view (~25 nodes). For the full detailed diagram with all 44 nodes, run: +```bash +dune exec rescript-editor-analysis -- reanalyze -dce -config -ci -reactive -mermaid +``` + +Key stages: 1. **File Layer**: `file_collection` → `file_data` → extracted collections 2. **TypeDeps**: `decl_by_path` → interface/implementation refs → `all_type_refs` diff --git a/analysis/reanalyze/diagrams/reactive-pipeline.mmd b/analysis/reanalyze/diagrams/reactive-pipeline.mmd index 4387813813..d3ba094a50 100644 --- a/analysis/reanalyze/diagrams/reactive-pipeline.mmd +++ b/analysis/reanalyze/diagrams/reactive-pipeline.mmd @@ -6,8 +6,6 @@ flowchart TB subgraph FileData["Per-File Data"] file_data[file_data] - files[files] - file_deps[file_deps] end subgraph Extracted["Extracted (ReactiveMerge)"] @@ -15,44 +13,26 @@ flowchart TB annotations[annotations] value_refs[value_refs] type_refs[type_refs] - cross_file[cross_file
items] + cross_file_items[cross_file_items] end subgraph TypeDeps["ReactiveTypeDeps"] decl_by_path[decl_by_path] - intf_decls[intf_decls] - impl_decls[impl_decls] - intf_to_impl[intf→impl] - impl_to_intf[impl→intf] - same_path[same_path] - combined_refs[combined
refs_to] - all_type_refs[all_type
refs_from] + all_type_refs[all_type_refs] end subgraph ExcRefs["ReactiveExceptionRefs"] - exc_collection[exception
refs] - exc_decls[exception
decls] - resolved_refs[resolved
refs] - resolved_from[resolved
refs_from] + exception_refs[exception_refs] + exception_decls[exception_decls] + resolved_refs[resolved_refs] end subgraph DeclRefs["ReactiveDeclRefs"] - decls_by_file[decls
by_file] - value_decl_refs[value
decl_refs] - type_decl_refs[type
decl_refs] - with_value[with_value
refs] - with_type[with_type
refs] - combined[combined] + combined_refs[combined_refs] end subgraph Liveness["ReactiveLiveness"] - value_refs_from[value
refs_from] - type_refs_from[type
refs_from] - ext_value_refs[external
value_refs] - ext_type_refs[external
type_refs] - ext_referenced[externally
referenced] - annotated_roots[annotated
roots] - all_roots[all_roots] + roots[roots] edges[edges] live[live
fixpoint] end @@ -60,115 +40,62 @@ flowchart TB subgraph Solver["ReactiveSolver"] dead_decls[dead_decls] live_decls[live_decls] - modules_dead[modules
with_dead] - modules_live[modules
with_live] - dead_modules[dead
modules] - dead_by_file[dead
by_file] - issues_by_file[issues
by_file] - incorrect_dead[incorrect
dead] - modules_reported[modules
reported] - module_issues[module
issues] + dead_modules[dead_modules] + dead_by_file[dead_by_file] + issues_by_file[issues_by_file] + incorrect_dead[incorrect_dead] + module_issues[module_issues] end subgraph Report["Report (iter only)"] OUTPUT[("REPORT")] end - %% File Layer file_collection -->|process| file_data - file_data --> files - file_data --> file_deps - file_data --> decls - file_data --> annotations - file_data --> value_refs - file_data --> type_refs - file_data --> cross_file - - %% TypeDeps - decls --> decl_by_path - decls --> intf_decls - decls --> impl_decls - decl_by_path --> intf_to_impl - decl_by_path --> impl_to_intf - decl_by_path --> same_path - intf_decls --> intf_to_impl - impl_decls --> impl_to_intf - intf_to_impl --> combined_refs - impl_to_intf --> combined_refs - same_path --> combined_refs - combined_refs --> all_type_refs - - %% ExceptionRefs - cross_file --> exc_collection - decls --> exc_decls - exc_collection --> resolved_refs - exc_decls --> resolved_refs - resolved_refs --> resolved_from - - %% DeclRefs - decls --> decls_by_file - decls --> with_value - decls --> with_type - decls_by_file --> value_decl_refs - decls_by_file --> type_decl_refs - value_decl_refs --> with_value - type_decl_refs --> with_type - with_value --> combined - with_type --> combined - - %% Liveness inputs - value_refs --> value_refs_from - type_refs --> type_refs_from - all_type_refs --> type_refs_from - resolved_from --> value_refs_from - - %% Liveness external refs - decls --> ext_value_refs - decls --> ext_type_refs - value_refs_from --> ext_value_refs - type_refs_from --> ext_type_refs - value_refs_from --> value_decl_refs - type_refs_from --> type_decl_refs - ext_value_refs --> ext_referenced - ext_type_refs --> ext_referenced - - %% Liveness roots - decls --> annotated_roots - annotations --> annotated_roots - ext_referenced --> all_roots - annotated_roots --> all_roots - - %% Liveness fixpoint - combined --> edges - all_roots --> live + file_data -->|flatMap| decls + file_data -->|flatMap| annotations + file_data -->|flatMap| value_refs + file_data -->|flatMap| type_refs + file_data -->|flatMap| cross_file_items + + decls -->|flatMap| decl_by_path + decl_by_path -->|union+join| all_type_refs + + cross_file_items -->|flatMap| exception_refs + decls -->|flatMap| exception_decls + exception_refs -->|join| resolved_refs + exception_decls -->|join| resolved_refs + + decls --> combined_refs + value_refs --> combined_refs + type_refs --> combined_refs + all_type_refs --> combined_refs + resolved_refs --> combined_refs + + decls --> roots + annotations --> roots + + combined_refs -->|flatMap| edges + roots --> live edges --> live - %% Solver partition - decls --> dead_decls - decls --> live_decls - live --> dead_decls - live --> live_decls + decls -->|join| dead_decls + live -->|NOT in| dead_decls + decls -->|join| live_decls + live -->|IN| live_decls - %% Solver modules - dead_decls --> modules_dead - live_decls --> modules_live - modules_dead --> dead_modules - modules_live --> dead_modules + dead_decls --> dead_modules + live_decls --> dead_modules - %% Solver issues - dead_decls --> dead_by_file - dead_by_file --> issues_by_file + dead_decls -->|flatMap| dead_by_file + dead_by_file -->|flatMap| issues_by_file - %% Solver incorrect @dead - live_decls --> incorrect_dead + live_decls -->|join @dead| incorrect_dead annotations --> incorrect_dead - %% Solver module issues - issues_by_file --> modules_reported - dead_modules --> module_issues - modules_reported --> module_issues + dead_modules -->|join| module_issues + issues_by_file --> module_issues - %% Output issues_by_file -->|iter| OUTPUT incorrect_dead -->|iter| OUTPUT module_issues -->|iter| OUTPUT @@ -182,11 +109,11 @@ flowchart TB classDef solver fill:#ffe6f0,stroke:#cc6699,stroke-width:2px classDef output fill:#e6ffe6,stroke:#2e8b2e,stroke-width:2px - class file_collection,file_data,files,file_deps fileLayer - class decls,annotations,value_refs,type_refs,cross_file extracted - class decl_by_path,intf_decls,impl_decls,intf_to_impl,impl_to_intf,same_path,combined_refs,all_type_refs typeDeps - class exc_collection,exc_decls,resolved_refs,resolved_from excDeps - class decls_by_file,value_decl_refs,type_decl_refs,with_value,with_type,combined declRefs - class value_refs_from,type_refs_from,ext_value_refs,ext_type_refs,ext_referenced,annotated_roots,all_roots,edges,live liveness - class dead_decls,live_decls,modules_dead,modules_live,dead_modules,dead_by_file,issues_by_file,incorrect_dead,modules_reported,module_issues solver + class file_collection,file_data fileLayer + class decls,annotations,value_refs,type_refs,cross_file_items extracted + class decl_by_path,all_type_refs typeDeps + class exception_refs,exception_decls,resolved_refs excDeps + class combined_refs declRefs + class roots,edges,live liveness + class dead_decls,live_decls,dead_modules,dead_by_file,issues_by_file,incorrect_dead,module_issues solver class OUTPUT output diff --git a/analysis/reanalyze/diagrams/reactive-pipeline.svg b/analysis/reanalyze/diagrams/reactive-pipeline.svg index 570b15bcc0..6065d0843d 100644 --- a/analysis/reanalyze/diagrams/reactive-pipeline.svg +++ b/analysis/reanalyze/diagrams/reactive-pipeline.svg @@ -1 +1 @@ -

Report (iter only)

ReactiveSolver

ReactiveLiveness

ReactiveDeclRefs

ReactiveExceptionRefs

ReactiveTypeDeps

Extracted (ReactiveMerge)

Per-File Data

File Layer

process

iter

iter

iter

file_collection

file_data

files

file_deps

decls

annotations

value_refs

type_refs

cross_file
items

decl_by_path

intf_decls

impl_decls

intf→impl

impl→intf

same_path

combined
refs_to

all_type
refs_from

exception
refs

exception
decls

resolved
refs

resolved
refs_from

decls
by_file

value
decl_refs

type
decl_refs

with_value
refs

with_type
refs

combined

value
refs_from

type
refs_from

external
value_refs

external
type_refs

externally
referenced

annotated
roots

all_roots

edges

live
fixpoint

dead_decls

live_decls

modules
with_dead

modules
with_live

dead
modules

dead
by_file

issues
by_file

incorrect
dead

modules
reported

module
issues

REPORT

\ No newline at end of file +

Report (iter only)

ReactiveSolver

ReactiveLiveness

ReactiveDeclRefs

ReactiveExceptionRefs

ReactiveTypeDeps

Extracted (ReactiveMerge)

Per-File Data

File Layer

process

flatMap

flatMap

flatMap

flatMap

flatMap

flatMap

union+join

flatMap

flatMap

join

join

flatMap

join

NOT in

join

IN

flatMap

flatMap

join @dead

join

iter

iter

iter

file_collection

file_data

decls

annotations

value_refs

type_refs

cross_file_items

decl_by_path

all_type_refs

exception_refs

exception_decls

resolved_refs

combined_refs

roots

edges

live
fixpoint

dead_decls

live_decls

dead_modules

dead_by_file

issues_by_file

incorrect_dead

module_issues

REPORT

\ No newline at end of file From b895d989c48916fec8ca028327902bac8ebf8704 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Wed, 17 Dec 2025 11:14:43 +0100 Subject: [PATCH 39/45] Add full auto-generated reactive pipeline diagram - Generate reactive-pipeline-full.mmd and .svg (44 nodes) - Link from ARCHITECTURE.md to the full diagram --- analysis/reanalyze/ARCHITECTURE.md | 5 +- .../diagrams/reactive-pipeline-full.mmd | 127 ++++++++++++++++++ .../diagrams/reactive-pipeline-full.svg | 1 + 3 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 analysis/reanalyze/diagrams/reactive-pipeline-full.mmd create mode 100644 analysis/reanalyze/diagrams/reactive-pipeline-full.svg diff --git a/analysis/reanalyze/ARCHITECTURE.md b/analysis/reanalyze/ARCHITECTURE.md index 3f7862f970..d75563b0c0 100644 --- a/analysis/reanalyze/ARCHITECTURE.md +++ b/analysis/reanalyze/ARCHITECTURE.md @@ -235,10 +235,7 @@ Files → file_data → decls, annotations, refs → live (fixpoint) → dead/li ![Reactive Pipeline](diagrams/reactive-pipeline.svg) -This is a high-level view (~25 nodes). For the full detailed diagram with all 44 nodes, run: -```bash -dune exec rescript-editor-analysis -- reanalyze -dce -config -ci -reactive -mermaid -``` +This is a high-level view (~25 nodes). See also the [full detailed diagram](diagrams/reactive-pipeline-full.svg) with all 44 nodes (auto-generated via `-mermaid` flag). Key stages: diff --git a/analysis/reanalyze/diagrams/reactive-pipeline-full.mmd b/analysis/reanalyze/diagrams/reactive-pipeline-full.mmd new file mode 100644 index 0000000000..9323ea75f0 --- /dev/null +++ b/analysis/reanalyze/diagrams/reactive-pipeline-full.mmd @@ -0,0 +1,127 @@ +graph TD + annotations[annotations L2] + annotations --> solver.incorrect_dead_decls + annotations --> liveness.annotated_roots + exc_refs.exception_decls[exc_refs.exception_decls L3] + exc_refs.exception_decls --> exc_refs.resolved_refs + type_deps.same_path_refs[type_deps.same_path_refs L4] + type_deps.same_path_refs --> type_deps.u1 + file_collection[file_collection L0] + file_collection --> file_data_collection + decl_refs.value_decl_refs[decl_refs.value_decl_refs L7] + decl_refs.value_decl_refs --> decl_refs.with_value_refs + type_deps.combined_refs_to[type_deps.combined_refs_to L7] + type_deps.combined_refs_to --> type_deps.all_type_refs_from + type_deps.all_type_refs_from[type_deps.all_type_refs_from L8] + type_deps.all_type_refs_from --> liveness.type_refs_from + type_deps.impl_needing_path2[type_deps.impl_needing_path2 L4] + type_deps.impl_needing_path2 --> type_deps.impl_to_intf_refs_path2 + exc_refs.resolved_refs_from[exc_refs.resolved_refs_from L5] + exc_refs.resolved_refs_from --> liveness.value_refs_from + exc_refs.resolved_refs[exc_refs.resolved_refs L4] + exc_refs.resolved_refs --> exc_refs.resolved_refs_from + type_deps.impl_to_intf_refs_path2[type_deps.impl_to_intf_refs_path2 L5] + type_deps.impl_to_intf_refs_path2 --> type_deps.u2 + file_deps_map[file_deps_map L2] + decl_refs.with_value_refs[decl_refs.with_value_refs L8] + decl_refs.with_value_refs --> decl_refs.combined + type_deps.u1[type_deps.u1 L5] + type_deps.u1 --> type_deps.u2 + cross_file_items[cross_file_items L2] + cross_file_items --> exception_refs_collection + decl_refs.decls_by_file[decl_refs.decls_by_file L3] + decl_refs.decls_by_file --> decl_refs.type_decl_refs + decl_refs.decls_by_file --> decl_refs.value_decl_refs + type_deps.impl_to_intf_refs[type_deps.impl_to_intf_refs L4] + type_deps.impl_to_intf_refs --> type_deps.u1 + solver.issues_by_file[solver.issues_by_file L17] + solver.issues_by_file --> solver.modules_with_reported + liveness.annotated_roots[liveness.annotated_roots L3] + liveness.annotated_roots --> liveness.all_roots + solver.incorrect_dead_decls[solver.incorrect_dead_decls L16] + type_deps.intf_to_impl_refs[type_deps.intf_to_impl_refs L4] + type_deps.intf_to_impl_refs --> type_deps.combined_refs_to + type_deps.decl_by_path[type_deps.decl_by_path L3] + type_deps.decl_by_path --> type_deps.intf_to_impl_refs + type_deps.decl_by_path --> type_deps.impl_to_intf_refs_path2 + type_deps.decl_by_path --> type_deps.impl_needing_path2 + type_deps.decl_by_path --> type_deps.impl_to_intf_refs + type_deps.decl_by_path --> type_deps.same_path_refs + type_deps.u2[type_deps.u2 L6] + type_deps.u2 --> type_deps.combined_refs_to + solver.live_decls[solver.live_decls L15] + solver.live_decls --> solver.incorrect_dead_decls + solver.live_decls --> solver.modules_with_live + type_deps.impl_decls[type_deps.impl_decls L3] + type_deps.impl_decls --> type_deps.impl_needing_path2 + type_deps.impl_decls --> type_deps.impl_to_intf_refs + liveness.all_roots[liveness.all_roots L12] + liveness.all_roots --> liveness.live + solver.dead_modules[solver.dead_modules L17] + solver.dead_modules --> solver.dead_module_issues + liveness.external_type_refs[liveness.external_type_refs L10] + liveness.external_type_refs --> liveness.externally_referenced + decl_refs.combined[decl_refs.combined L12] + decl_refs.combined --> liveness.edges + type_refs_from[type_refs_from L2] + type_refs_from --> liveness.type_refs_from + liveness.type_refs_from[liveness.type_refs_from L9] + liveness.type_refs_from --> liveness.external_type_refs + liveness.type_refs_from --> decl_refs.type_decl_refs + solver.dead_decls_by_file[solver.dead_decls_by_file L16] + solver.dead_decls_by_file --> solver.issues_by_file + liveness.external_value_refs[liveness.external_value_refs L7] + liveness.external_value_refs --> liveness.externally_referenced + liveness.value_refs_from[liveness.value_refs_from L6] + liveness.value_refs_from --> liveness.external_value_refs + liveness.value_refs_from --> decl_refs.value_decl_refs + value_refs_from[value_refs_from L2] + value_refs_from --> liveness.value_refs_from + solver.modules_with_dead[solver.modules_with_dead L16] + solver.modules_with_dead --> solver.dead_modules + solver.dead_decls[solver.dead_decls L15] + solver.dead_decls --> solver.dead_decls_by_file + solver.dead_decls --> solver.modules_with_dead + exception_refs_collection[exception_refs_collection L3] + exception_refs_collection --> exc_refs.resolved_refs + type_deps.intf_decls[type_deps.intf_decls L3] + type_deps.intf_decls --> type_deps.intf_to_impl_refs + file_data_collection[file_data_collection L1] + file_data_collection --> files + file_data_collection --> file_deps_map + file_data_collection --> cross_file_items + file_data_collection --> type_refs_from + file_data_collection --> value_refs_from + file_data_collection --> annotations + file_data_collection --> decls + solver.dead_module_issues[solver.dead_module_issues L19] + decl_refs.with_type_refs[decl_refs.with_type_refs L11] + decl_refs.with_type_refs --> decl_refs.combined + solver.modules_with_live[solver.modules_with_live L16] + solver.modules_with_live --> solver.dead_modules + decl_refs.type_decl_refs[decl_refs.type_decl_refs L10] + decl_refs.type_decl_refs --> decl_refs.with_type_refs + files[files L2] + solver.modules_with_reported[solver.modules_with_reported L18] + solver.modules_with_reported --> solver.dead_module_issues + liveness.externally_referenced[liveness.externally_referenced L11] + liveness.externally_referenced --> liveness.all_roots + liveness.edges[liveness.edges L13] + liveness.edges --> liveness.live + liveness.live[liveness.live L14] + liveness.live --> solver.live_decls + liveness.live --> solver.dead_decls + decls[decls L2] + decls --> solver.live_decls + decls --> solver.dead_decls + decls --> liveness.annotated_roots + decls --> liveness.external_type_refs + decls --> liveness.external_value_refs + decls --> decl_refs.with_type_refs + decls --> decl_refs.with_value_refs + decls --> decl_refs.decls_by_file + decls --> exc_refs.exception_decls + decls --> type_deps.intf_decls + decls --> type_deps.impl_decls + decls --> type_deps.decl_by_path + diff --git a/analysis/reanalyze/diagrams/reactive-pipeline-full.svg b/analysis/reanalyze/diagrams/reactive-pipeline-full.svg new file mode 100644 index 0000000000..67295f4228 --- /dev/null +++ b/analysis/reanalyze/diagrams/reactive-pipeline-full.svg @@ -0,0 +1 @@ +

annotations L2

solver.incorrect_dead_decls L16

liveness.annotated_roots L3

exc_refs.exception_decls L3

exc_refs.resolved_refs L4

type_deps.same_path_refs L4

type_deps.u1 L5

file_collection L0

file_data_collection L1

decl_refs.value_decl_refs L7

decl_refs.with_value_refs L8

type_deps.combined_refs_to L7

type_deps.all_type_refs_from L8

liveness.type_refs_from L9

type_deps.impl_needing_path2 L4

type_deps.impl_to_intf_refs_path2 L5

exc_refs.resolved_refs_from L5

liveness.value_refs_from L6

type_deps.u2 L6

file_deps_map L2

decl_refs.combined L12

cross_file_items L2

exception_refs_collection L3

decl_refs.decls_by_file L3

decl_refs.type_decl_refs L10

type_deps.impl_to_intf_refs L4

solver.issues_by_file L17

solver.modules_with_reported L18

liveness.all_roots L12

type_deps.intf_to_impl_refs L4

type_deps.decl_by_path L3

solver.live_decls L15

solver.modules_with_live L16

type_deps.impl_decls L3

liveness.live L14

solver.dead_modules L17

solver.dead_module_issues L19

liveness.external_type_refs L10

liveness.externally_referenced L11

liveness.edges L13

type_refs_from L2

solver.dead_decls_by_file L16

liveness.external_value_refs L7

value_refs_from L2

solver.modules_with_dead L16

solver.dead_decls L15

type_deps.intf_decls L3

files L2

decls L2

decl_refs.with_type_refs L11

\ No newline at end of file From 8635190da0c43d4c123b48a72c2935211d6648bc Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Wed, 17 Dec 2025 11:18:18 +0100 Subject: [PATCH 40/45] Keep only .mmd source for full diagram (no SVG) --- analysis/reanalyze/ARCHITECTURE.md | 2 +- analysis/reanalyze/diagrams/reactive-pipeline-full.svg | 1 - analysis/reanalyze/src/ReactiveDeclRefs.ml | 3 +-- analysis/reanalyze/src/ReactiveExceptionRefs.ml | 3 +-- analysis/reanalyze/src/ReactiveMerge.ml | 4 ++-- analysis/reanalyze/src/ReactiveSolver.ml | 3 +-- analysis/reanalyze/src/Reanalyze.ml | 4 +++- 7 files changed, 9 insertions(+), 11 deletions(-) delete mode 100644 analysis/reanalyze/diagrams/reactive-pipeline-full.svg diff --git a/analysis/reanalyze/ARCHITECTURE.md b/analysis/reanalyze/ARCHITECTURE.md index d75563b0c0..ae04765813 100644 --- a/analysis/reanalyze/ARCHITECTURE.md +++ b/analysis/reanalyze/ARCHITECTURE.md @@ -235,7 +235,7 @@ Files → file_data → decls, annotations, refs → live (fixpoint) → dead/li ![Reactive Pipeline](diagrams/reactive-pipeline.svg) -This is a high-level view (~25 nodes). See also the [full detailed diagram](diagrams/reactive-pipeline-full.svg) with all 44 nodes (auto-generated via `-mermaid` flag). +This is a high-level view (~25 nodes). See also the [full detailed diagram source](diagrams/reactive-pipeline-full.mmd) with all 44 nodes (auto-generated via `-mermaid` flag). Key stages: diff --git a/analysis/reanalyze/diagrams/reactive-pipeline-full.svg b/analysis/reanalyze/diagrams/reactive-pipeline-full.svg deleted file mode 100644 index 67295f4228..0000000000 --- a/analysis/reanalyze/diagrams/reactive-pipeline-full.svg +++ /dev/null @@ -1 +0,0 @@ -

annotations L2

solver.incorrect_dead_decls L16

liveness.annotated_roots L3

exc_refs.exception_decls L3

exc_refs.resolved_refs L4

type_deps.same_path_refs L4

type_deps.u1 L5

file_collection L0

file_data_collection L1

decl_refs.value_decl_refs L7

decl_refs.with_value_refs L8

type_deps.combined_refs_to L7

type_deps.all_type_refs_from L8

liveness.type_refs_from L9

type_deps.impl_needing_path2 L4

type_deps.impl_to_intf_refs_path2 L5

exc_refs.resolved_refs_from L5

liveness.value_refs_from L6

type_deps.u2 L6

file_deps_map L2

decl_refs.combined L12

cross_file_items L2

exception_refs_collection L3

decl_refs.decls_by_file L3

decl_refs.type_decl_refs L10

type_deps.impl_to_intf_refs L4

solver.issues_by_file L17

solver.modules_with_reported L18

liveness.all_roots L12

type_deps.intf_to_impl_refs L4

type_deps.decl_by_path L3

solver.live_decls L15

solver.modules_with_live L16

type_deps.impl_decls L3

liveness.live L14

solver.dead_modules L17

solver.dead_module_issues L19

liveness.external_type_refs L10

liveness.externally_referenced L11

liveness.edges L13

type_refs_from L2

solver.dead_decls_by_file L16

liveness.external_value_refs L7

value_refs_from L2

solver.modules_with_dead L16

solver.dead_decls L15

type_deps.intf_decls L3

files L2

decls L2

decl_refs.with_type_refs L11

\ No newline at end of file diff --git a/analysis/reanalyze/src/ReactiveDeclRefs.ml b/analysis/reanalyze/src/ReactiveDeclRefs.ml index 4698cbce2b..9f5a2ea26c 100644 --- a/analysis/reanalyze/src/ReactiveDeclRefs.ml +++ b/analysis/reanalyze/src/ReactiveDeclRefs.ml @@ -43,8 +43,7 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) in let type_decl_refs : (Lexing.position, PosSet.t) Reactive.t = - Reactive.join ~name:"decl_refs.type_decl_refs" type_refs_from - decls_by_file + Reactive.join ~name:"decl_refs.type_decl_refs" type_refs_from decls_by_file ~key_of:(fun posFrom _targets -> posFrom.Lexing.pos_fname) ~f:(fun posFrom targets decls_opt -> match decls_opt with diff --git a/analysis/reanalyze/src/ReactiveExceptionRefs.ml b/analysis/reanalyze/src/ReactiveExceptionRefs.ml index c696d30c5b..81e23bfbe6 100644 --- a/analysis/reanalyze/src/ReactiveExceptionRefs.ml +++ b/analysis/reanalyze/src/ReactiveExceptionRefs.ml @@ -44,8 +44,7 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) (* Step 2: Join exception_refs with exception_decls *) let resolved_refs = - Reactive.join ~name:"exc_refs.resolved_refs" exception_refs - exception_decls + Reactive.join ~name:"exc_refs.resolved_refs" exception_refs exception_decls ~key_of:(fun path _loc_from -> path) ~f:(fun _path loc_from loc_to_opt -> match loc_to_opt with diff --git a/analysis/reanalyze/src/ReactiveMerge.ml b/analysis/reanalyze/src/ReactiveMerge.ml index f06ce9f21a..f0a340f6c1 100644 --- a/analysis/reanalyze/src/ReactiveMerge.ml +++ b/analysis/reanalyze/src/ReactiveMerge.ml @@ -21,8 +21,8 @@ type t = { (** {1 Creation} *) -let create (source : (string, DceFileProcessing.file_data option) Reactive.t) - : t = +let create (source : (string, DceFileProcessing.file_data option) Reactive.t) : + t = (* Declarations: (pos, Decl.t) with last-write-wins *) let decls = Reactive.flatMap ~name:"decls" source diff --git a/analysis/reanalyze/src/ReactiveSolver.ml b/analysis/reanalyze/src/ReactiveSolver.ml index 7e224e50fc..17b2fff489 100644 --- a/analysis/reanalyze/src/ReactiveSolver.ml +++ b/analysis/reanalyze/src/ReactiveSolver.ml @@ -50,8 +50,7 @@ let decl_module_name (decl : Decl.t) : Name.t = let create ~(decls : (Lexing.position, Decl.t) Reactive.t) ~(live : (Lexing.position, unit) Reactive.t) - ~(annotations : - (Lexing.position, FileAnnotations.annotated_as) Reactive.t) + ~(annotations : (Lexing.position, FileAnnotations.annotated_as) Reactive.t) ~(value_refs_from : (Lexing.position, PosSet.t) Reactive.t option) ~(config : DceConfig.t) : t = (* dead_decls = decls where NOT in live (reactive join) *) diff --git a/analysis/reanalyze/src/Reanalyze.ml b/analysis/reanalyze/src/Reanalyze.ml index 91c923efb1..70b6678b42 100644 --- a/analysis/reanalyze/src/Reanalyze.ml +++ b/analysis/reanalyze/src/Reanalyze.ml @@ -647,7 +647,9 @@ let cli () = "n Process files in parallel using n domains (0 = sequential, default; \ -1 = auto-detect cores)" ); ("-timing", Set Cli.timing, "Report internal timing of analysis phases"); - ("-mermaid", Set Cli.mermaid, "Output Mermaid diagram of reactive pipeline"); + ( "-mermaid", + Set Cli.mermaid, + "Output Mermaid diagram of reactive pipeline" ); ( "-reactive", Set Cli.reactive, "Use reactive analysis (caches processed file_data, skips unchanged \ From 393c4c27e3f81b0f6e699d6877a44575d019b9ad Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Wed, 17 Dec 2025 11:25:16 +0100 Subject: [PATCH 41/45] Add edge labels to auto-generated Mermaid diagram - Add label parameter to Registry.add_edge - flatMap, join, union edges labeled with combinator name - fixpoint edges labeled as 'roots' and 'edges' - Regenerate reactive-pipeline-full.mmd with labels --- analysis/reactive/src/Reactive.ml | 34 ++-- .../diagrams/reactive-pipeline-full.mmd | 150 +++++++++--------- 2 files changed, 98 insertions(+), 86 deletions(-) diff --git a/analysis/reactive/src/Reactive.ml b/analysis/reactive/src/Reactive.ml index 202ef0315b..bde07d3cbf 100644 --- a/analysis/reactive/src/Reactive.ml +++ b/analysis/reactive/src/Reactive.ml @@ -96,6 +96,7 @@ module Registry = struct } let nodes : (string, node_info) Hashtbl.t = Hashtbl.create 64 + let edges : (string * string, string) Hashtbl.t = Hashtbl.create 128 let dirty_nodes : string list ref = ref [] let register ~name ~level ~process ~stats = @@ -113,7 +114,8 @@ module Registry = struct Hashtbl.replace nodes name info; info - let add_edge ~from_name ~to_name = + let add_edge ~from_name ~to_name ~label = + Hashtbl.replace edges (from_name, to_name) label; (match Hashtbl.find_opt nodes from_name with | Some info -> info.downstream <- to_name :: info.downstream | None -> ()); @@ -130,6 +132,7 @@ module Registry = struct let clear () = Hashtbl.clear nodes; + Hashtbl.clear edges; dirty_nodes := [] (** Generate Mermaid diagram of the pipeline *) @@ -141,11 +144,20 @@ module Registry = struct (* Node with level annotation *) Buffer.add_string buf (Printf.sprintf " %s[%s L%d]\n" name name info.level); - (* Edges *) + (* Edges with labels *) List.iter (fun downstream -> - Buffer.add_string buf - (Printf.sprintf " %s --> %s\n" name downstream)) + let label = + match Hashtbl.find_opt edges (name, downstream) with + | Some l -> l + | None -> "" + in + if label = "" then + Buffer.add_string buf + (Printf.sprintf " %s --> %s\n" name downstream) + else + Buffer.add_string buf + (Printf.sprintf " %s -->|%s| %s\n" name label downstream)) info.downstream) nodes; Buffer.contents buf @@ -458,7 +470,7 @@ let flatMap ~name (src : ('k1, 'v1) t) ~f ?merge () : ('k2, 'v2) t = let _info = Registry.register ~name ~level:my_level ~process ~stats:my_stats in - Registry.add_edge ~from_name:src.name ~to_name:name; + Registry.add_edge ~from_name:src.name ~to_name:name ~label:"flatMap"; (* Subscribe to source: just accumulate *) src.subscribe (fun delta -> @@ -697,8 +709,8 @@ let join ~name (left : ('k1, 'v1) t) (right : ('k2, 'v2) t) ~key_of ~f ?merge () let _info = Registry.register ~name ~level:my_level ~process ~stats:my_stats in - Registry.add_edge ~from_name:left.name ~to_name:name; - Registry.add_edge ~from_name:right.name ~to_name:name; + Registry.add_edge ~from_name:left.name ~to_name:name ~label:"join"; + Registry.add_edge ~from_name:right.name ~to_name:name ~label:"join"; (* Subscribe to sources: just accumulate *) left.subscribe (fun delta -> @@ -830,8 +842,8 @@ let union ~name (left : ('k, 'v) t) (right : ('k, 'v) t) ?merge () : ('k, 'v) t let _info = Registry.register ~name ~level:my_level ~process ~stats:my_stats in - Registry.add_edge ~from_name:left.name ~to_name:name; - Registry.add_edge ~from_name:right.name ~to_name:name; + Registry.add_edge ~from_name:left.name ~to_name:name ~label:"union"; + Registry.add_edge ~from_name:right.name ~to_name:name ~label:"union"; (* Subscribe to sources: just accumulate *) left.subscribe (fun delta -> @@ -1032,8 +1044,8 @@ let fixpoint ~name ~(init : ('k, unit) t) ~(edges : ('k, 'k list) t) () : let _info = Registry.register ~name ~level:my_level ~process ~stats:my_stats in - Registry.add_edge ~from_name:init.name ~to_name:name; - Registry.add_edge ~from_name:edges.name ~to_name:name; + Registry.add_edge ~from_name:init.name ~to_name:name ~label:"roots"; + Registry.add_edge ~from_name:edges.name ~to_name:name ~label:"edges"; (* Subscribe to sources: just accumulate *) init.subscribe (fun delta -> diff --git a/analysis/reanalyze/diagrams/reactive-pipeline-full.mmd b/analysis/reanalyze/diagrams/reactive-pipeline-full.mmd index 9323ea75f0..7c8f986542 100644 --- a/analysis/reanalyze/diagrams/reactive-pipeline-full.mmd +++ b/analysis/reanalyze/diagrams/reactive-pipeline-full.mmd @@ -1,127 +1,127 @@ graph TD annotations[annotations L2] - annotations --> solver.incorrect_dead_decls - annotations --> liveness.annotated_roots + annotations -->|join| solver.incorrect_dead_decls + annotations -->|join| liveness.annotated_roots exc_refs.exception_decls[exc_refs.exception_decls L3] - exc_refs.exception_decls --> exc_refs.resolved_refs + exc_refs.exception_decls -->|join| exc_refs.resolved_refs type_deps.same_path_refs[type_deps.same_path_refs L4] - type_deps.same_path_refs --> type_deps.u1 + type_deps.same_path_refs -->|union| type_deps.u1 file_collection[file_collection L0] - file_collection --> file_data_collection + file_collection -->|flatMap| file_data_collection decl_refs.value_decl_refs[decl_refs.value_decl_refs L7] - decl_refs.value_decl_refs --> decl_refs.with_value_refs + decl_refs.value_decl_refs -->|join| decl_refs.with_value_refs type_deps.combined_refs_to[type_deps.combined_refs_to L7] - type_deps.combined_refs_to --> type_deps.all_type_refs_from + type_deps.combined_refs_to -->|flatMap| type_deps.all_type_refs_from type_deps.all_type_refs_from[type_deps.all_type_refs_from L8] - type_deps.all_type_refs_from --> liveness.type_refs_from + type_deps.all_type_refs_from -->|union| liveness.type_refs_from type_deps.impl_needing_path2[type_deps.impl_needing_path2 L4] - type_deps.impl_needing_path2 --> type_deps.impl_to_intf_refs_path2 + type_deps.impl_needing_path2 -->|join| type_deps.impl_to_intf_refs_path2 exc_refs.resolved_refs_from[exc_refs.resolved_refs_from L5] - exc_refs.resolved_refs_from --> liveness.value_refs_from + exc_refs.resolved_refs_from -->|union| liveness.value_refs_from exc_refs.resolved_refs[exc_refs.resolved_refs L4] - exc_refs.resolved_refs --> exc_refs.resolved_refs_from + exc_refs.resolved_refs -->|flatMap| exc_refs.resolved_refs_from type_deps.impl_to_intf_refs_path2[type_deps.impl_to_intf_refs_path2 L5] - type_deps.impl_to_intf_refs_path2 --> type_deps.u2 + type_deps.impl_to_intf_refs_path2 -->|union| type_deps.u2 file_deps_map[file_deps_map L2] decl_refs.with_value_refs[decl_refs.with_value_refs L8] - decl_refs.with_value_refs --> decl_refs.combined + decl_refs.with_value_refs -->|join| decl_refs.combined type_deps.u1[type_deps.u1 L5] - type_deps.u1 --> type_deps.u2 + type_deps.u1 -->|union| type_deps.u2 cross_file_items[cross_file_items L2] - cross_file_items --> exception_refs_collection + cross_file_items -->|flatMap| exception_refs_collection decl_refs.decls_by_file[decl_refs.decls_by_file L3] - decl_refs.decls_by_file --> decl_refs.type_decl_refs - decl_refs.decls_by_file --> decl_refs.value_decl_refs + decl_refs.decls_by_file -->|join| decl_refs.type_decl_refs + decl_refs.decls_by_file -->|join| decl_refs.value_decl_refs type_deps.impl_to_intf_refs[type_deps.impl_to_intf_refs L4] - type_deps.impl_to_intf_refs --> type_deps.u1 + type_deps.impl_to_intf_refs -->|union| type_deps.u1 solver.issues_by_file[solver.issues_by_file L17] - solver.issues_by_file --> solver.modules_with_reported + solver.issues_by_file -->|flatMap| solver.modules_with_reported liveness.annotated_roots[liveness.annotated_roots L3] - liveness.annotated_roots --> liveness.all_roots + liveness.annotated_roots -->|union| liveness.all_roots solver.incorrect_dead_decls[solver.incorrect_dead_decls L16] type_deps.intf_to_impl_refs[type_deps.intf_to_impl_refs L4] - type_deps.intf_to_impl_refs --> type_deps.combined_refs_to + type_deps.intf_to_impl_refs -->|union| type_deps.combined_refs_to type_deps.decl_by_path[type_deps.decl_by_path L3] - type_deps.decl_by_path --> type_deps.intf_to_impl_refs - type_deps.decl_by_path --> type_deps.impl_to_intf_refs_path2 - type_deps.decl_by_path --> type_deps.impl_needing_path2 - type_deps.decl_by_path --> type_deps.impl_to_intf_refs - type_deps.decl_by_path --> type_deps.same_path_refs + type_deps.decl_by_path -->|join| type_deps.intf_to_impl_refs + type_deps.decl_by_path -->|join| type_deps.impl_to_intf_refs_path2 + type_deps.decl_by_path -->|join| type_deps.impl_needing_path2 + type_deps.decl_by_path -->|join| type_deps.impl_to_intf_refs + type_deps.decl_by_path -->|flatMap| type_deps.same_path_refs type_deps.u2[type_deps.u2 L6] - type_deps.u2 --> type_deps.combined_refs_to + type_deps.u2 -->|union| type_deps.combined_refs_to solver.live_decls[solver.live_decls L15] - solver.live_decls --> solver.incorrect_dead_decls - solver.live_decls --> solver.modules_with_live + solver.live_decls -->|join| solver.incorrect_dead_decls + solver.live_decls -->|flatMap| solver.modules_with_live type_deps.impl_decls[type_deps.impl_decls L3] - type_deps.impl_decls --> type_deps.impl_needing_path2 - type_deps.impl_decls --> type_deps.impl_to_intf_refs + type_deps.impl_decls -->|join| type_deps.impl_needing_path2 + type_deps.impl_decls -->|join| type_deps.impl_to_intf_refs liveness.all_roots[liveness.all_roots L12] - liveness.all_roots --> liveness.live + liveness.all_roots -->|roots| liveness.live solver.dead_modules[solver.dead_modules L17] - solver.dead_modules --> solver.dead_module_issues + solver.dead_modules -->|join| solver.dead_module_issues liveness.external_type_refs[liveness.external_type_refs L10] - liveness.external_type_refs --> liveness.externally_referenced + liveness.external_type_refs -->|union| liveness.externally_referenced decl_refs.combined[decl_refs.combined L12] - decl_refs.combined --> liveness.edges + decl_refs.combined -->|flatMap| liveness.edges type_refs_from[type_refs_from L2] - type_refs_from --> liveness.type_refs_from + type_refs_from -->|union| liveness.type_refs_from liveness.type_refs_from[liveness.type_refs_from L9] - liveness.type_refs_from --> liveness.external_type_refs - liveness.type_refs_from --> decl_refs.type_decl_refs + liveness.type_refs_from -->|join| liveness.external_type_refs + liveness.type_refs_from -->|join| decl_refs.type_decl_refs solver.dead_decls_by_file[solver.dead_decls_by_file L16] - solver.dead_decls_by_file --> solver.issues_by_file + solver.dead_decls_by_file -->|flatMap| solver.issues_by_file liveness.external_value_refs[liveness.external_value_refs L7] - liveness.external_value_refs --> liveness.externally_referenced + liveness.external_value_refs -->|union| liveness.externally_referenced liveness.value_refs_from[liveness.value_refs_from L6] - liveness.value_refs_from --> liveness.external_value_refs - liveness.value_refs_from --> decl_refs.value_decl_refs + liveness.value_refs_from -->|join| liveness.external_value_refs + liveness.value_refs_from -->|join| decl_refs.value_decl_refs value_refs_from[value_refs_from L2] - value_refs_from --> liveness.value_refs_from + value_refs_from -->|union| liveness.value_refs_from solver.modules_with_dead[solver.modules_with_dead L16] - solver.modules_with_dead --> solver.dead_modules + solver.modules_with_dead -->|join| solver.dead_modules solver.dead_decls[solver.dead_decls L15] - solver.dead_decls --> solver.dead_decls_by_file - solver.dead_decls --> solver.modules_with_dead + solver.dead_decls -->|flatMap| solver.dead_decls_by_file + solver.dead_decls -->|flatMap| solver.modules_with_dead exception_refs_collection[exception_refs_collection L3] - exception_refs_collection --> exc_refs.resolved_refs + exception_refs_collection -->|join| exc_refs.resolved_refs type_deps.intf_decls[type_deps.intf_decls L3] - type_deps.intf_decls --> type_deps.intf_to_impl_refs + type_deps.intf_decls -->|join| type_deps.intf_to_impl_refs file_data_collection[file_data_collection L1] - file_data_collection --> files - file_data_collection --> file_deps_map - file_data_collection --> cross_file_items - file_data_collection --> type_refs_from - file_data_collection --> value_refs_from - file_data_collection --> annotations - file_data_collection --> decls + file_data_collection -->|flatMap| files + file_data_collection -->|flatMap| file_deps_map + file_data_collection -->|flatMap| cross_file_items + file_data_collection -->|flatMap| type_refs_from + file_data_collection -->|flatMap| value_refs_from + file_data_collection -->|flatMap| annotations + file_data_collection -->|flatMap| decls solver.dead_module_issues[solver.dead_module_issues L19] decl_refs.with_type_refs[decl_refs.with_type_refs L11] - decl_refs.with_type_refs --> decl_refs.combined + decl_refs.with_type_refs -->|join| decl_refs.combined solver.modules_with_live[solver.modules_with_live L16] - solver.modules_with_live --> solver.dead_modules + solver.modules_with_live -->|join| solver.dead_modules decl_refs.type_decl_refs[decl_refs.type_decl_refs L10] - decl_refs.type_decl_refs --> decl_refs.with_type_refs + decl_refs.type_decl_refs -->|join| decl_refs.with_type_refs files[files L2] solver.modules_with_reported[solver.modules_with_reported L18] - solver.modules_with_reported --> solver.dead_module_issues + solver.modules_with_reported -->|join| solver.dead_module_issues liveness.externally_referenced[liveness.externally_referenced L11] - liveness.externally_referenced --> liveness.all_roots + liveness.externally_referenced -->|union| liveness.all_roots liveness.edges[liveness.edges L13] - liveness.edges --> liveness.live + liveness.edges -->|edges| liveness.live liveness.live[liveness.live L14] - liveness.live --> solver.live_decls - liveness.live --> solver.dead_decls + liveness.live -->|join| solver.live_decls + liveness.live -->|join| solver.dead_decls decls[decls L2] - decls --> solver.live_decls - decls --> solver.dead_decls - decls --> liveness.annotated_roots - decls --> liveness.external_type_refs - decls --> liveness.external_value_refs - decls --> decl_refs.with_type_refs - decls --> decl_refs.with_value_refs - decls --> decl_refs.decls_by_file - decls --> exc_refs.exception_decls - decls --> type_deps.intf_decls - decls --> type_deps.impl_decls - decls --> type_deps.decl_by_path + decls -->|join| solver.live_decls + decls -->|join| solver.dead_decls + decls -->|join| liveness.annotated_roots + decls -->|join| liveness.external_type_refs + decls -->|join| liveness.external_value_refs + decls -->|join| decl_refs.with_type_refs + decls -->|join| decl_refs.with_value_refs + decls -->|flatMap| decl_refs.decls_by_file + decls -->|flatMap| exc_refs.exception_decls + decls -->|flatMap| type_deps.intf_decls + decls -->|flatMap| type_deps.impl_decls + decls -->|flatMap| type_deps.decl_by_path From cb79a214a76254beab659336e6b0f3e4afedcb0e Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Wed, 17 Dec 2025 11:32:40 +0100 Subject: [PATCH 42/45] Represent combinators as diamond nodes in Mermaid diagram Multi-input combinators (join, union, fixpoint) are now shown as separate diamond-shaped nodes in the diagram: left --> combinator{join} right --> combinator combinator --> result This makes the diagram more faithful to the actual data flow. --- analysis/reactive/src/Reactive.ml | 68 +++++-- .../diagrams/reactive-pipeline-full.mmd | 192 +++++++++++------- 2 files changed, 177 insertions(+), 83 deletions(-) diff --git a/analysis/reactive/src/Reactive.ml b/analysis/reactive/src/Reactive.ml index bde07d3cbf..e1287634c8 100644 --- a/analysis/reactive/src/Reactive.ml +++ b/analysis/reactive/src/Reactive.ml @@ -97,6 +97,9 @@ module Registry = struct let nodes : (string, node_info) Hashtbl.t = Hashtbl.create 64 let edges : (string * string, string) Hashtbl.t = Hashtbl.create 128 + (* Combinator nodes: (combinator_id, (shape, inputs, output)) *) + let combinators : (string, string * string list * string) Hashtbl.t = + Hashtbl.create 32 let dirty_nodes : string list ref = ref [] let register ~name ~level ~process ~stats = @@ -123,6 +126,10 @@ module Registry = struct | Some info -> info.upstream <- from_name :: info.upstream | None -> () + (** Register a multi-input combinator (rendered as diamond in Mermaid) *) + let add_combinator ~name ~shape ~inputs ~output = + Hashtbl.replace combinators name (shape, inputs, output) + let mark_dirty name = match Hashtbl.find_opt nodes name with | Some info when not info.dirty -> @@ -133,33 +140,62 @@ module Registry = struct let clear () = Hashtbl.clear nodes; Hashtbl.clear edges; + Hashtbl.clear combinators; dirty_nodes := [] (** Generate Mermaid diagram of the pipeline *) let to_mermaid () = let buf = Buffer.create 256 in Buffer.add_string buf "graph TD\n"; + (* Collect edges that are part of combinators *) + let combinator_edges = Hashtbl.create 64 in + Hashtbl.iter + (fun comb_name (_, inputs, output) -> + List.iter + (fun input -> Hashtbl.replace combinator_edges (input, output) comb_name) + inputs) + combinators; + (* Output regular nodes *) Hashtbl.iter (fun name info -> - (* Node with level annotation *) Buffer.add_string buf - (Printf.sprintf " %s[%s L%d]\n" name name info.level); - (* Edges with labels *) + (Printf.sprintf " %s[%s L%d]\n" name name info.level)) + nodes; + (* Output combinator nodes (diamond shape) *) + Hashtbl.iter + (fun comb_name (shape, _inputs, _output) -> + Buffer.add_string buf (Printf.sprintf " %s{%s}\n" comb_name shape)) + combinators; + (* Output edges *) + Hashtbl.iter + (fun name info -> List.iter (fun downstream -> - let label = - match Hashtbl.find_opt edges (name, downstream) with - | Some l -> l - | None -> "" - in - if label = "" then - Buffer.add_string buf - (Printf.sprintf " %s --> %s\n" name downstream) - else + (* Check if this edge is part of a combinator *) + match Hashtbl.find_opt combinator_edges (name, downstream) with + | Some comb_name -> + (* Edge goes to combinator node instead *) Buffer.add_string buf - (Printf.sprintf " %s -->|%s| %s\n" name label downstream)) + (Printf.sprintf " %s --> %s\n" name comb_name) + | None -> + let label = + match Hashtbl.find_opt edges (name, downstream) with + | Some l -> l + | None -> "" + in + if label = "" then + Buffer.add_string buf + (Printf.sprintf " %s --> %s\n" name downstream) + else + Buffer.add_string buf + (Printf.sprintf " %s -->|%s| %s\n" name label downstream)) info.downstream) nodes; + (* Output edges from combinators to their outputs *) + Hashtbl.iter + (fun comb_name (_shape, _inputs, output) -> + Buffer.add_string buf (Printf.sprintf " %s --> %s\n" comb_name output)) + combinators; Buffer.contents buf (** Print timing stats for all nodes *) @@ -711,6 +747,8 @@ let join ~name (left : ('k1, 'v1) t) (right : ('k2, 'v2) t) ~key_of ~f ?merge () in Registry.add_edge ~from_name:left.name ~to_name:name ~label:"join"; Registry.add_edge ~from_name:right.name ~to_name:name ~label:"join"; + Registry.add_combinator ~name:(name ^ "_join") ~shape:"join" + ~inputs:[left.name; right.name] ~output:name; (* Subscribe to sources: just accumulate *) left.subscribe (fun delta -> @@ -844,6 +882,8 @@ let union ~name (left : ('k, 'v) t) (right : ('k, 'v) t) ?merge () : ('k, 'v) t in Registry.add_edge ~from_name:left.name ~to_name:name ~label:"union"; Registry.add_edge ~from_name:right.name ~to_name:name ~label:"union"; + Registry.add_combinator ~name:(name ^ "_union") ~shape:"union" + ~inputs:[left.name; right.name] ~output:name; (* Subscribe to sources: just accumulate *) left.subscribe (fun delta -> @@ -1046,6 +1086,8 @@ let fixpoint ~name ~(init : ('k, unit) t) ~(edges : ('k, 'k list) t) () : in Registry.add_edge ~from_name:init.name ~to_name:name ~label:"roots"; Registry.add_edge ~from_name:edges.name ~to_name:name ~label:"edges"; + Registry.add_combinator ~name:(name ^ "_fp") ~shape:"fixpoint" + ~inputs:[init.name; edges.name] ~output:name; (* Subscribe to sources: just accumulate *) init.subscribe (fun delta -> diff --git a/analysis/reanalyze/diagrams/reactive-pipeline-full.mmd b/analysis/reanalyze/diagrams/reactive-pipeline-full.mmd index 7c8f986542..399e0bc68b 100644 --- a/analysis/reanalyze/diagrams/reactive-pipeline-full.mmd +++ b/analysis/reanalyze/diagrams/reactive-pipeline-full.mmd @@ -1,127 +1,179 @@ graph TD annotations[annotations L2] - annotations -->|join| solver.incorrect_dead_decls - annotations -->|join| liveness.annotated_roots exc_refs.exception_decls[exc_refs.exception_decls L3] - exc_refs.exception_decls -->|join| exc_refs.resolved_refs type_deps.same_path_refs[type_deps.same_path_refs L4] - type_deps.same_path_refs -->|union| type_deps.u1 file_collection[file_collection L0] - file_collection -->|flatMap| file_data_collection decl_refs.value_decl_refs[decl_refs.value_decl_refs L7] - decl_refs.value_decl_refs -->|join| decl_refs.with_value_refs type_deps.combined_refs_to[type_deps.combined_refs_to L7] - type_deps.combined_refs_to -->|flatMap| type_deps.all_type_refs_from type_deps.all_type_refs_from[type_deps.all_type_refs_from L8] - type_deps.all_type_refs_from -->|union| liveness.type_refs_from type_deps.impl_needing_path2[type_deps.impl_needing_path2 L4] - type_deps.impl_needing_path2 -->|join| type_deps.impl_to_intf_refs_path2 exc_refs.resolved_refs_from[exc_refs.resolved_refs_from L5] - exc_refs.resolved_refs_from -->|union| liveness.value_refs_from exc_refs.resolved_refs[exc_refs.resolved_refs L4] - exc_refs.resolved_refs -->|flatMap| exc_refs.resolved_refs_from type_deps.impl_to_intf_refs_path2[type_deps.impl_to_intf_refs_path2 L5] - type_deps.impl_to_intf_refs_path2 -->|union| type_deps.u2 file_deps_map[file_deps_map L2] decl_refs.with_value_refs[decl_refs.with_value_refs L8] - decl_refs.with_value_refs -->|join| decl_refs.combined type_deps.u1[type_deps.u1 L5] - type_deps.u1 -->|union| type_deps.u2 cross_file_items[cross_file_items L2] - cross_file_items -->|flatMap| exception_refs_collection decl_refs.decls_by_file[decl_refs.decls_by_file L3] - decl_refs.decls_by_file -->|join| decl_refs.type_decl_refs - decl_refs.decls_by_file -->|join| decl_refs.value_decl_refs type_deps.impl_to_intf_refs[type_deps.impl_to_intf_refs L4] - type_deps.impl_to_intf_refs -->|union| type_deps.u1 solver.issues_by_file[solver.issues_by_file L17] - solver.issues_by_file -->|flatMap| solver.modules_with_reported liveness.annotated_roots[liveness.annotated_roots L3] - liveness.annotated_roots -->|union| liveness.all_roots solver.incorrect_dead_decls[solver.incorrect_dead_decls L16] type_deps.intf_to_impl_refs[type_deps.intf_to_impl_refs L4] - type_deps.intf_to_impl_refs -->|union| type_deps.combined_refs_to type_deps.decl_by_path[type_deps.decl_by_path L3] - type_deps.decl_by_path -->|join| type_deps.intf_to_impl_refs - type_deps.decl_by_path -->|join| type_deps.impl_to_intf_refs_path2 - type_deps.decl_by_path -->|join| type_deps.impl_needing_path2 - type_deps.decl_by_path -->|join| type_deps.impl_to_intf_refs - type_deps.decl_by_path -->|flatMap| type_deps.same_path_refs type_deps.u2[type_deps.u2 L6] - type_deps.u2 -->|union| type_deps.combined_refs_to solver.live_decls[solver.live_decls L15] - solver.live_decls -->|join| solver.incorrect_dead_decls - solver.live_decls -->|flatMap| solver.modules_with_live type_deps.impl_decls[type_deps.impl_decls L3] - type_deps.impl_decls -->|join| type_deps.impl_needing_path2 - type_deps.impl_decls -->|join| type_deps.impl_to_intf_refs liveness.all_roots[liveness.all_roots L12] - liveness.all_roots -->|roots| liveness.live solver.dead_modules[solver.dead_modules L17] - solver.dead_modules -->|join| solver.dead_module_issues liveness.external_type_refs[liveness.external_type_refs L10] - liveness.external_type_refs -->|union| liveness.externally_referenced decl_refs.combined[decl_refs.combined L12] - decl_refs.combined -->|flatMap| liveness.edges type_refs_from[type_refs_from L2] - type_refs_from -->|union| liveness.type_refs_from liveness.type_refs_from[liveness.type_refs_from L9] - liveness.type_refs_from -->|join| liveness.external_type_refs - liveness.type_refs_from -->|join| decl_refs.type_decl_refs solver.dead_decls_by_file[solver.dead_decls_by_file L16] - solver.dead_decls_by_file -->|flatMap| solver.issues_by_file liveness.external_value_refs[liveness.external_value_refs L7] - liveness.external_value_refs -->|union| liveness.externally_referenced liveness.value_refs_from[liveness.value_refs_from L6] - liveness.value_refs_from -->|join| liveness.external_value_refs - liveness.value_refs_from -->|join| decl_refs.value_decl_refs value_refs_from[value_refs_from L2] - value_refs_from -->|union| liveness.value_refs_from solver.modules_with_dead[solver.modules_with_dead L16] - solver.modules_with_dead -->|join| solver.dead_modules solver.dead_decls[solver.dead_decls L15] - solver.dead_decls -->|flatMap| solver.dead_decls_by_file - solver.dead_decls -->|flatMap| solver.modules_with_dead exception_refs_collection[exception_refs_collection L3] - exception_refs_collection -->|join| exc_refs.resolved_refs type_deps.intf_decls[type_deps.intf_decls L3] - type_deps.intf_decls -->|join| type_deps.intf_to_impl_refs file_data_collection[file_data_collection L1] - file_data_collection -->|flatMap| files - file_data_collection -->|flatMap| file_deps_map - file_data_collection -->|flatMap| cross_file_items - file_data_collection -->|flatMap| type_refs_from - file_data_collection -->|flatMap| value_refs_from - file_data_collection -->|flatMap| annotations - file_data_collection -->|flatMap| decls solver.dead_module_issues[solver.dead_module_issues L19] decl_refs.with_type_refs[decl_refs.with_type_refs L11] - decl_refs.with_type_refs -->|join| decl_refs.combined solver.modules_with_live[solver.modules_with_live L16] - solver.modules_with_live -->|join| solver.dead_modules decl_refs.type_decl_refs[decl_refs.type_decl_refs L10] - decl_refs.type_decl_refs -->|join| decl_refs.with_type_refs files[files L2] solver.modules_with_reported[solver.modules_with_reported L18] - solver.modules_with_reported -->|join| solver.dead_module_issues liveness.externally_referenced[liveness.externally_referenced L11] - liveness.externally_referenced -->|union| liveness.all_roots liveness.edges[liveness.edges L13] - liveness.edges -->|edges| liveness.live liveness.live[liveness.live L14] - liveness.live -->|join| solver.live_decls - liveness.live -->|join| solver.dead_decls decls[decls L2] - decls -->|join| solver.live_decls - decls -->|join| solver.dead_decls - decls -->|join| liveness.annotated_roots - decls -->|join| liveness.external_type_refs - decls -->|join| liveness.external_value_refs - decls -->|join| decl_refs.with_type_refs - decls -->|join| decl_refs.with_value_refs + type_deps.intf_to_impl_refs_join{join} + liveness.external_value_refs_join{join} + type_deps.impl_to_intf_refs_path2_join{join} + solver.incorrect_dead_decls_join{join} + liveness.value_refs_from_union{union} + exc_refs.resolved_refs_join{join} + type_deps.combined_refs_to_union{union} + liveness.externally_referenced_union{union} + solver.dead_modules_join{join} + liveness.all_roots_union{union} + solver.dead_module_issues_join{join} + solver.dead_decls_join{join} + liveness.annotated_roots_join{join} + decl_refs.value_decl_refs_join{join} + type_deps.impl_to_intf_refs_join{join} + solver.live_decls_join{join} + decl_refs.with_value_refs_join{join} + liveness.type_refs_from_union{union} + type_deps.impl_needing_path2_join{join} + liveness.external_type_refs_join{join} + liveness.live_fp{fixpoint} + decl_refs.type_decl_refs_join{join} + type_deps.u2_union{union} + decl_refs.combined_join{join} + type_deps.u1_union{union} + decl_refs.with_type_refs_join{join} + annotations --> solver.incorrect_dead_decls_join + annotations --> liveness.annotated_roots_join + exc_refs.exception_decls --> exc_refs.resolved_refs_join + type_deps.same_path_refs --> type_deps.u1_union + file_collection -->|flatMap| file_data_collection + decl_refs.value_decl_refs --> decl_refs.with_value_refs_join + type_deps.combined_refs_to -->|flatMap| type_deps.all_type_refs_from + type_deps.all_type_refs_from --> liveness.type_refs_from_union + type_deps.impl_needing_path2 --> type_deps.impl_to_intf_refs_path2_join + exc_refs.resolved_refs_from --> liveness.value_refs_from_union + exc_refs.resolved_refs -->|flatMap| exc_refs.resolved_refs_from + type_deps.impl_to_intf_refs_path2 --> type_deps.u2_union + decl_refs.with_value_refs --> decl_refs.combined_join + type_deps.u1 --> type_deps.u2_union + cross_file_items -->|flatMap| exception_refs_collection + decl_refs.decls_by_file --> decl_refs.type_decl_refs_join + decl_refs.decls_by_file --> decl_refs.value_decl_refs_join + type_deps.impl_to_intf_refs --> type_deps.u1_union + solver.issues_by_file -->|flatMap| solver.modules_with_reported + liveness.annotated_roots --> liveness.all_roots_union + type_deps.intf_to_impl_refs --> type_deps.combined_refs_to_union + type_deps.decl_by_path --> type_deps.intf_to_impl_refs_join + type_deps.decl_by_path --> type_deps.impl_to_intf_refs_path2_join + type_deps.decl_by_path --> type_deps.impl_needing_path2_join + type_deps.decl_by_path --> type_deps.impl_to_intf_refs_join + type_deps.decl_by_path -->|flatMap| type_deps.same_path_refs + type_deps.u2 --> type_deps.combined_refs_to_union + solver.live_decls --> solver.incorrect_dead_decls_join + solver.live_decls -->|flatMap| solver.modules_with_live + type_deps.impl_decls --> type_deps.impl_needing_path2_join + type_deps.impl_decls --> type_deps.impl_to_intf_refs_join + liveness.all_roots --> liveness.live_fp + solver.dead_modules --> solver.dead_module_issues_join + liveness.external_type_refs --> liveness.externally_referenced_union + decl_refs.combined -->|flatMap| liveness.edges + type_refs_from --> liveness.type_refs_from_union + liveness.type_refs_from --> liveness.external_type_refs_join + liveness.type_refs_from --> decl_refs.type_decl_refs_join + solver.dead_decls_by_file -->|flatMap| solver.issues_by_file + liveness.external_value_refs --> liveness.externally_referenced_union + liveness.value_refs_from --> liveness.external_value_refs_join + liveness.value_refs_from --> decl_refs.value_decl_refs_join + value_refs_from --> liveness.value_refs_from_union + solver.modules_with_dead --> solver.dead_modules_join + solver.dead_decls -->|flatMap| solver.dead_decls_by_file + solver.dead_decls -->|flatMap| solver.modules_with_dead + exception_refs_collection --> exc_refs.resolved_refs_join + type_deps.intf_decls --> type_deps.intf_to_impl_refs_join + file_data_collection -->|flatMap| files + file_data_collection -->|flatMap| file_deps_map + file_data_collection -->|flatMap| cross_file_items + file_data_collection -->|flatMap| type_refs_from + file_data_collection -->|flatMap| value_refs_from + file_data_collection -->|flatMap| annotations + file_data_collection -->|flatMap| decls + decl_refs.with_type_refs --> decl_refs.combined_join + solver.modules_with_live --> solver.dead_modules_join + decl_refs.type_decl_refs --> decl_refs.with_type_refs_join + solver.modules_with_reported --> solver.dead_module_issues_join + liveness.externally_referenced --> liveness.all_roots_union + liveness.edges --> liveness.live_fp + liveness.live --> solver.live_decls_join + liveness.live --> solver.dead_decls_join + decls --> solver.live_decls_join + decls --> solver.dead_decls_join + decls --> liveness.annotated_roots_join + decls --> liveness.external_type_refs_join + decls --> liveness.external_value_refs_join + decls --> decl_refs.with_type_refs_join + decls --> decl_refs.with_value_refs_join decls -->|flatMap| decl_refs.decls_by_file decls -->|flatMap| exc_refs.exception_decls decls -->|flatMap| type_deps.intf_decls decls -->|flatMap| type_deps.impl_decls decls -->|flatMap| type_deps.decl_by_path + type_deps.intf_to_impl_refs_join --> type_deps.intf_to_impl_refs + liveness.external_value_refs_join --> liveness.external_value_refs + type_deps.impl_to_intf_refs_path2_join --> type_deps.impl_to_intf_refs_path2 + solver.incorrect_dead_decls_join --> solver.incorrect_dead_decls + liveness.value_refs_from_union --> liveness.value_refs_from + exc_refs.resolved_refs_join --> exc_refs.resolved_refs + type_deps.combined_refs_to_union --> type_deps.combined_refs_to + liveness.externally_referenced_union --> liveness.externally_referenced + solver.dead_modules_join --> solver.dead_modules + liveness.all_roots_union --> liveness.all_roots + solver.dead_module_issues_join --> solver.dead_module_issues + solver.dead_decls_join --> solver.dead_decls + liveness.annotated_roots_join --> liveness.annotated_roots + decl_refs.value_decl_refs_join --> decl_refs.value_decl_refs + type_deps.impl_to_intf_refs_join --> type_deps.impl_to_intf_refs + solver.live_decls_join --> solver.live_decls + decl_refs.with_value_refs_join --> decl_refs.with_value_refs + liveness.type_refs_from_union --> liveness.type_refs_from + type_deps.impl_needing_path2_join --> type_deps.impl_needing_path2 + liveness.external_type_refs_join --> liveness.external_type_refs + liveness.live_fp --> liveness.live + decl_refs.type_decl_refs_join --> decl_refs.type_decl_refs + type_deps.u2_union --> type_deps.u2 + decl_refs.combined_join --> decl_refs.combined + type_deps.u1_union --> type_deps.u1 + decl_refs.with_type_refs_join --> decl_refs.with_type_refs From 8a1b2f3404349d34d9fcba7e037338f6ca44aa8c Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Wed, 17 Dec 2025 11:43:27 +0100 Subject: [PATCH 43/45] Add colored combinator nodes to Mermaid diagram - join nodes: light blue (#e6f3ff) - union nodes: light orange (#fff0e6) - fixpoint nodes: light green (#e6ffe6) - Remove level annotations from node labels --- analysis/reactive/src/Reactive.ml | 44 +++++-- .../diagrams/reactive-pipeline-full.mmd | 107 ++++++++++-------- 2 files changed, 94 insertions(+), 57 deletions(-) diff --git a/analysis/reactive/src/Reactive.ml b/analysis/reactive/src/Reactive.ml index e1287634c8..0f712f1bdb 100644 --- a/analysis/reactive/src/Reactive.ml +++ b/analysis/reactive/src/Reactive.ml @@ -97,6 +97,7 @@ module Registry = struct let nodes : (string, node_info) Hashtbl.t = Hashtbl.create 64 let edges : (string * string, string) Hashtbl.t = Hashtbl.create 128 + (* Combinator nodes: (combinator_id, (shape, inputs, output)) *) let combinators : (string, string * string list * string) Hashtbl.t = Hashtbl.create 32 @@ -152,19 +153,27 @@ module Registry = struct Hashtbl.iter (fun comb_name (_, inputs, output) -> List.iter - (fun input -> Hashtbl.replace combinator_edges (input, output) comb_name) + (fun input -> + Hashtbl.replace combinator_edges (input, output) comb_name) inputs) combinators; (* Output regular nodes *) Hashtbl.iter - (fun name info -> - Buffer.add_string buf - (Printf.sprintf " %s[%s L%d]\n" name name info.level)) + (fun name _info -> + Buffer.add_string buf (Printf.sprintf " %s[%s]\n" name name)) nodes; - (* Output combinator nodes (diamond shape) *) + (* Output combinator nodes (diamond shape) with classes *) + let join_nodes = ref [] in + let union_nodes = ref [] in + let fixpoint_nodes = ref [] in Hashtbl.iter (fun comb_name (shape, _inputs, _output) -> - Buffer.add_string buf (Printf.sprintf " %s{%s}\n" comb_name shape)) + Buffer.add_string buf (Printf.sprintf " %s{%s}\n" comb_name shape); + match shape with + | "join" -> join_nodes := comb_name :: !join_nodes + | "union" -> union_nodes := comb_name :: !union_nodes + | "fixpoint" -> fixpoint_nodes := comb_name :: !fixpoint_nodes + | _ -> ()) combinators; (* Output edges *) Hashtbl.iter @@ -194,8 +203,29 @@ module Registry = struct (* Output edges from combinators to their outputs *) Hashtbl.iter (fun comb_name (_shape, _inputs, output) -> - Buffer.add_string buf (Printf.sprintf " %s --> %s\n" comb_name output)) + Buffer.add_string buf + (Printf.sprintf " %s --> %s\n" comb_name output)) combinators; + (* Style definitions for combinator types *) + Buffer.add_string buf + "\n classDef joinClass fill:#e6f3ff,stroke:#0066cc\n"; + Buffer.add_string buf + " classDef unionClass fill:#fff0e6,stroke:#cc6600\n"; + Buffer.add_string buf + " classDef fixpointClass fill:#e6ffe6,stroke:#006600\n"; + (* Assign classes to combinator nodes *) + if !join_nodes <> [] then + Buffer.add_string buf + (Printf.sprintf " class %s joinClass\n" + (String.concat "," !join_nodes)); + if !union_nodes <> [] then + Buffer.add_string buf + (Printf.sprintf " class %s unionClass\n" + (String.concat "," !union_nodes)); + if !fixpoint_nodes <> [] then + Buffer.add_string buf + (Printf.sprintf " class %s fixpointClass\n" + (String.concat "," !fixpoint_nodes)); Buffer.contents buf (** Print timing stats for all nodes *) diff --git a/analysis/reanalyze/diagrams/reactive-pipeline-full.mmd b/analysis/reanalyze/diagrams/reactive-pipeline-full.mmd index 399e0bc68b..cf2fc700d4 100644 --- a/analysis/reanalyze/diagrams/reactive-pipeline-full.mmd +++ b/analysis/reanalyze/diagrams/reactive-pipeline-full.mmd @@ -1,54 +1,54 @@ graph TD - annotations[annotations L2] - exc_refs.exception_decls[exc_refs.exception_decls L3] - type_deps.same_path_refs[type_deps.same_path_refs L4] - file_collection[file_collection L0] - decl_refs.value_decl_refs[decl_refs.value_decl_refs L7] - type_deps.combined_refs_to[type_deps.combined_refs_to L7] - type_deps.all_type_refs_from[type_deps.all_type_refs_from L8] - type_deps.impl_needing_path2[type_deps.impl_needing_path2 L4] - exc_refs.resolved_refs_from[exc_refs.resolved_refs_from L5] - exc_refs.resolved_refs[exc_refs.resolved_refs L4] - type_deps.impl_to_intf_refs_path2[type_deps.impl_to_intf_refs_path2 L5] - file_deps_map[file_deps_map L2] - decl_refs.with_value_refs[decl_refs.with_value_refs L8] - type_deps.u1[type_deps.u1 L5] - cross_file_items[cross_file_items L2] - decl_refs.decls_by_file[decl_refs.decls_by_file L3] - type_deps.impl_to_intf_refs[type_deps.impl_to_intf_refs L4] - solver.issues_by_file[solver.issues_by_file L17] - liveness.annotated_roots[liveness.annotated_roots L3] - solver.incorrect_dead_decls[solver.incorrect_dead_decls L16] - type_deps.intf_to_impl_refs[type_deps.intf_to_impl_refs L4] - type_deps.decl_by_path[type_deps.decl_by_path L3] - type_deps.u2[type_deps.u2 L6] - solver.live_decls[solver.live_decls L15] - type_deps.impl_decls[type_deps.impl_decls L3] - liveness.all_roots[liveness.all_roots L12] - solver.dead_modules[solver.dead_modules L17] - liveness.external_type_refs[liveness.external_type_refs L10] - decl_refs.combined[decl_refs.combined L12] - type_refs_from[type_refs_from L2] - liveness.type_refs_from[liveness.type_refs_from L9] - solver.dead_decls_by_file[solver.dead_decls_by_file L16] - liveness.external_value_refs[liveness.external_value_refs L7] - liveness.value_refs_from[liveness.value_refs_from L6] - value_refs_from[value_refs_from L2] - solver.modules_with_dead[solver.modules_with_dead L16] - solver.dead_decls[solver.dead_decls L15] - exception_refs_collection[exception_refs_collection L3] - type_deps.intf_decls[type_deps.intf_decls L3] - file_data_collection[file_data_collection L1] - solver.dead_module_issues[solver.dead_module_issues L19] - decl_refs.with_type_refs[decl_refs.with_type_refs L11] - solver.modules_with_live[solver.modules_with_live L16] - decl_refs.type_decl_refs[decl_refs.type_decl_refs L10] - files[files L2] - solver.modules_with_reported[solver.modules_with_reported L18] - liveness.externally_referenced[liveness.externally_referenced L11] - liveness.edges[liveness.edges L13] - liveness.live[liveness.live L14] - decls[decls L2] + annotations[annotations] + exc_refs.exception_decls[exc_refs.exception_decls] + type_deps.same_path_refs[type_deps.same_path_refs] + file_collection[file_collection] + decl_refs.value_decl_refs[decl_refs.value_decl_refs] + type_deps.combined_refs_to[type_deps.combined_refs_to] + type_deps.all_type_refs_from[type_deps.all_type_refs_from] + type_deps.impl_needing_path2[type_deps.impl_needing_path2] + exc_refs.resolved_refs_from[exc_refs.resolved_refs_from] + exc_refs.resolved_refs[exc_refs.resolved_refs] + type_deps.impl_to_intf_refs_path2[type_deps.impl_to_intf_refs_path2] + file_deps_map[file_deps_map] + decl_refs.with_value_refs[decl_refs.with_value_refs] + type_deps.u1[type_deps.u1] + cross_file_items[cross_file_items] + decl_refs.decls_by_file[decl_refs.decls_by_file] + type_deps.impl_to_intf_refs[type_deps.impl_to_intf_refs] + solver.issues_by_file[solver.issues_by_file] + liveness.annotated_roots[liveness.annotated_roots] + solver.incorrect_dead_decls[solver.incorrect_dead_decls] + type_deps.intf_to_impl_refs[type_deps.intf_to_impl_refs] + type_deps.decl_by_path[type_deps.decl_by_path] + type_deps.u2[type_deps.u2] + solver.live_decls[solver.live_decls] + type_deps.impl_decls[type_deps.impl_decls] + liveness.all_roots[liveness.all_roots] + solver.dead_modules[solver.dead_modules] + liveness.external_type_refs[liveness.external_type_refs] + decl_refs.combined[decl_refs.combined] + type_refs_from[type_refs_from] + liveness.type_refs_from[liveness.type_refs_from] + solver.dead_decls_by_file[solver.dead_decls_by_file] + liveness.external_value_refs[liveness.external_value_refs] + liveness.value_refs_from[liveness.value_refs_from] + value_refs_from[value_refs_from] + solver.modules_with_dead[solver.modules_with_dead] + solver.dead_decls[solver.dead_decls] + exception_refs_collection[exception_refs_collection] + type_deps.intf_decls[type_deps.intf_decls] + file_data_collection[file_data_collection] + solver.dead_module_issues[solver.dead_module_issues] + decl_refs.with_type_refs[decl_refs.with_type_refs] + solver.modules_with_live[solver.modules_with_live] + decl_refs.type_decl_refs[decl_refs.type_decl_refs] + files[files] + solver.modules_with_reported[solver.modules_with_reported] + liveness.externally_referenced[liveness.externally_referenced] + liveness.edges[liveness.edges] + liveness.live[liveness.live] + decls[decls] type_deps.intf_to_impl_refs_join{join} liveness.external_value_refs_join{join} type_deps.impl_to_intf_refs_path2_join{join} @@ -177,3 +177,10 @@ graph TD type_deps.u1_union --> type_deps.u1 decl_refs.with_type_refs_join --> decl_refs.with_type_refs + classDef joinClass fill:#e6f3ff,stroke:#0066cc + classDef unionClass fill:#fff0e6,stroke:#cc6600 + classDef fixpointClass fill:#e6ffe6,stroke:#006600 + class decl_refs.with_type_refs_join,decl_refs.combined_join,decl_refs.type_decl_refs_join,liveness.external_type_refs_join,type_deps.impl_needing_path2_join,decl_refs.with_value_refs_join,solver.live_decls_join,type_deps.impl_to_intf_refs_join,decl_refs.value_decl_refs_join,liveness.annotated_roots_join,solver.dead_decls_join,solver.dead_module_issues_join,solver.dead_modules_join,exc_refs.resolved_refs_join,solver.incorrect_dead_decls_join,type_deps.impl_to_intf_refs_path2_join,liveness.external_value_refs_join,type_deps.intf_to_impl_refs_join joinClass + class type_deps.u1_union,type_deps.u2_union,liveness.type_refs_from_union,liveness.all_roots_union,liveness.externally_referenced_union,type_deps.combined_refs_to_union,liveness.value_refs_from_union unionClass + class liveness.live_fp fixpointClass + From dfcb63e54bd88443263a4d06bd8e5d3fa98c8d09 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Wed, 17 Dec 2025 12:19:28 +0100 Subject: [PATCH 44/45] Fix dead module location and hasRefBelow in reactive mode - Dead module issues with ghost locations now use fileName from declaration instead of _none_ from the ghost location - hasRefBelow now correctly checks ALL refs (including cross-file) since refIsBelow returns true for any cross-file ref - Both fixes ensure reactive/non-reactive produce identical results - Verified against master: all 4 mode combinations match exactly --- analysis/reanalyze/src/ReactiveSolver.ml | 77 ++++++++++-------------- 1 file changed, 33 insertions(+), 44 deletions(-) diff --git a/analysis/reanalyze/src/ReactiveSolver.ml b/analysis/reanalyze/src/ReactiveSolver.ml index 17b2fff489..009d64d3fd 100644 --- a/analysis/reanalyze/src/ReactiveSolver.ml +++ b/analysis/reanalyze/src/ReactiveSolver.ml @@ -15,7 +15,7 @@ - is_pos_live uses reactive live collection (no resolvedDead mutation) - shouldReport callback replaces report field mutation (no mutation needed) - isInsideReportedValue is per-file only, so files are independent - - hasRefBelow uses per-file refs: O(file_refs) per dead decl (was O(total_refs)) + - hasRefBelow uses on-demand search: O(total_refs) per dead decl (cross-file refs count as "below") All issues now match between reactive and non-reactive modes (380 on deadcode test): - Dead code issues: 362 (Exception:2, Module:31, Type:87, Value:233, ValueWithSideEffects:8) @@ -29,8 +29,8 @@ type t = { live_decls: (Lexing.position, Decl.t) Reactive.t; annotations: (Lexing.position, FileAnnotations.annotated_as) Reactive.t; value_refs_from: (Lexing.position, PosSet.t) Reactive.t option; - dead_modules: (Name.t, Location.t) Reactive.t; - (** Modules where all declarations are dead. Reactive anti-join. *) + dead_modules: (Name.t, Location.t * string) Reactive.t; + (** Modules where all declarations are dead. Value is (loc, fileName). Reactive anti-join. *) dead_decls_by_file: (string, Decl.t list) Reactive.t; (** Dead declarations grouped by file. Reactive per-file grouping. *) issues_by_file: (string, Issue.t list * Name.t list) Reactive.t; @@ -83,11 +83,15 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) ~f:(fun _ _ -> []) () else - (* modules_with_dead: (moduleName, loc) for each module with dead decls *) + (* modules_with_dead: (moduleName, (loc, fileName)) for each module with dead decls *) let modules_with_dead = Reactive.flatMap ~name:"solver.modules_with_dead" dead_decls - ~f:(fun _pos decl -> [(decl_module_name decl, decl.moduleLoc)]) - ~merge:(fun loc1 _loc2 -> loc1) (* keep first location *) + ~f:(fun _pos decl -> + [ + ( decl_module_name decl, + (decl.moduleLoc, decl.pos.Lexing.pos_fname) ); + ]) + ~merge:(fun v1 _v2 -> v1) (* keep first *) () in (* modules_with_live: (moduleName, ()) for each module with live decls *) @@ -99,10 +103,10 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) (* Anti-join: modules in dead but not in live *) Reactive.join ~name:"solver.dead_modules" modules_with_dead modules_with_live - ~key_of:(fun modName _loc -> modName) - ~f:(fun modName loc live_opt -> + ~key_of:(fun modName (_loc, _fileName) -> modName) + ~f:(fun modName (loc, fileName) live_opt -> match live_opt with - | None -> [(modName, loc)] (* dead: no live decls *) + | None -> [(modName, (loc, fileName))] (* dead: no live decls *) | Some () -> []) (* live: has at least one live decl *) () in @@ -115,26 +119,12 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) () in - (* Reactive per-file grouping of value refs (for hasRefBelow optimization) *) let transitive = config.DceConfig.run.transitive in - let value_refs_from_by_file = - match value_refs_from with - | None -> None - | Some refs_from -> - Some - (Reactive.flatMap ~name:"solver.value_refs_from_by_file" refs_from - ~f:(fun posFrom posToSet -> - [(posFrom.Lexing.pos_fname, [(posFrom, posToSet)])]) - ~merge:(fun refs1 refs2 -> refs1 @ refs2) - ()) - in (* Reactive per-file issues - recomputed when dead_decls_by_file changes. Returns (file, (value_issues, modules_with_reported_values)) where modules_with_reported_values are modules that have at least one reported dead value. - Module issues are generated separately in collect_issues using dead_modules. - - hasRefBelow now uses per-file refs: O(file_refs) instead of O(total_refs). *) + Module issues are generated separately in collect_issues using dead_modules. *) let issues_by_file = Reactive.flatMap ~name:"solver.issues_by_file" dead_decls_by_file ~f:(fun file decls -> @@ -153,23 +143,16 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) Hashtbl.replace modules_with_values moduleName (); None (* Module issues generated separately *) in - (* Per-file hasRefBelow: only scan refs from this file *) + (* hasRefBelow: check if decl has any ref from "below" (including cross-file refs) *) let hasRefBelow = if transitive then fun _ -> false else - match value_refs_from_by_file with + match value_refs_from with | None -> fun _ -> false - | Some refs_by_file -> ( - let file_refs = Reactive.get refs_by_file file in - fun decl -> - match file_refs with - | None -> false - | Some refs_list -> - List.exists - (fun (posFrom, posToSet) -> - PosSet.mem decl.Decl.pos posToSet - && DeadCommon.refIsBelow decl posFrom) - refs_list) + | Some refs_from -> + (* Must iterate ALL refs since cross-file refs also count as "below" *) + DeadCommon.make_hasRefBelow ~transitive + ~iter_value_refs_from:(fun f -> Reactive.iter f refs_from) in (* Sort within file and generate issues *) let sorted = decls |> List.fast_sort Decl.compareForReporting in @@ -210,15 +193,19 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) let dead_module_issues = Reactive.join ~name:"solver.dead_module_issues" dead_modules modules_with_reported - ~key_of:(fun moduleName _loc -> moduleName) - ~f:(fun moduleName loc has_reported_opt -> + ~key_of:(fun moduleName (_loc, _fileName) -> moduleName) + ~f:(fun moduleName (loc, fileName) has_reported_opt -> match has_reported_opt with | Some () -> let loc = if loc.Location.loc_ghost then - let pos_fname = loc.loc_start.pos_fname in let pos = - {Lexing.pos_fname; pos_lnum = 0; pos_bol = 0; pos_cnum = 0} + { + Lexing.pos_fname = fileName; + pos_lnum = 0; + pos_bol = 0; + pos_cnum = 0; + } in {Location.loc_start = pos; loc_end = pos; loc_ghost = false} else loc @@ -245,18 +232,20 @@ let create ~(decls : (Lexing.position, Decl.t) Reactive.t) (** Check if a module is dead using reactive collection. Returns issue if dead. Uses reported_modules set to avoid duplicate reports. *) -let check_module_dead ~(dead_modules : (Name.t, Location.t) Reactive.t) +let check_module_dead ~(dead_modules : (Name.t, Location.t * string) Reactive.t) ~(reported_modules : (Name.t, unit) Hashtbl.t) ~fileName:pos_fname moduleName : Issue.t option = if Hashtbl.mem reported_modules moduleName then None else match Reactive.get dead_modules moduleName with - | Some loc -> + | Some (loc, fileName) -> Hashtbl.replace reported_modules moduleName (); let loc = if loc.Location.loc_ghost then + (* Use fileName from dead_modules, fallback to pos_fname *) + let fname = if fileName <> "" then fileName else pos_fname in let pos = - {Lexing.pos_fname; pos_lnum = 0; pos_bol = 0; pos_cnum = 0} + {Lexing.pos_fname = fname; pos_lnum = 0; pos_bol = 0; pos_cnum = 0} in {Location.loc_start = pos; loc_end = pos; loc_ghost = false} else loc From f329544e9196be200a2818b3e50ab381006cb549 Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Wed, 17 Dec 2025 13:32:06 +0100 Subject: [PATCH 45/45] Add churn mode for incremental correctness testing - Add -churn n CLI option to simulate file add/remove cycles - Alternates between removing n random files and adding them back - Shows issue count changes per run with detailed diff - Add remove_batch to ReactiveFileCollection for batched removals (28x faster) - Add reset_stats to Reactive for per-operation stat tracking - Include churn time in timing report with proper accounting - Print aggregate stats at end: mean/std for churn time and issue changes - Add skip_file parameter to filter removed files from processing --- analysis/reactive/src/Reactive.ml | 16 ++ analysis/reactive/src/Reactive.mli | 3 + .../reactive/src/ReactiveFileCollection.ml | 13 ++ .../reactive/src/ReactiveFileCollection.mli | 4 + analysis/reanalyze/src/Cli.ml | 3 + analysis/reanalyze/src/Log_.ml | 1 + analysis/reanalyze/src/Reanalyze.ml | 147 +++++++++++++++++- analysis/reanalyze/src/Timing.ml | 11 +- analysis/src/DceCommand.ml | 3 +- 9 files changed, 191 insertions(+), 10 deletions(-) diff --git a/analysis/reactive/src/Reactive.ml b/analysis/reactive/src/Reactive.ml index 0f712f1bdb..71d94ad8b3 100644 --- a/analysis/reactive/src/Reactive.ml +++ b/analysis/reactive/src/Reactive.ml @@ -144,6 +144,21 @@ module Registry = struct Hashtbl.clear combinators; dirty_nodes := [] + let reset_stats () = + Hashtbl.iter + (fun _ info -> + info.stats.deltas_received <- 0; + info.stats.entries_received <- 0; + info.stats.adds_received <- 0; + info.stats.removes_received <- 0; + info.stats.process_count <- 0; + info.stats.process_time_ns <- 0L; + info.stats.deltas_emitted <- 0; + info.stats.entries_emitted <- 0; + info.stats.adds_emitted <- 0; + info.stats.removes_emitted <- 0) + nodes + (** Generate Mermaid diagram of the pipeline *) let to_mermaid () = let buf = Buffer.create 256 in @@ -1167,3 +1182,4 @@ let fixpoint ~name ~(init : ('k, unit) t) ~(edges : ('k, 'k list) t) () : let to_mermaid () = Registry.to_mermaid () let print_stats () = Registry.print_stats () let reset () = Registry.clear () +let reset_stats () = Registry.reset_stats () diff --git a/analysis/reactive/src/Reactive.mli b/analysis/reactive/src/Reactive.mli index 9a90c53192..8964baed03 100644 --- a/analysis/reactive/src/Reactive.mli +++ b/analysis/reactive/src/Reactive.mli @@ -164,3 +164,6 @@ val print_stats : unit -> unit val reset : unit -> unit (** Clear all registered nodes (for tests) *) + +val reset_stats : unit -> unit +(** Reset all node statistics to zero (keeps nodes intact) *) diff --git a/analysis/reactive/src/ReactiveFileCollection.ml b/analysis/reactive/src/ReactiveFileCollection.ml index d1641479ff..bcae68a0b7 100644 --- a/analysis/reactive/src/ReactiveFileCollection.ml +++ b/analysis/reactive/src/ReactiveFileCollection.ml @@ -82,6 +82,19 @@ let remove t path = Hashtbl.remove t.internal.cache path; emit t (Reactive.Remove path) +(** Remove multiple files as a batch *) +let remove_batch t paths = + let entries = + paths + |> List.filter_map (fun path -> + if Hashtbl.mem t.internal.cache path then ( + Hashtbl.remove t.internal.cache path; + Some (path, None)) + else None) + in + if entries <> [] then emit t (Reactive.Batch entries); + List.length entries + (** Clear all cached data *) let clear t = Hashtbl.clear t.internal.cache diff --git a/analysis/reactive/src/ReactiveFileCollection.mli b/analysis/reactive/src/ReactiveFileCollection.mli index b6e50b820f..e50c661828 100644 --- a/analysis/reactive/src/ReactiveFileCollection.mli +++ b/analysis/reactive/src/ReactiveFileCollection.mli @@ -54,6 +54,10 @@ val process_if_changed : ('raw, 'v) t -> string -> bool val remove : ('raw, 'v) t -> string -> unit (** Remove a file from the collection. *) +val remove_batch : ('raw, 'v) t -> string list -> int +(** Remove multiple files as a batch. Returns the number of files removed. + More efficient than calling [remove] multiple times. *) + (** {1 Cache Management} *) val invalidate : ('raw, 'v) t -> string -> unit diff --git a/analysis/reanalyze/src/Cli.ml b/analysis/reanalyze/src/Cli.ml index 499e370ff9..a05ff04e0c 100644 --- a/analysis/reanalyze/src/Cli.ml +++ b/analysis/reanalyze/src/Cli.ml @@ -34,5 +34,8 @@ let reactive = ref false (* number of analysis runs (for benchmarking reactive mode) *) let runs = ref 1 +(* number of files to churn (remove/re-add) between runs for incremental testing *) +let churn = ref 0 + (* output mermaid diagram of reactive pipeline *) let mermaid = ref false diff --git a/analysis/reanalyze/src/Log_.ml b/analysis/reanalyze/src/Log_.ml index a50a73cd68..5a03ae5551 100644 --- a/analysis/reanalyze/src/Log_.ml +++ b/analysis/reanalyze/src/Log_.ml @@ -197,6 +197,7 @@ module Stats = struct let issues = ref [] let addIssue (issue : Issue.t) = issues := issue :: !issues let clear () = issues := [] + let get_issue_count () = List.length !issues let getSortedIssues () = let counters2 = Hashtbl.create 1 in diff --git a/analysis/reanalyze/src/Reanalyze.ml b/analysis/reanalyze/src/Reanalyze.ml index 70b6678b42..4b6860e86f 100644 --- a/analysis/reanalyze/src/Reanalyze.ml +++ b/analysis/reanalyze/src/Reanalyze.ml @@ -204,8 +204,14 @@ let processFilesParallel ~config ~numDomains (cmtFilePaths : string list) : (** Process all cmt files and return results for DCE and Exception analysis. Conceptually: map process_cmt_file over all files. *) -let processCmtFiles ~config ~cmtRoot ~reactive_collection : all_files_result = - let cmtFilePaths = collectCmtFilePaths ~cmtRoot in +let processCmtFiles ~config ~cmtRoot ~reactive_collection ~skip_file : + all_files_result = + let cmtFilePaths = + let all = collectCmtFilePaths ~cmtRoot in + match skip_file with + | Some should_skip -> List.filter (fun p -> not (should_skip p)) all + | None -> all + in (* Reactive mode: use incremental processing that skips unchanged files *) match reactive_collection with | Some collection -> @@ -245,10 +251,10 @@ let shuffle_list lst = Array.to_list arr let runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge - ~reactive_liveness ~reactive_solver = + ~reactive_liveness ~reactive_solver ~skip_file = (* Map: process each file -> list of file_data *) let {dce_data_list; exception_results} = - processCmtFiles ~config:dce_config ~cmtRoot ~reactive_collection + processCmtFiles ~config:dce_config ~cmtRoot ~reactive_collection ~skip_file in (* Get exception results from reactive collection if available *) let exception_results = @@ -522,20 +528,141 @@ let runAnalysisAndReport ~cmtRoot = ~config:dce_config) | _ -> None in + (* Collect CMT file paths once for churning *) + let cmtFilePaths = + if !Cli.churn > 0 then Some (collectCmtFilePaths ~cmtRoot) else None + in + (* Track previous issue count for diff reporting *) + let prev_issue_count = ref 0 in + (* Track currently removed files (to add them back on next run) *) + let removed_files = ref [] in + (* Set of removed files for filtering in processCmtFiles *) + let removed_set = Hashtbl.create 64 in + (* Aggregate stats for churn mode *) + let churn_times = ref [] in + let issues_added_list = ref [] in + let issues_removed_list = ref [] in for run = 1 to numRuns do Timing.reset (); (* Clear stats at start of each run to avoid accumulation *) if run > 1 then Log_.Stats.clear (); + (* Print run header first *) if numRuns > 1 && !Cli.timing then Printf.eprintf "\n=== Run %d/%d ===\n%!" run numRuns; + (* Churn: alternate between remove and add phases *) + (if !Cli.churn > 0 then + match (reactive_collection, cmtFilePaths) with + | Some collection, Some paths -> + Reactive.reset_stats (); + if run > 1 && !removed_files <> [] then ( + (* Add back previously removed files *) + let to_add = !removed_files in + removed_files := []; + (* Clear removed set so these files get processed again *) + List.iter (fun p -> Hashtbl.remove removed_set p) to_add; + let t0 = Unix.gettimeofday () in + let processed = + ReactiveFileCollection.process_files_batch + (collection + : ReactiveAnalysis.t + :> (_, _) ReactiveFileCollection.t) + to_add + in + let elapsed = Unix.gettimeofday () -. t0 in + Timing.add_churn_time elapsed; + churn_times := elapsed :: !churn_times; + if !Cli.timing then ( + Printf.eprintf " Added back %d files (%.3fs)\n%!" processed + elapsed; + (match reactive_liveness with + | Some liveness -> ReactiveLiveness.print_stats ~t:liveness + | None -> ()); + match reactive_solver with + | Some solver -> ReactiveSolver.print_stats ~t:solver + | None -> ())) + else if run > 1 then ( + (* Remove new random files *) + let numChurn = min !Cli.churn (List.length paths) in + let shuffled = shuffle_list paths in + let to_remove = List.filteri (fun i _ -> i < numChurn) shuffled in + removed_files := to_remove; + (* Mark as removed so processCmtFiles skips them *) + List.iter (fun p -> Hashtbl.replace removed_set p ()) to_remove; + let t0 = Unix.gettimeofday () in + let removed = + ReactiveFileCollection.remove_batch + (collection + : ReactiveAnalysis.t + :> (_, _) ReactiveFileCollection.t) + to_remove + in + let elapsed = Unix.gettimeofday () -. t0 in + Timing.add_churn_time elapsed; + churn_times := elapsed :: !churn_times; + if !Cli.timing then ( + Printf.eprintf " Removed %d files (%.3fs)\n%!" removed elapsed; + (match reactive_liveness with + | Some liveness -> ReactiveLiveness.print_stats ~t:liveness + | None -> ()); + match reactive_solver with + | Some solver -> ReactiveSolver.print_stats ~t:solver + | None -> ())) + | _ -> ()); + (* Skip removed files in reactive mode *) + let skip_file = + if Hashtbl.length removed_set > 0 then + Some (fun path -> Hashtbl.mem removed_set path) + else None + in runAnalysis ~dce_config ~cmtRoot ~reactive_collection ~reactive_merge - ~reactive_liveness ~reactive_solver; - if run = numRuns then ( - (* Only report on last run *) + ~reactive_liveness ~reactive_solver ~skip_file; + (* Report issue count with diff *) + let current_count = Log_.Stats.get_issue_count () in + if !Cli.churn > 0 then ( + let diff = current_count - !prev_issue_count in + (* Track added/removed separately *) + if run > 1 then + if diff > 0 then + issues_added_list := float_of_int diff :: !issues_added_list + else if diff < 0 then + issues_removed_list := float_of_int (-diff) :: !issues_removed_list; + let diff_str = + if run = 1 then "" + else if diff >= 0 then Printf.sprintf " (+%d)" diff + else Printf.sprintf " (%d)" diff + in Log_.Stats.report ~config:dce_config; - Log_.Stats.clear ()); + if !Cli.timing then + Printf.eprintf " Total issues: %d%s\n%!" current_count diff_str; + prev_issue_count := current_count) + else if run = numRuns then + (* Only report on last run for non-churn mode *) + Log_.Stats.report ~config:dce_config; + Log_.Stats.clear (); Timing.report () done; + (* Print aggregate churn stats *) + if !Cli.churn > 0 && !Cli.timing && List.length !churn_times > 0 then ( + let calc_stats lst = + if lst = [] then (0.0, 0.0) + else + let n = float_of_int (List.length lst) in + let sum = List.fold_left ( +. ) 0.0 lst in + let mean = sum /. n in + let variance = + List.fold_left (fun acc x -> acc +. ((x -. mean) ** 2.0)) 0.0 lst /. n + in + (mean, sqrt variance) + in + let time_mean, time_std = calc_stats !churn_times in + let added_mean, added_std = calc_stats !issues_added_list in + let removed_mean, removed_std = calc_stats !issues_removed_list in + Printf.eprintf "\n=== Churn Summary ===\n"; + Printf.eprintf " Churn operations: %d\n" (List.length !churn_times); + Printf.eprintf " Churn time: mean=%.3fs std=%.3fs\n" time_mean time_std; + Printf.eprintf " Issues added: mean=%.0f std=%.0f\n" added_mean added_std; + Printf.eprintf " Issues removed: mean=%.0f std=%.0f\n" removed_mean + removed_std); if !Cli.json then EmitJson.finish () let cli () = @@ -657,6 +784,10 @@ let cli () = ( "-runs", Int (fun n -> Cli.runs := n), "n Run analysis n times (for benchmarking cache effectiveness)" ); + ( "-churn", + Int (fun n -> Cli.churn := n), + "n Remove and re-add n random files between runs (tests incremental \ + correctness)" ); ("-version", Unit versionAndExit, "Show version information and exit"); ("--version", Unit versionAndExit, "Show version information and exit"); ] diff --git a/analysis/reanalyze/src/Timing.ml b/analysis/reanalyze/src/Timing.ml index 2341bd9109..782b0d5399 100644 --- a/analysis/reanalyze/src/Timing.ml +++ b/analysis/reanalyze/src/Timing.ml @@ -3,6 +3,8 @@ let enabled = ref false type phase_times = { + (* Churn (file add/remove) *) + mutable churn: float; (* CMT processing sub-phases *) mutable file_loading: float; mutable result_collection: float; @@ -15,6 +17,7 @@ type phase_times = { let times = { + churn = 0.0; file_loading = 0.0; result_collection = 0.0; merging = 0.0; @@ -26,12 +29,15 @@ let times = let timing_mutex = Mutex.create () let reset () = + times.churn <- 0.0; times.file_loading <- 0.0; times.result_collection <- 0.0; times.merging <- 0.0; times.solving <- 0.0; times.reporting <- 0.0 +let add_churn_time t = times.churn <- times.churn +. t + let now () = Unix.gettimeofday () let time_phase phase_name f = @@ -56,8 +62,11 @@ let report () = if !enabled then ( let cmt_total = times.file_loading in let analysis_total = times.merging +. times.solving in - let total = cmt_total +. analysis_total +. times.reporting in + let total = times.churn +. cmt_total +. analysis_total +. times.reporting in Printf.eprintf "\n=== Timing ===\n"; + if times.churn > 0.0 then + Printf.eprintf " Churn: %.3fs (%.1f%%)\n" times.churn + (100.0 *. times.churn /. total); Printf.eprintf " CMT processing: %.3fs (%.1f%%)\n" cmt_total (100.0 *. cmt_total /. total); (* Only show parallel-specific timing when used *) diff --git a/analysis/src/DceCommand.ml b/analysis/src/DceCommand.ml index 5e0420c3c3..45d3e610a2 100644 --- a/analysis/src/DceCommand.ml +++ b/analysis/src/DceCommand.ml @@ -2,6 +2,7 @@ let command () = Reanalyze.RunConfig.dce (); let dce_config = Reanalyze.DceConfig.current () in Reanalyze.runAnalysis ~dce_config ~cmtRoot:None ~reactive_collection:None - ~reactive_merge:None ~reactive_liveness:None ~reactive_solver:None; + ~reactive_merge:None ~reactive_liveness:None ~reactive_solver:None + ~skip_file:None; let issues = !Reanalyze.Log_.Stats.issues in Printf.printf "issues:%d\n" (List.length issues)