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