Skip to main content

engine_input_producers/
selector_usage.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Component, Path, PathBuf};
3
4use crate::{
5    EngineInputV2, RangeV2, SelectorUsageCandidateV0, SelectorUsageCandidatesV0,
6    SelectorUsageCanonicalCandidateBundleV0, SelectorUsageCanonicalProducerSignalV0,
7    SelectorUsageEditableDirectSiteV0, SelectorUsageEvaluatorCandidatePayloadV0,
8    SelectorUsageEvaluatorCandidateV0, SelectorUsageEvaluatorCandidatesV0, SelectorUsageFragmentV0,
9    SelectorUsageFragmentsV0, SelectorUsagePlanSummaryV0, SelectorUsageQueryFragmentV0,
10    SelectorUsageQueryFragmentsV0, SelectorUsageReferenceSiteV0, StyleAnalysisInputV2,
11    StyleSelectorV2, canonical_selector_count,
12    expression_domain::{
13        ExpressionDomainSelectorCertaintyFlowHedgesV0,
14        collect_expression_domain_selector_certainty_flow_hedges,
15    },
16    map_selector_certainty_projection, resolve_selector_names,
17};
18
19pub fn summarize_selector_usage_plan_input(input: &EngineInputV2) -> SelectorUsagePlanSummaryV0 {
20    let mut canonical_selector_names = Vec::new();
21    let mut view_kind_counts = BTreeMap::new();
22    let mut nested_safety_counts = BTreeMap::new();
23    let mut composed_selector_count = 0usize;
24    let mut total_composes_refs = 0usize;
25
26    for style in &input.styles {
27        for selector in &style.document.selectors {
28            *view_kind_counts
29                .entry(selector.view_kind.clone())
30                .or_insert(0) += 1;
31
32            if let Some(nested_safety) = &selector.nested_safety {
33                *nested_safety_counts
34                    .entry(nested_safety.clone())
35                    .or_insert(0) += 1;
36            }
37
38            let composes_len = selector.composes.as_ref().map_or(0, Vec::len);
39            if composes_len > 0 {
40                composed_selector_count += 1;
41                total_composes_refs += composes_len;
42            }
43
44            if selector.view_kind == "canonical"
45                && let Some(canonical_name) = &selector.canonical_name
46            {
47                canonical_selector_names.push(canonical_name.clone());
48            }
49        }
50    }
51
52    SelectorUsagePlanSummaryV0 {
53        schema_version: "0",
54        input_version: input.version.clone(),
55        canonical_selector_names,
56        view_kind_counts,
57        nested_safety_counts,
58        composed_selector_count,
59        total_composes_refs,
60    }
61}
62
63pub fn summarize_selector_usage_fragments_input(input: &EngineInputV2) -> SelectorUsageFragmentsV0 {
64    let mut fragments = Vec::new();
65
66    for style in &input.styles {
67        for (ordinal, selector) in style.document.selectors.iter().enumerate() {
68            fragments.push(SelectorUsageFragmentV0 {
69                ordinal,
70                view_kind: selector.view_kind.clone(),
71                canonical_name: selector.canonical_name.clone(),
72                nested_safety: selector.nested_safety.clone(),
73                composes_count: selector.composes.as_ref().map_or(0, Vec::len),
74            });
75        }
76    }
77
78    SelectorUsageFragmentsV0 {
79        schema_version: "0",
80        input_version: input.version.clone(),
81        fragments,
82    }
83}
84
85pub fn summarize_selector_usage_query_fragments_input(
86    input: &EngineInputV2,
87) -> SelectorUsageQueryFragmentsV0 {
88    let mut fragments = Vec::new();
89
90    for style in &input.styles {
91        for selector in &style.document.selectors {
92            if selector.view_kind != "canonical" {
93                continue;
94            }
95            let Some(canonical_name) = &selector.canonical_name else {
96                continue;
97            };
98            fragments.push(SelectorUsageQueryFragmentV0 {
99                query_id: canonical_name.clone(),
100                canonical_name: canonical_name.clone(),
101                nested_safety: selector.nested_safety.clone(),
102                composes_count: selector.composes.as_ref().map_or(0, Vec::len),
103            });
104        }
105    }
106
107    fragments.sort_by(|a, b| a.query_id.cmp(&b.query_id));
108
109    SelectorUsageQueryFragmentsV0 {
110        schema_version: "0",
111        input_version: input.version.clone(),
112        fragments,
113    }
114}
115
116#[derive(Default, Clone)]
117struct SelectorUsageAggregate {
118    total_references: usize,
119    direct_reference_count: usize,
120    editable_direct_reference_count: usize,
121    exact_reference_count: usize,
122    inferred_or_better_reference_count: usize,
123    has_expanded_references: bool,
124    all_sites: Vec<SelectorUsageReferenceSiteV0>,
125    editable_direct_sites: Vec<SelectorUsageEditableDirectSiteV0>,
126}
127
128struct SelectorUsageInputRows {
129    query_fragments: Vec<SelectorUsageQueryFragmentV0>,
130    fragments: Vec<SelectorUsageFragmentV0>,
131    candidates: Vec<SelectorUsageCandidateV0>,
132    evaluator_candidates: Vec<SelectorUsageEvaluatorCandidateV0>,
133}
134
135pub fn summarize_selector_usage_candidates_input(
136    input: &EngineInputV2,
137) -> SelectorUsageCandidatesV0 {
138    let rows = collect_selector_usage_input_rows(input);
139
140    SelectorUsageCandidatesV0 {
141        schema_version: "0",
142        input_version: input.version.clone(),
143        candidates: rows.candidates,
144    }
145}
146
147pub fn summarize_selector_usage_evaluator_candidates_input(
148    input: &EngineInputV2,
149) -> SelectorUsageEvaluatorCandidatesV0 {
150    let rows = collect_selector_usage_input_rows(input);
151
152    SelectorUsageEvaluatorCandidatesV0 {
153        schema_version: "0",
154        input_version: input.version.clone(),
155        results: rows.evaluator_candidates,
156    }
157}
158
159pub fn summarize_selector_usage_canonical_candidate_bundle_input(
160    input: &EngineInputV2,
161) -> SelectorUsageCanonicalCandidateBundleV0 {
162    let rows = collect_selector_usage_input_rows(input);
163
164    SelectorUsageCanonicalCandidateBundleV0 {
165        schema_version: "0",
166        input_version: input.version.clone(),
167        query_fragments: rows.query_fragments,
168        fragments: rows.fragments,
169        candidates: rows.candidates,
170    }
171}
172
173pub fn summarize_selector_usage_canonical_producer_signal_input(
174    input: &EngineInputV2,
175) -> SelectorUsageCanonicalProducerSignalV0 {
176    let rows = collect_selector_usage_input_rows(input);
177    let input_version = input.version.clone();
178
179    SelectorUsageCanonicalProducerSignalV0 {
180        schema_version: "0",
181        input_version: input_version.clone(),
182        canonical_bundle: SelectorUsageCanonicalCandidateBundleV0 {
183            schema_version: "0",
184            input_version: input_version.clone(),
185            query_fragments: rows.query_fragments.clone(),
186            fragments: rows.fragments.clone(),
187            candidates: rows.candidates.clone(),
188        },
189        evaluator_candidates: SelectorUsageEvaluatorCandidatesV0 {
190            schema_version: "0",
191            input_version,
192            results: rows.evaluator_candidates,
193        },
194    }
195}
196
197fn collect_selector_usage_input_rows(input: &EngineInputV2) -> SelectorUsageInputRows {
198    let selector_certainty_flow_hedges =
199        collect_expression_domain_selector_certainty_flow_hedges(input);
200    collect_selector_usage_input_rows_with_flow_hedges(input, &selector_certainty_flow_hedges)
201}
202
203fn collect_selector_usage_input_rows_with_flow_hedges(
204    input: &EngineInputV2,
205    selector_certainty_flow_hedges: &ExpressionDomainSelectorCertaintyFlowHedgesV0,
206) -> SelectorUsageInputRows {
207    let mut expression_index = BTreeMap::new();
208    let mut style_index = BTreeMap::new();
209    let mut canonical_by_file = BTreeMap::<String, BTreeSet<String>>::new();
210
211    for source in &input.sources {
212        for expression in &source.document.class_expressions {
213            expression_index.insert(expression.id.clone(), expression);
214        }
215    }
216
217    for style in &input.styles {
218        style_index.insert(style.file_path.clone(), style);
219        let names = canonical_by_file
220            .entry(style.file_path.clone())
221            .or_default();
222        for selector in &style.document.selectors {
223            if selector.view_kind != "canonical" {
224                continue;
225            }
226            if let Some(canonical_name) = &selector.canonical_name {
227                names.insert(canonical_name.clone());
228            }
229        }
230    }
231
232    let mut source_counts = BTreeMap::<(String, String), SelectorUsageAggregate>::new();
233
234    for entry in &input.type_facts {
235        let Some(expression) = expression_index.get(&entry.expression_id) else {
236            continue;
237        };
238        let Some(style) = style_index.get(&expression.scss_module_path) else {
239            continue;
240        };
241
242        let selector_names = resolve_selector_names(style, &entry.facts);
243        if selector_names.is_empty() {
244            continue;
245        }
246
247        let selector_certainty = map_selector_certainty_projection(
248            &entry.facts,
249            selector_names.len(),
250            canonical_selector_count(style),
251            selector_certainty_flow_hedges
252                .get(&(entry.file_path.clone(), entry.expression_id.clone())),
253        )
254        .certainty;
255        let is_direct_source = matches!(expression.kind.as_str(), "literal" | "styleAccess");
256
257        for selector_name in selector_names {
258            let counts = source_counts
259                .entry((expression.scss_module_path.clone(), selector_name))
260                .or_default();
261            counts.total_references += 1;
262            push_usage_site(
263                &mut counts.all_sites,
264                SelectorUsageReferenceSiteV0 {
265                    file_path: entry.file_path.clone(),
266                    range: expression.range.clone(),
267                    expansion: if is_direct_source {
268                        "direct".to_string()
269                    } else {
270                        "expanded".to_string()
271                    },
272                    reference_kind: "source".to_string(),
273                },
274            );
275            if is_direct_source {
276                counts.direct_reference_count += 1;
277                counts.editable_direct_reference_count += 1;
278                if let Some(class_name) = &expression.class_name {
279                    push_usage_editable_direct_site(
280                        &mut counts.editable_direct_sites,
281                        SelectorUsageEditableDirectSiteV0 {
282                            file_path: entry.file_path.clone(),
283                            range: expression.range.clone(),
284                            class_name: class_name.clone(),
285                        },
286                    );
287                }
288            } else {
289                counts.has_expanded_references = true;
290            }
291            match selector_certainty.as_str() {
292                "exact" => {
293                    counts.exact_reference_count += 1;
294                    counts.inferred_or_better_reference_count += 1;
295                }
296                "inferred" => {
297                    counts.inferred_or_better_reference_count += 1;
298                }
299                _ => {}
300            }
301        }
302    }
303
304    let incoming_style_dependencies = build_incoming_style_dependencies(input, &canonical_by_file);
305
306    let fragments = summarize_selector_usage_fragments_input(input).fragments;
307    let mut query_fragments = summarize_selector_usage_query_fragments_input(input).fragments;
308    query_fragments.sort_by(|a, b| a.query_id.cmp(&b.query_id));
309
310    let mut candidates = Vec::new();
311    let mut evaluator_candidates = Vec::new();
312
313    for style in &input.styles {
314        for selector in &style.document.selectors {
315            if selector.view_kind != "canonical" {
316                continue;
317            }
318            let Some(canonical_name) = &selector.canonical_name else {
319                continue;
320            };
321
322            let mut counts = source_counts
323                .remove(&(style.file_path.clone(), canonical_name.clone()))
324                .unwrap_or_default();
325            let style_dependency_sites = collect_incoming_style_dependency_sites(
326                &incoming_style_dependencies,
327                &style_index,
328                &style.file_path,
329                canonical_name,
330            );
331            let style_dependency_count = style_dependency_sites.len();
332            for site in style_dependency_sites {
333                push_usage_site(&mut counts.all_sites, site);
334            }
335
336            counts.total_references += style_dependency_count;
337            counts.direct_reference_count += style_dependency_count;
338            counts.exact_reference_count += style_dependency_count;
339            counts.inferred_or_better_reference_count += style_dependency_count;
340
341            let has_style_dependency_references = style_dependency_count > 0;
342            let has_any_references = counts.total_references > 0;
343
344            let candidate = SelectorUsageCandidateV0 {
345                query_id: canonical_name.clone(),
346                canonical_name: canonical_name.clone(),
347                file_path: style.file_path.clone(),
348                total_references: counts.total_references,
349                direct_reference_count: counts.direct_reference_count,
350                editable_direct_reference_count: counts.editable_direct_reference_count,
351                exact_reference_count: counts.exact_reference_count,
352                inferred_or_better_reference_count: counts.inferred_or_better_reference_count,
353                has_expanded_references: counts.has_expanded_references,
354                has_style_dependency_references,
355                has_any_references,
356            };
357
358            candidates.push(candidate.clone());
359            evaluator_candidates.push(SelectorUsageEvaluatorCandidateV0 {
360                kind: "selector-usage",
361                file_path: style.file_path.clone(),
362                query_id: canonical_name.clone(),
363                payload: SelectorUsageEvaluatorCandidatePayloadV0 {
364                    canonical_name: canonical_name.clone(),
365                    total_references: candidate.total_references,
366                    direct_reference_count: candidate.direct_reference_count,
367                    editable_direct_reference_count: candidate.editable_direct_reference_count,
368                    exact_reference_count: candidate.exact_reference_count,
369                    inferred_or_better_reference_count: candidate
370                        .inferred_or_better_reference_count,
371                    has_expanded_references: candidate.has_expanded_references,
372                    has_style_dependency_references: candidate.has_style_dependency_references,
373                    has_any_references: candidate.has_any_references,
374                    all_sites: counts.all_sites.clone(),
375                    editable_direct_sites: counts.editable_direct_sites.clone(),
376                },
377            });
378        }
379    }
380
381    candidates.sort_by(|a, b| {
382        a.file_path
383            .cmp(&b.file_path)
384            .then(a.query_id.cmp(&b.query_id))
385    });
386    evaluator_candidates.sort_by(|a, b| {
387        a.file_path
388            .cmp(&b.file_path)
389            .then(a.query_id.cmp(&b.query_id))
390    });
391
392    SelectorUsageInputRows {
393        query_fragments,
394        fragments,
395        candidates,
396        evaluator_candidates,
397    }
398}
399
400fn build_incoming_style_dependencies(
401    input: &EngineInputV2,
402    canonical_by_file: &BTreeMap<String, BTreeSet<String>>,
403) -> BTreeMap<(String, String), BTreeSet<(String, String)>> {
404    let mut incoming = BTreeMap::<(String, String), BTreeSet<(String, String)>>::new();
405
406    for style in &input.styles {
407        for selector in &style.document.selectors {
408            if selector.view_kind != "canonical" {
409                continue;
410            }
411            let Some(incoming_canonical_name) = &selector.canonical_name else {
412                continue;
413            };
414            let Some(composes) = &selector.composes else {
415                continue;
416            };
417
418            for compose in composes {
419                let Some(class_names) = compose
420                    .get("classNames")
421                    .and_then(|value| value.as_array())
422                    .map(|values| {
423                        values
424                            .iter()
425                            .filter_map(|value| value.as_str().map(ToString::to_string))
426                            .collect::<Vec<_>>()
427                    })
428                else {
429                    continue;
430                };
431                if class_names.is_empty() {
432                    continue;
433                }
434                if compose
435                    .get("fromGlobal")
436                    .and_then(|value| value.as_bool())
437                    .unwrap_or(false)
438                {
439                    continue;
440                }
441
442                let target_file = compose
443                    .get("from")
444                    .and_then(|value| value.as_str())
445                    .map(|from| normalize_joined_path(&style.file_path, from))
446                    .unwrap_or_else(|| style.file_path.clone());
447
448                let Some(target_names) = canonical_by_file.get(&target_file) else {
449                    continue;
450                };
451
452                for class_name in class_names {
453                    if !target_names.contains(&class_name) {
454                        continue;
455                    }
456                    incoming
457                        .entry((target_file.clone(), class_name))
458                        .or_default()
459                        .insert((style.file_path.clone(), incoming_canonical_name.clone()));
460                }
461            }
462        }
463    }
464
465    incoming
466}
467
468fn collect_incoming_style_dependency_sites(
469    incoming: &BTreeMap<(String, String), BTreeSet<(String, String)>>,
470    style_index: &BTreeMap<String, &StyleAnalysisInputV2>,
471    file_path: &str,
472    canonical_name: &str,
473) -> Vec<SelectorUsageReferenceSiteV0> {
474    let mut seen = BTreeSet::<(String, String)>::new();
475    let mut sites = Vec::<SelectorUsageReferenceSiteV0>::new();
476    collect_incoming_style_dependencies(
477        incoming,
478        style_index,
479        &(file_path.to_string(), canonical_name.to_string()),
480        &mut seen,
481        &mut sites,
482    );
483    sites.sort_by(|a, b| {
484        a.file_path
485            .cmp(&b.file_path)
486            .then(a.range.start.line.cmp(&b.range.start.line))
487            .then(a.range.start.character.cmp(&b.range.start.character))
488            .then(a.range.end.line.cmp(&b.range.end.line))
489            .then(a.range.end.character.cmp(&b.range.end.character))
490            .then(a.reference_kind.cmp(&b.reference_kind))
491            .then(a.expansion.cmp(&b.expansion))
492    });
493    sites.dedup();
494    sites
495}
496
497fn collect_incoming_style_dependencies(
498    incoming: &BTreeMap<(String, String), BTreeSet<(String, String)>>,
499    style_index: &BTreeMap<String, &StyleAnalysisInputV2>,
500    key: &(String, String),
501    seen: &mut BTreeSet<(String, String)>,
502    sites: &mut Vec<SelectorUsageReferenceSiteV0>,
503) {
504    let Some(entries) = incoming.get(key) else {
505        return;
506    };
507    for entry in entries {
508        if seen.insert(entry.clone()) {
509            if let Some(style) = style_index.get(&entry.0)
510                && let Some(selector) = find_canonical_selector(style, &entry.1)
511            {
512                sites.push(SelectorUsageReferenceSiteV0 {
513                    file_path: entry.0.clone(),
514                    range: selector_site_range(selector),
515                    expansion: "direct".to_string(),
516                    reference_kind: "styleDependency".to_string(),
517                });
518            }
519            collect_incoming_style_dependencies(incoming, style_index, entry, seen, sites);
520        }
521    }
522}
523
524fn push_usage_site(
525    sites: &mut Vec<SelectorUsageReferenceSiteV0>,
526    site: SelectorUsageReferenceSiteV0,
527) {
528    if !sites.iter().any(|existing| existing == &site) {
529        sites.push(site);
530    }
531}
532
533fn push_usage_editable_direct_site(
534    sites: &mut Vec<SelectorUsageEditableDirectSiteV0>,
535    site: SelectorUsageEditableDirectSiteV0,
536) {
537    if !sites.iter().any(|existing| existing == &site) {
538        sites.push(site);
539    }
540}
541
542fn find_canonical_selector<'a>(
543    style: &'a StyleAnalysisInputV2,
544    canonical_name: &str,
545) -> Option<&'a StyleSelectorV2> {
546    style.document.selectors.iter().find(|selector| {
547        selector.view_kind == "canonical"
548            && selector.canonical_name.as_deref() == Some(canonical_name)
549    })
550}
551
552fn selector_site_range(selector: &StyleSelectorV2) -> RangeV2 {
553    selector
554        .bem_suffix
555        .as_ref()
556        .map(|suffix| suffix.raw_token_range.clone())
557        .unwrap_or_else(|| selector.range.clone())
558}
559
560fn normalize_joined_path(base_file_path: &str, relative_from: &str) -> String {
561    let base_dir = Path::new(base_file_path)
562        .parent()
563        .map(Path::to_path_buf)
564        .unwrap_or_default();
565    let joined = base_dir.join(relative_from);
566    let mut normalized = PathBuf::new();
567
568    for component in joined.components() {
569        match component {
570            Component::CurDir => {}
571            Component::ParentDir => {
572                normalized.pop();
573            }
574            other => normalized.push(other.as_os_str()),
575        }
576    }
577
578    normalized.to_string_lossy().into_owned()
579}
580
581#[cfg(test)]
582mod tests {
583    use super::{
584        summarize_selector_usage_candidates_input,
585        summarize_selector_usage_canonical_candidate_bundle_input,
586        summarize_selector_usage_canonical_producer_signal_input,
587        summarize_selector_usage_evaluator_candidates_input,
588        summarize_selector_usage_fragments_input, summarize_selector_usage_plan_input,
589        summarize_selector_usage_query_fragments_input,
590    };
591    use crate::{configure_nonconvergent_selector_certainty_fixture, test_support::sample_input};
592    use serde_json::json;
593
594    #[test]
595    fn summarizes_selector_usage_universe() {
596        let summary = summarize_selector_usage_plan_input(&sample_input());
597
598        assert_eq!(
599            summary.canonical_selector_names,
600            vec!["btn-active".to_string(), "card-header".to_string()]
601        );
602        assert_eq!(summary.view_kind_counts.get("canonical"), Some(&2));
603        assert_eq!(summary.view_kind_counts.get("nested"), Some(&1));
604        assert_eq!(summary.nested_safety_counts.get("safe"), Some(&1));
605        assert_eq!(summary.nested_safety_counts.get("unsafe"), Some(&1));
606        assert_eq!(summary.nested_safety_counts.get("unknown"), Some(&1));
607        assert_eq!(summary.composed_selector_count, 2);
608        assert_eq!(summary.total_composes_refs, 3);
609    }
610
611    #[test]
612    fn summarizes_selector_usage_fragments() {
613        let summary = summarize_selector_usage_fragments_input(&sample_input());
614
615        assert_eq!(summary.fragments.len(), 3);
616        assert_eq!(summary.fragments[0].ordinal, 0);
617        assert_eq!(summary.fragments[0].view_kind, "canonical");
618        assert_eq!(
619            summary.fragments[0].canonical_name.as_deref(),
620            Some("btn-active")
621        );
622        assert_eq!(summary.fragments[0].nested_safety.as_deref(), Some("safe"));
623        assert_eq!(summary.fragments[0].composes_count, 1);
624
625        assert_eq!(summary.fragments[2].ordinal, 1);
626        assert_eq!(summary.fragments[2].view_kind, "nested");
627        assert_eq!(
628            summary.fragments[2].canonical_name.as_deref(),
629            Some("card-header")
630        );
631        assert_eq!(
632            summary.fragments[2].nested_safety.as_deref(),
633            Some("unknown")
634        );
635        assert_eq!(summary.fragments[2].composes_count, 2);
636    }
637
638    #[test]
639    fn summarizes_selector_usage_query_fragments() {
640        let summary = summarize_selector_usage_query_fragments_input(&sample_input());
641
642        assert_eq!(summary.fragments.len(), 2);
643        assert_eq!(summary.fragments[0].query_id, "btn-active");
644        assert_eq!(summary.fragments[0].canonical_name, "btn-active");
645        assert_eq!(summary.fragments[0].nested_safety.as_deref(), Some("safe"));
646        assert_eq!(summary.fragments[0].composes_count, 1);
647
648        assert_eq!(summary.fragments[1].query_id, "card-header");
649        assert_eq!(summary.fragments[1].canonical_name, "card-header");
650        assert_eq!(
651            summary.fragments[1].nested_safety.as_deref(),
652            Some("unsafe")
653        );
654        assert_eq!(summary.fragments[1].composes_count, 0);
655    }
656
657    #[test]
658    fn summarizes_selector_usage_candidates() {
659        let mut input = sample_input();
660        input.styles[0].document.selectors[0].composes = Some(vec![json!({
661            "classNames": ["card-header"],
662            "from": "./Card.module.scss"
663        })]);
664
665        let summary = summarize_selector_usage_candidates_input(&input);
666
667        assert_eq!(summary.candidates.len(), 2);
668        let app = &summary.candidates[0];
669        assert_eq!(app.file_path, "/tmp/App.module.scss");
670        assert_eq!(app.query_id, "btn-active");
671        assert_eq!(app.total_references, 1);
672        assert_eq!(app.direct_reference_count, 0);
673        assert_eq!(app.editable_direct_reference_count, 0);
674        assert_eq!(app.exact_reference_count, 0);
675        assert_eq!(app.inferred_or_better_reference_count, 1);
676        assert!(app.has_expanded_references);
677        assert!(!app.has_style_dependency_references);
678        assert!(app.has_any_references);
679
680        let card = &summary.candidates[1];
681        assert_eq!(card.file_path, "/tmp/Card.module.scss");
682        assert_eq!(card.query_id, "card-header");
683        assert_eq!(card.total_references, 2);
684        assert_eq!(card.direct_reference_count, 2);
685        assert_eq!(card.editable_direct_reference_count, 1);
686        assert_eq!(card.exact_reference_count, 1);
687        assert_eq!(card.inferred_or_better_reference_count, 2);
688        assert!(!card.has_expanded_references);
689        assert!(card.has_style_dependency_references);
690        assert!(card.has_any_references);
691    }
692
693    #[test]
694    fn nonconverged_flow_hedge_demotes_selector_usage_aggregates() {
695        let mut input = sample_input();
696        input.styles[0].document.selectors[0].name = "x".to_string();
697        input.styles[0].document.selectors[0].canonical_name = Some("x".to_string());
698        configure_nonconvergent_selector_certainty_fixture(&mut input.type_facts[0], "x");
699        let product = summarize_selector_usage_canonical_producer_signal_input(&input);
700        let candidate = &product.canonical_bundle.candidates[0];
701
702        assert_eq!(candidate.query_id, "x");
703        assert_eq!(candidate.total_references, 1);
704        assert_eq!(candidate.exact_reference_count, 0);
705        assert_eq!(candidate.inferred_or_better_reference_count, 0);
706        assert_eq!(
707            product
708                .evaluator_candidates
709                .results
710                .iter()
711                .find(|entry| entry.query_id == "x")
712                .map(|entry| entry.payload.inferred_or_better_reference_count),
713            Some(0)
714        );
715    }
716
717    #[test]
718    fn summarizes_selector_usage_evaluator_candidates() {
719        let summary = summarize_selector_usage_evaluator_candidates_input(&sample_input());
720
721        assert_eq!(summary.results.len(), 2);
722        assert_eq!(summary.results[0].kind, "selector-usage");
723        assert_eq!(summary.results[0].file_path, "/tmp/App.module.scss");
724        assert_eq!(summary.results[0].query_id, "btn-active");
725        assert_eq!(summary.results[0].payload.all_sites.len(), 1);
726        assert_eq!(
727            summary.results[0].payload.all_sites[0].file_path,
728            "/tmp/App.tsx"
729        );
730        assert_eq!(
731            summary.results[0].payload.all_sites[0].expansion,
732            "expanded"
733        );
734        assert_eq!(
735            summary.results[0].payload.all_sites[0].reference_kind,
736            "source"
737        );
738        assert!(summary.results[0].payload.editable_direct_sites.is_empty());
739        assert_eq!(summary.results[1].payload.editable_direct_sites.len(), 1);
740        assert_eq!(
741            summary.results[1].payload.editable_direct_sites[0].file_path,
742            "/tmp/Card.tsx"
743        );
744        assert_eq!(
745            summary.results[1].payload.editable_direct_sites[0].class_name,
746            "card-header"
747        );
748    }
749
750    #[test]
751    fn summarizes_selector_usage_canonical_candidate_bundle() {
752        let summary = summarize_selector_usage_canonical_candidate_bundle_input(&sample_input());
753
754        assert_eq!(summary.query_fragments.len(), 2);
755        assert_eq!(summary.fragments.len(), 3);
756        assert_eq!(summary.candidates.len(), 2);
757    }
758
759    #[test]
760    fn summarizes_selector_usage_canonical_producer_signal() {
761        let summary = summarize_selector_usage_canonical_producer_signal_input(&sample_input());
762
763        assert_eq!(summary.canonical_bundle.candidates.len(), 2);
764        assert_eq!(summary.evaluator_candidates.results.len(), 2);
765        assert_eq!(
766            summary.evaluator_candidates.results[0].query_id,
767            "btn-active"
768        );
769    }
770}