Skip to main content

fallow_core/analyze/
feature_flags.rs

1//! Feature flag collection and cross-reference with dead code findings.
2//!
3//! Collects per-file flag uses from parsed modules and builds
4//! project-level `FeatureFlag` results. Optionally correlates with
5//! dead code findings to identify flags guarding unused code.
6
7use std::path::PathBuf;
8
9use fallow_types::extract::{FlagUse, FlagUseKind, ModuleInfo, byte_offset_to_line_col};
10use fallow_types::results::{AnalysisResults, FeatureFlag, FlagConfidence, FlagKind};
11
12use crate::graph::ModuleGraph;
13
14/// Collect feature flag uses from all parsed modules into `FeatureFlag` results.
15///
16/// Maps extraction-level `FlagUse` (per-file, no path) to result-level
17/// `FeatureFlag` (with full path, confidence). Resolves guard span byte
18/// offsets to line numbers using per-file line offset tables.
19#[deprecated(
20    since = "2.76.0",
21    note = "fallow_core is internal; use fallow_api::run_feature_flags for typed output; serialize with fallow_api::serialize_feature_flags_programmatic_json for JSON output. See docs/fallow-core-migration.md and ADR-008."
22)]
23pub fn collect_feature_flags(modules: &[ModuleInfo], graph: &ModuleGraph) -> Vec<FeatureFlag> {
24    let mut flags = Vec::new();
25
26    for module in modules {
27        if module.flag_uses.is_empty() {
28            continue;
29        }
30
31        let idx = module.file_id.0 as usize;
32        let Some(node) = graph.modules.get(idx) else {
33            continue;
34        };
35
36        for flag_use in &module.flag_uses {
37            let mut flag = flag_use_to_feature_flag(flag_use, node.path.clone());
38
39            if let (Some(start), Some(end)) = (flag_use.guard_span_start, flag_use.guard_span_end)
40                && !module.line_offsets.is_empty()
41            {
42                let (start_line, _) = byte_offset_to_line_col(&module.line_offsets, start);
43                let (end_line, _) = byte_offset_to_line_col(&module.line_offsets, end);
44                flag.guard_line_start = Some(start_line);
45                flag.guard_line_end = Some(end_line);
46            }
47
48            flags.push(flag);
49        }
50    }
51
52    flags
53}
54
55/// Correlate feature flags with dead code findings.
56///
57/// For each flag that guards a code span, check if any dead code findings
58/// (unused exports) fall within that span. Populates `guarded_dead_exports`
59/// on each flag.
60#[deprecated(
61    since = "2.76.0",
62    note = "fallow_core is internal; use fallow_api::run_feature_flags for typed output; serialize with fallow_api::serialize_feature_flags_programmatic_json for JSON output. The `guarded_dead_exports` field carries the same correlation. See docs/fallow-core-migration.md and ADR-008."
63)]
64pub fn correlate_with_dead_code(flags: &mut [FeatureFlag], results: &AnalysisResults) {
65    if results.unused_exports.is_empty() && results.unused_types.is_empty() {
66        return;
67    }
68
69    for flag in flags.iter_mut() {
70        let (Some(guard_start), Some(guard_end)) = (flag.guard_line_start, flag.guard_line_end)
71        else {
72            continue;
73        };
74
75        for export in &results.unused_exports {
76            if export.export.path == flag.path
77                && export.export.line >= guard_start
78                && export.export.line <= guard_end
79            {
80                flag.guarded_dead_exports
81                    .push(export.export.export_name.clone());
82            }
83        }
84
85        for export in &results.unused_types {
86            if export.export.path == flag.path
87                && export.export.line >= guard_start
88                && export.export.line <= guard_end
89            {
90                flag.guarded_dead_exports
91                    .push(export.export.export_name.clone());
92            }
93        }
94    }
95}
96
97/// Convert an extraction-level `FlagUse` to a result-level `FeatureFlag`.
98fn flag_use_to_feature_flag(flag_use: &FlagUse, path: PathBuf) -> FeatureFlag {
99    let (kind, confidence) = match flag_use.kind {
100        FlagUseKind::EnvVar => (FlagKind::EnvironmentVariable, FlagConfidence::High),
101        FlagUseKind::SdkCall => (FlagKind::SdkCall, FlagConfidence::High),
102        FlagUseKind::ConfigObject => (FlagKind::ConfigObject, FlagConfidence::Low),
103    };
104
105    FeatureFlag {
106        path,
107        flag_name: flag_use.flag_name.clone(),
108        kind,
109        confidence,
110        line: flag_use.line,
111        col: flag_use.col,
112        guard_span_start: flag_use.guard_span_start,
113        guard_span_end: flag_use.guard_span_end,
114        sdk_name: flag_use.sdk_name.clone(),
115        guard_line_start: None,
116        guard_line_end: None,
117        guarded_dead_exports: Vec::new(),
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use fallow_types::discover::{DiscoveredFile, EntryPoint, FileId};
124    use fallow_types::extract::compute_line_offsets;
125    use fallow_types::output_dead_code::UnusedExportFinding;
126    use fallow_types::results::{AnalysisResults, UnusedExport};
127
128    use crate::graph::ModuleGraph;
129    use crate::resolve::ResolvedModule;
130
131    use super::*;
132
133    // ---------------------------------------------------------------------------
134    // Helpers
135    // ---------------------------------------------------------------------------
136
137    /// Build a minimal [`ModuleGraph`] that has exactly one module at index
138    /// `file_id.0` with the given absolute path.  No imports or exports are
139    /// wired; we only need `graph.modules[idx].path` to exist.
140    fn graph_with_module(file_id: FileId, path: PathBuf) -> ModuleGraph {
141        let files = vec![DiscoveredFile {
142            id: file_id,
143            path: path.clone(),
144            size_bytes: 0,
145        }];
146        let resolved = vec![ResolvedModule {
147            file_id,
148            path,
149            ..Default::default()
150        }];
151        let entry_points: Vec<EntryPoint> = vec![];
152        ModuleGraph::build(&resolved, &entry_points, &files)
153    }
154
155    /// Minimal [`ModuleInfo`] with the given `file_id` and `flag_uses`, all
156    /// other fields defaulting to empty / false.
157    fn module_with_flags(file_id: FileId, flag_uses: Vec<FlagUse>) -> ModuleInfo {
158        ModuleInfo {
159            file_id,
160            flag_uses,
161            exports: Vec::new(),
162            imports: Vec::new(),
163            re_exports: Vec::new(),
164            dynamic_imports: Vec::new(),
165            dynamic_import_patterns: Vec::new(),
166            require_calls: Vec::new(),
167            package_path_references: Box::default(),
168            member_accesses: Vec::new(),
169            semantic_facts: Box::default(),
170            whole_object_uses: Box::default(),
171            has_cjs_exports: false,
172            has_angular_component_template_url: false,
173            content_hash: 0,
174            suppressions: Vec::new(),
175            unknown_suppression_kinds: Vec::new(),
176            unused_import_bindings: Vec::new(),
177            type_referenced_import_bindings: Vec::new(),
178            value_referenced_import_bindings: Vec::new(),
179            line_offsets: Vec::new(),
180            complexity: Vec::new(),
181            class_heritage: Vec::new(),
182            exported_factory_returns: Box::default(),
183            injection_tokens: Vec::new(),
184            local_type_declarations: Vec::new(),
185            public_signature_type_references: Vec::new(),
186            namespace_object_aliases: Vec::new(),
187            iconify_prefixes: Vec::new(),
188            iconify_icon_names: Vec::new(),
189            auto_import_candidates: Vec::new(),
190            directives: Vec::new(),
191            client_only_dynamic_import_spans: Vec::new(),
192            security_sinks: Vec::new(),
193            security_sinks_skipped: 0,
194            security_unresolved_callee_sites: Vec::new(),
195            tainted_bindings: Vec::new(),
196            sanitized_sink_args: Vec::new(),
197            security_control_sites: Vec::new(),
198            callee_uses: Vec::new(),
199            misplaced_directives: Vec::new(),
200            inline_server_action_exports: Vec::new(),
201            di_key_sites: Vec::new(),
202            has_dynamic_provide: false,
203            referenced_import_bindings: Vec::new(),
204            component_props: Vec::new(),
205            has_props_attrs_fallthrough: false,
206            has_define_expose: false,
207            has_define_model: false,
208            has_unharvestable_props: false,
209            component_emits: Vec::new(),
210            angular_inputs: Vec::new(),
211            angular_outputs: Vec::new(),
212            has_unharvestable_emits: false,
213            has_dynamic_emit: false,
214            has_emit_whole_object_use: false,
215            load_return_keys: Vec::new(),
216            has_unharvestable_load: false,
217            has_load_data_whole_use: false,
218            has_page_data_store_whole_use: false,
219            component_functions: Vec::new(),
220            react_props: Vec::new(),
221            hook_uses: Vec::new(),
222            render_edges: Vec::new(),
223            svelte_dispatched_events: Vec::new(),
224            svelte_listened_events: Vec::new(),
225            angular_component_selectors: Vec::new(),
226            registered_custom_elements: Vec::new(),
227            used_custom_element_tags: Vec::new(),
228            angular_used_selectors: Vec::new(),
229            angular_entry_component_refs: Vec::new(),
230            has_dynamic_component_render: false,
231            has_dynamic_dispatch: false,
232        }
233    }
234
235    fn make_unused_export(path: PathBuf, export_name: &str, line: u32) -> UnusedExportFinding {
236        UnusedExportFinding::with_actions(UnusedExport {
237            path,
238            export_name: export_name.to_string(),
239            is_type_only: false,
240            line,
241            col: 0,
242            span_start: 0,
243            is_re_export: false,
244        })
245    }
246
247    // ---------------------------------------------------------------------------
248    // collect_feature_flags: positive detection path
249    // ---------------------------------------------------------------------------
250
251    /// A module with no flag uses is skipped entirely.
252    #[test]
253    #[expect(deprecated, reason = "testing the deprecated public API")]
254    fn collect_feature_flags_empty_flag_uses_skipped() {
255        let graph = graph_with_module(FileId(0), PathBuf::from("/project/src/empty.ts"));
256        let module = module_with_flags(FileId(0), vec![]);
257        let flags = collect_feature_flags(&[module], &graph);
258        assert!(
259            flags.is_empty(),
260            "module with no flag_uses should produce no flags"
261        );
262    }
263
264    /// A module whose file_id index falls outside graph.modules is skipped.
265    #[test]
266    #[expect(deprecated, reason = "testing the deprecated public API")]
267    fn collect_feature_flags_missing_graph_node_skipped() {
268        // Graph has a module at index 0, but the ModuleInfo claims file_id 5.
269        let path = PathBuf::from("/project/src/file.ts");
270        let graph = graph_with_module(FileId(0), path);
271        let flag_use = FlagUse {
272            flag_name: "MY_FLAG".to_string(),
273            kind: FlagUseKind::EnvVar,
274            line: 1,
275            col: 0,
276            guard_span_start: None,
277            guard_span_end: None,
278            sdk_name: None,
279        };
280        // FileId(5) maps to index 5, but graph only has index 0: should be skipped.
281        let module = module_with_flags(FileId(5), vec![flag_use]);
282        let flags = collect_feature_flags(&[module], &graph);
283        assert!(
284            flags.is_empty(),
285            "module whose file_id has no graph node should be skipped"
286        );
287    }
288
289    /// A module with an EnvVar flag use produces one FeatureFlag with High confidence.
290    #[test]
291    #[expect(deprecated, reason = "testing the deprecated public API")]
292    fn collect_feature_flags_produces_flag_from_env_var() {
293        let graph = graph_with_module(FileId(0), PathBuf::from("/project/src/config.ts"));
294        let flag_use = FlagUse {
295            flag_name: "ENABLE_DARK_MODE".to_string(),
296            kind: FlagUseKind::EnvVar,
297            line: 3,
298            col: 8,
299            guard_span_start: None,
300            guard_span_end: None,
301            sdk_name: None,
302        };
303        let module = module_with_flags(FileId(0), vec![flag_use]);
304        let flags = collect_feature_flags(&[module], &graph);
305        assert_eq!(flags.len(), 1);
306        let flag = &flags[0];
307        assert_eq!(flag.flag_name, "ENABLE_DARK_MODE");
308        assert_eq!(flag.kind, FlagKind::EnvironmentVariable);
309        assert_eq!(flag.confidence, FlagConfidence::High);
310        assert_eq!(flag.line, 3);
311        assert_eq!(flag.col, 8);
312        // Path must come from the graph node, not the ModuleInfo.
313        assert_eq!(
314            flag.path.to_string_lossy().replace('\\', "/"),
315            "/project/src/config.ts"
316        );
317    }
318
319    /// An SdkCall flag use is mapped correctly including sdk_name.
320    #[test]
321    #[expect(deprecated, reason = "testing the deprecated public API")]
322    fn collect_feature_flags_sdk_call_maps_sdk_name() {
323        let graph = graph_with_module(FileId(0), PathBuf::from("/project/src/feature.ts"));
324        let flag_use = FlagUse {
325            flag_name: "new-onboarding".to_string(),
326            kind: FlagUseKind::SdkCall,
327            line: 7,
328            col: 0,
329            guard_span_start: None,
330            guard_span_end: None,
331            sdk_name: Some("Unleash".to_string()),
332        };
333        let module = module_with_flags(FileId(0), vec![flag_use]);
334        let flags = collect_feature_flags(&[module], &graph);
335        assert_eq!(flags.len(), 1);
336        assert_eq!(flags[0].kind, FlagKind::SdkCall);
337        assert_eq!(flags[0].confidence, FlagConfidence::High);
338        assert_eq!(flags[0].sdk_name.as_deref(), Some("Unleash"));
339    }
340
341    /// A ConfigObject flag use maps to Low confidence.
342    #[test]
343    #[expect(deprecated, reason = "testing the deprecated public API")]
344    fn collect_feature_flags_config_object_has_low_confidence() {
345        let graph = graph_with_module(FileId(0), PathBuf::from("/project/src/flags.ts"));
346        let flag_use = FlagUse {
347            flag_name: "feature.beta".to_string(),
348            kind: FlagUseKind::ConfigObject,
349            line: 12,
350            col: 4,
351            guard_span_start: None,
352            guard_span_end: None,
353            sdk_name: None,
354        };
355        let module = module_with_flags(FileId(0), vec![flag_use]);
356        let flags = collect_feature_flags(&[module], &graph);
357        assert_eq!(flags.len(), 1);
358        assert_eq!(flags[0].confidence, FlagConfidence::Low);
359    }
360
361    /// Multiple flag uses in one module all produce individual FeatureFlag entries.
362    #[test]
363    #[expect(deprecated, reason = "testing the deprecated public API")]
364    fn collect_feature_flags_multiple_uses_in_one_module() {
365        let graph = graph_with_module(FileId(0), PathBuf::from("/project/src/multi.ts"));
366        let flag_uses = vec![
367            FlagUse {
368                flag_name: "FLAG_A".to_string(),
369                kind: FlagUseKind::EnvVar,
370                line: 1,
371                col: 0,
372                guard_span_start: None,
373                guard_span_end: None,
374                sdk_name: None,
375            },
376            FlagUse {
377                flag_name: "FLAG_B".to_string(),
378                kind: FlagUseKind::SdkCall,
379                line: 2,
380                col: 0,
381                guard_span_start: None,
382                guard_span_end: None,
383                sdk_name: None,
384            },
385        ];
386        let module = module_with_flags(FileId(0), flag_uses);
387        let flags = collect_feature_flags(&[module], &graph);
388        assert_eq!(flags.len(), 2);
389        let names: Vec<&str> = flags.iter().map(|f| f.flag_name.as_str()).collect();
390        assert!(names.contains(&"FLAG_A"));
391        assert!(names.contains(&"FLAG_B"));
392    }
393
394    /// Guard span byte offsets are resolved to line numbers when line_offsets
395    /// are non-empty.  Source "abc\ndef\n" produces offsets [0, 4, 8].
396    /// Byte 1 is on line 1; byte 5 is on line 2.
397    #[test]
398    #[expect(deprecated, reason = "testing the deprecated public API")]
399    fn collect_feature_flags_resolves_guard_span_to_line_numbers() {
400        let source = "abc\ndef\n";
401        let line_offsets = compute_line_offsets(source);
402
403        let graph = graph_with_module(FileId(0), PathBuf::from("/project/src/guarded.ts"));
404        let flag_use = FlagUse {
405            flag_name: "GUARDED_FLAG".to_string(),
406            kind: FlagUseKind::EnvVar,
407            line: 1,
408            col: 0,
409            guard_span_start: Some(1),
410            guard_span_end: Some(5),
411            sdk_name: None,
412        };
413        let mut module = module_with_flags(FileId(0), vec![flag_use]);
414        module.line_offsets = line_offsets;
415
416        let flags = collect_feature_flags(&[module], &graph);
417        assert_eq!(flags.len(), 1);
418        let flag = &flags[0];
419        assert!(
420            flag.guard_line_start.is_some(),
421            "guard_line_start should be resolved from byte offset"
422        );
423        assert!(
424            flag.guard_line_end.is_some(),
425            "guard_line_end should be resolved from byte offset"
426        );
427    }
428
429    /// When guard span is present but line_offsets is empty, guard lines stay None.
430    #[test]
431    #[expect(deprecated, reason = "testing the deprecated public API")]
432    fn collect_feature_flags_no_guard_lines_when_offsets_empty() {
433        let graph = graph_with_module(FileId(0), PathBuf::from("/project/src/no_offsets.ts"));
434        let flag_use = FlagUse {
435            flag_name: "NO_OFFSETS_FLAG".to_string(),
436            kind: FlagUseKind::EnvVar,
437            line: 1,
438            col: 0,
439            guard_span_start: Some(10),
440            guard_span_end: Some(50),
441            sdk_name: None,
442        };
443        // Leave line_offsets empty (the default in module_with_flags).
444        let module = module_with_flags(FileId(0), vec![flag_use]);
445        let flags = collect_feature_flags(&[module], &graph);
446        assert_eq!(flags.len(), 1);
447        assert!(
448            flags[0].guard_line_start.is_none(),
449            "without line_offsets, guard lines stay None"
450        );
451        assert!(
452            flags[0].guard_line_end.is_none(),
453            "without line_offsets, guard lines stay None"
454        );
455    }
456
457    /// When guard_span_start or guard_span_end is None, guard lines stay None
458    /// even when line_offsets are present.
459    #[test]
460    #[expect(deprecated, reason = "testing the deprecated public API")]
461    fn collect_feature_flags_no_guard_lines_when_span_absent() {
462        let graph = graph_with_module(FileId(0), PathBuf::from("/project/src/no_span.ts"));
463        let flag_use = FlagUse {
464            flag_name: "NO_SPAN_FLAG".to_string(),
465            kind: FlagUseKind::SdkCall,
466            line: 5,
467            col: 0,
468            guard_span_start: None,
469            guard_span_end: None,
470            sdk_name: None,
471        };
472        let mut module = module_with_flags(FileId(0), vec![flag_use]);
473        module.line_offsets = compute_line_offsets("some\ncontent\nhere\n");
474
475        let flags = collect_feature_flags(&[module], &graph);
476        assert_eq!(flags.len(), 1);
477        assert!(flags[0].guard_line_start.is_none());
478        assert!(flags[0].guard_line_end.is_none());
479    }
480
481    // ---------------------------------------------------------------------------
482    // correlate_with_dead_code
483    // ---------------------------------------------------------------------------
484
485    /// When both unused_exports and unused_types are empty, the function returns
486    /// early without touching any flag.
487    #[test]
488    #[expect(deprecated, reason = "testing the deprecated public API")]
489    fn correlate_with_dead_code_early_return_when_results_empty() {
490        let mut flags = vec![FeatureFlag {
491            path: PathBuf::from("/project/src/a.ts"),
492            flag_name: "EARLY".to_string(),
493            kind: FlagKind::EnvironmentVariable,
494            confidence: FlagConfidence::High,
495            line: 1,
496            col: 0,
497            guard_span_start: Some(0),
498            guard_span_end: Some(100),
499            sdk_name: None,
500            guard_line_start: Some(1),
501            guard_line_end: Some(5),
502            guarded_dead_exports: Vec::new(),
503        }];
504        let results = AnalysisResults::default();
505        correlate_with_dead_code(&mut flags, &results);
506        assert!(
507            flags[0].guarded_dead_exports.is_empty(),
508            "no dead exports should be added when results are empty"
509        );
510    }
511
512    /// A flag with no guard lines (None) is skipped in the correlation loop.
513    #[test]
514    #[expect(deprecated, reason = "testing the deprecated public API")]
515    fn correlate_with_dead_code_flag_without_guard_lines_is_skipped() {
516        let path = PathBuf::from("/project/src/b.ts");
517        let mut flags = vec![FeatureFlag {
518            path: path.clone(),
519            flag_name: "NO_GUARD".to_string(),
520            kind: FlagKind::EnvironmentVariable,
521            confidence: FlagConfidence::High,
522            line: 2,
523            col: 0,
524            guard_span_start: None,
525            guard_span_end: None,
526            sdk_name: None,
527            guard_line_start: None,
528            guard_line_end: None,
529            guarded_dead_exports: Vec::new(),
530        }];
531        let mut results = AnalysisResults::default();
532        results
533            .unused_exports
534            .push(make_unused_export(path, "someExport", 5));
535
536        correlate_with_dead_code(&mut flags, &results);
537        assert!(
538            flags[0].guarded_dead_exports.is_empty(),
539            "flag without guard lines should not accumulate dead exports"
540        );
541    }
542
543    /// An unused export whose path and line fall within the flag guard span is credited.
544    #[test]
545    #[expect(deprecated, reason = "testing the deprecated public API")]
546    fn correlate_with_dead_code_unused_export_within_guard_span_is_credited() {
547        let path = PathBuf::from("/project/src/feature.ts");
548        let mut flags = vec![FeatureFlag {
549            path: path.clone(),
550            flag_name: "MY_FEATURE".to_string(),
551            kind: FlagKind::EnvironmentVariable,
552            confidence: FlagConfidence::High,
553            line: 1,
554            col: 0,
555            guard_span_start: Some(0),
556            guard_span_end: Some(200),
557            sdk_name: None,
558            guard_line_start: Some(10),
559            guard_line_end: Some(20),
560            guarded_dead_exports: Vec::new(),
561        }];
562        // Export on line 15 of the same file is within [10, 20].
563        let mut results = AnalysisResults::default();
564        results
565            .unused_exports
566            .push(make_unused_export(path, "myExport", 15));
567
568        correlate_with_dead_code(&mut flags, &results);
569        assert_eq!(flags[0].guarded_dead_exports, vec!["myExport"]);
570    }
571
572    /// An unused export on a different path is not credited.
573    #[test]
574    #[expect(deprecated, reason = "testing the deprecated public API")]
575    fn correlate_with_dead_code_export_on_different_path_not_credited() {
576        let other_path = PathBuf::from("/project/src/other.ts");
577        let mut flags = vec![FeatureFlag {
578            path: PathBuf::from("/project/src/feature.ts"),
579            flag_name: "MY_FEATURE".to_string(),
580            kind: FlagKind::EnvironmentVariable,
581            confidence: FlagConfidence::High,
582            line: 1,
583            col: 0,
584            guard_span_start: Some(0),
585            guard_span_end: Some(200),
586            sdk_name: None,
587            guard_line_start: Some(1),
588            guard_line_end: Some(50),
589            guarded_dead_exports: Vec::new(),
590        }];
591        let mut results = AnalysisResults::default();
592        results
593            .unused_exports
594            .push(make_unused_export(other_path, "wrongFile", 10));
595
596        correlate_with_dead_code(&mut flags, &results);
597        assert!(
598            flags[0].guarded_dead_exports.is_empty(),
599            "export from a different path should not be credited"
600        );
601    }
602
603    /// An unused export outside the line range is not credited.
604    #[test]
605    #[expect(deprecated, reason = "testing the deprecated public API")]
606    fn correlate_with_dead_code_export_outside_line_range_not_credited() {
607        let path = PathBuf::from("/project/src/feature.ts");
608        let mut flags = vec![FeatureFlag {
609            path: path.clone(),
610            flag_name: "MY_FEATURE".to_string(),
611            kind: FlagKind::EnvironmentVariable,
612            confidence: FlagConfidence::High,
613            line: 1,
614            col: 0,
615            guard_span_start: Some(0),
616            guard_span_end: Some(200),
617            sdk_name: None,
618            guard_line_start: Some(10),
619            guard_line_end: Some(20),
620            guarded_dead_exports: Vec::new(),
621        }];
622        let mut results = AnalysisResults::default();
623        // Line 99 is outside [10, 20].
624        results
625            .unused_exports
626            .push(make_unused_export(path, "outsideExport", 99));
627
628        correlate_with_dead_code(&mut flags, &results);
629        assert!(
630            flags[0].guarded_dead_exports.is_empty(),
631            "export outside guard line range should not be credited"
632        );
633    }
634
635    /// An unused TYPE within the guard span is credited via the unused_types path.
636    #[test]
637    #[expect(deprecated, reason = "testing the deprecated public API")]
638    fn correlate_with_dead_code_unused_type_within_guard_span_is_credited() {
639        use fallow_types::output_dead_code::UnusedTypeFinding;
640
641        let path = PathBuf::from("/project/src/types.ts");
642        let mut flags = vec![FeatureFlag {
643            path: path.clone(),
644            flag_name: "TYPE_FLAG".to_string(),
645            kind: FlagKind::SdkCall,
646            confidence: FlagConfidence::High,
647            line: 1,
648            col: 0,
649            guard_span_start: Some(0),
650            guard_span_end: Some(500),
651            sdk_name: None,
652            guard_line_start: Some(5),
653            guard_line_end: Some(30),
654            guarded_dead_exports: Vec::new(),
655        }];
656        let unused_type = UnusedTypeFinding::with_actions(UnusedExport {
657            path,
658            export_name: "MyInterface".to_string(),
659            is_type_only: true,
660            line: 10,
661            col: 0,
662            span_start: 0,
663            is_re_export: false,
664        });
665        let mut results = AnalysisResults::default();
666        results.unused_types.push(unused_type);
667
668        correlate_with_dead_code(&mut flags, &results);
669        assert_eq!(flags[0].guarded_dead_exports, vec!["MyInterface"]);
670    }
671
672    /// Both unused_exports and unused_types contribute to guarded_dead_exports.
673    #[test]
674    #[expect(deprecated, reason = "testing the deprecated public API")]
675    fn correlate_with_dead_code_combines_exports_and_types() {
676        use fallow_types::output_dead_code::UnusedTypeFinding;
677
678        let path = PathBuf::from("/project/src/combined.ts");
679        let mut flags = vec![FeatureFlag {
680            path: path.clone(),
681            flag_name: "COMBO".to_string(),
682            kind: FlagKind::EnvironmentVariable,
683            confidence: FlagConfidence::High,
684            line: 1,
685            col: 0,
686            guard_span_start: Some(0),
687            guard_span_end: Some(1000),
688            sdk_name: None,
689            guard_line_start: Some(1),
690            guard_line_end: Some(100),
691            guarded_dead_exports: Vec::new(),
692        }];
693        let mut results = AnalysisResults::default();
694        results
695            .unused_exports
696            .push(make_unused_export(path.clone(), "valueExport", 10));
697        results
698            .unused_types
699            .push(UnusedTypeFinding::with_actions(UnusedExport {
700                path,
701                export_name: "TypeExport".to_string(),
702                is_type_only: true,
703                line: 50,
704                col: 0,
705                span_start: 0,
706                is_re_export: false,
707            }));
708
709        correlate_with_dead_code(&mut flags, &results);
710        assert!(
711            flags[0]
712                .guarded_dead_exports
713                .contains(&"valueExport".to_string())
714        );
715        assert!(
716            flags[0]
717                .guarded_dead_exports
718                .contains(&"TypeExport".to_string())
719        );
720    }
721
722    /// Boundary check: export exactly at guard_line_start is credited (inclusive lower bound).
723    #[test]
724    #[expect(deprecated, reason = "testing the deprecated public API")]
725    fn correlate_with_dead_code_export_at_guard_start_is_credited() {
726        let path = PathBuf::from("/project/src/boundary.ts");
727        let mut flags = vec![FeatureFlag {
728            path: path.clone(),
729            flag_name: "BOUNDARY_FLAG".to_string(),
730            kind: FlagKind::EnvironmentVariable,
731            confidence: FlagConfidence::High,
732            line: 1,
733            col: 0,
734            guard_span_start: Some(0),
735            guard_span_end: Some(200),
736            sdk_name: None,
737            guard_line_start: Some(10),
738            guard_line_end: Some(20),
739            guarded_dead_exports: Vec::new(),
740        }];
741        let mut results = AnalysisResults::default();
742        results
743            .unused_exports
744            .push(make_unused_export(path, "atStart", 10));
745
746        correlate_with_dead_code(&mut flags, &results);
747        assert!(
748            flags[0]
749                .guarded_dead_exports
750                .contains(&"atStart".to_string()),
751            "export exactly at guard_line_start should be credited (inclusive lower bound)"
752        );
753    }
754
755    /// Boundary check: export exactly at guard_line_end is credited (inclusive upper bound).
756    #[test]
757    #[expect(deprecated, reason = "testing the deprecated public API")]
758    fn correlate_with_dead_code_export_at_guard_end_is_credited() {
759        let path = PathBuf::from("/project/src/boundary.ts");
760        let mut flags = vec![FeatureFlag {
761            path: path.clone(),
762            flag_name: "BOUNDARY_FLAG".to_string(),
763            kind: FlagKind::EnvironmentVariable,
764            confidence: FlagConfidence::High,
765            line: 1,
766            col: 0,
767            guard_span_start: Some(0),
768            guard_span_end: Some(200),
769            sdk_name: None,
770            guard_line_start: Some(10),
771            guard_line_end: Some(20),
772            guarded_dead_exports: Vec::new(),
773        }];
774        let mut results = AnalysisResults::default();
775        results
776            .unused_exports
777            .push(make_unused_export(path, "atEnd", 20));
778
779        correlate_with_dead_code(&mut flags, &results);
780        assert!(
781            flags[0].guarded_dead_exports.contains(&"atEnd".to_string()),
782            "export exactly at guard_line_end should be credited (inclusive upper bound)"
783        );
784    }
785
786    /// Multiple flags each only pick up exports in their own guard span.
787    #[test]
788    #[expect(deprecated, reason = "testing the deprecated public API")]
789    fn correlate_with_dead_code_multiple_flags_independent() {
790        let path = PathBuf::from("/project/src/multi_flag.ts");
791        let mut flags = vec![
792            FeatureFlag {
793                path: path.clone(),
794                flag_name: "FLAG_1".to_string(),
795                kind: FlagKind::EnvironmentVariable,
796                confidence: FlagConfidence::High,
797                line: 1,
798                col: 0,
799                guard_span_start: Some(0),
800                guard_span_end: Some(200),
801                sdk_name: None,
802                guard_line_start: Some(1),
803                guard_line_end: Some(10),
804                guarded_dead_exports: Vec::new(),
805            },
806            FeatureFlag {
807                path: path.clone(),
808                flag_name: "FLAG_2".to_string(),
809                kind: FlagKind::EnvironmentVariable,
810                confidence: FlagConfidence::High,
811                line: 15,
812                col: 0,
813                guard_span_start: Some(200),
814                guard_span_end: Some(400),
815                sdk_name: None,
816                guard_line_start: Some(20),
817                guard_line_end: Some(30),
818                guarded_dead_exports: Vec::new(),
819            },
820        ];
821        let mut results = AnalysisResults::default();
822        // Export at line 5 belongs to FLAG_1 guard [1..10].
823        results
824            .unused_exports
825            .push(make_unused_export(path.clone(), "exportForFlag1", 5));
826        // Export at line 25 belongs to FLAG_2 guard [20..30].
827        results
828            .unused_exports
829            .push(make_unused_export(path, "exportForFlag2", 25));
830
831        correlate_with_dead_code(&mut flags, &results);
832        assert_eq!(flags[0].guarded_dead_exports, vec!["exportForFlag1"]);
833        assert_eq!(flags[1].guarded_dead_exports, vec!["exportForFlag2"]);
834    }
835
836    // ---------------------------------------------------------------------------
837    // Original private-fn tests (preserved from before this batch)
838    // ---------------------------------------------------------------------------
839
840    #[test]
841    fn flag_use_to_feature_flag_env_var() {
842        let flag_use = FlagUse {
843            flag_name: "FEATURE_X".to_string(),
844            kind: FlagUseKind::EnvVar,
845            line: 10,
846            col: 4,
847            guard_span_start: Some(100),
848            guard_span_end: Some(200),
849            sdk_name: None,
850        };
851
852        let result = flag_use_to_feature_flag(&flag_use, PathBuf::from("src/config.ts"));
853        assert_eq!(result.flag_name, "FEATURE_X");
854        assert_eq!(result.kind, FlagKind::EnvironmentVariable);
855        assert_eq!(result.confidence, FlagConfidence::High);
856        assert_eq!(result.line, 10);
857        assert!(result.guard_span_start.is_some());
858    }
859
860    #[test]
861    fn flag_use_to_feature_flag_sdk_call() {
862        let flag_use = FlagUse {
863            flag_name: "new-checkout".to_string(),
864            kind: FlagUseKind::SdkCall,
865            line: 5,
866            col: 0,
867            guard_span_start: None,
868            guard_span_end: None,
869            sdk_name: Some("LaunchDarkly".to_string()),
870        };
871
872        let result = flag_use_to_feature_flag(&flag_use, PathBuf::from("src/hooks.ts"));
873        assert_eq!(result.kind, FlagKind::SdkCall);
874        assert_eq!(result.confidence, FlagConfidence::High);
875        assert_eq!(result.sdk_name.as_deref(), Some("LaunchDarkly"));
876    }
877
878    #[test]
879    fn flag_use_to_feature_flag_config_object() {
880        let flag_use = FlagUse {
881            flag_name: "features.newCheckout".to_string(),
882            kind: FlagUseKind::ConfigObject,
883            line: 42,
884            col: 8,
885            guard_span_start: None,
886            guard_span_end: None,
887            sdk_name: None,
888        };
889
890        let result = flag_use_to_feature_flag(&flag_use, PathBuf::from("src/app.ts"));
891        assert_eq!(result.kind, FlagKind::ConfigObject);
892        assert_eq!(result.confidence, FlagConfidence::Low);
893    }
894}