Skip to main content

omena_query/style/
diagnostics.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use omena_parser::{
4    ParsedAnimationFactKind, ParsedCssModuleComposesEdgeKind, ParsedExtendTargetFactKind,
5    ParsedSassModuleEdgeFact, ParsedSassModuleEdgeFactKind, ParsedSelectorFactKind,
6    ParsedVariableFactKind,
7};
8use omena_query_checker_orchestrator::{
9    ModuleGraphEdgeV0, ModuleGraphV0, OutcomeMode, REPLICA_ENSEMBLE_FEATURE_GATE_V0,
10    REPLICA_ENSEMBLE_LAYER_MARKER_V0, REPLICA_ENSEMBLE_SCHEMA_VERSION_V0, ReplicaSnapshotV0,
11    ReportOptionsV0, ReportRecommendation, build_cross_file_inconsistency_report,
12};
13use omena_query_checker_orchestrator::{
14    OmenaCheckerReplicaEnsembleInputV0, OmenaCheckerReplicaEnsembleReportInputV0,
15    run_omena_query_checker_replica_ensemble_gate_v0,
16};
17
18use super::cascade_checker::collect_query_replica_ensemble_site_outcomes;
19use super::cascade_checker::summarize_query_cascade_checker_diagnostics_with_deep_analysis;
20use super::diagnostic_suppressions::OmenaStrictnessLevelV0;
21use super::diagnostic_suppressions::apply_omena_query_style_diagnostic_suppressions;
22use super::diagnostic_suppressions::parse_omena_query_style_strictness_level;
23use super::parser_facade::collect_omena_query_omena_parser_style_facts_raw;
24use super::*;
25
26const LSP_DIAGNOSTIC_TAG_UNNECESSARY: u8 = 1;
27const LSP_DIAGNOSTIC_TAG_DEPRECATED: u8 = 2;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum OmenaQueryExternalModuleModeV0 {
31    Ignored,
32    Sif,
33}
34
35pub fn summarize_omena_query_missing_custom_property_diagnostics(
36    style_uri: &str,
37    source: &str,
38    candidates: &[OmenaQueryStyleHoverCandidateV0],
39) -> Vec<OmenaQueryStyleDiagnosticV0> {
40    let declaration_names = candidates
41        .iter()
42        .filter(|candidate| candidate.kind == "customPropertyDeclaration")
43        .map(|candidate| candidate.name.as_str())
44        .collect::<BTreeSet<_>>();
45    if declaration_names.is_empty() {
46        return Vec::new();
47    }
48
49    // `var(--x, fallback)` references cannot be "missing" in any observable way — the
50    // fallback guarantees a value — so suppress the lint per-reference. The fallback fact
51    // range and the candidate range both derive from the same parser byte span via
52    // `parser_range_for_byte_span`, so matching on the rendered range scopes the suppression
53    // to the exact `var()` argument (a nested fallback-less `var(--b)` in
54    // `var(--a, var(--b))` stays a live candidate).
55    let dialect = omena_parser_dialect_for_style_path(style_uri);
56    let facts = collect_omena_query_omena_parser_style_facts_raw(source, dialect);
57    let fallback_ranges = facts
58        .variables
59        .iter()
60        .filter(|fact| {
61            fact.kind == ParsedVariableFactKind::CustomPropertyReference && fact.has_fallback
62        })
63        .map(|fact| {
64            let byte_span = ParserByteSpanV0 {
65                start: u32::from(fact.range.start()) as usize,
66                end: u32::from(fact.range.end()) as usize,
67            };
68            (
69                fact.name.clone(),
70                parser_range_for_byte_span(source, byte_span),
71            )
72        })
73        .collect::<BTreeSet<_>>();
74
75    let insertion_range = end_of_source_range(source);
76    candidates
77        .iter()
78        .filter(|candidate| {
79            candidate.kind == "customPropertyReference"
80                && !declaration_names.contains(candidate.name.as_str())
81                && !fallback_ranges.contains(&(candidate.name.clone(), candidate.range))
82        })
83        .map(|candidate| OmenaQueryStyleDiagnosticV0 {
84            code: "missingCustomProperty",
85            severity: "warning",
86            provenance: vec![
87                "omena-parser.custom-property-facts",
88                "omena-query.style-diagnostics",
89            ],
90            range: candidate.range,
91            message: format!(
92                "CSS custom property '{}' not found in indexed style tokens.",
93                candidate.name
94            ),
95            tags: Vec::new(),
96            create_custom_property: Some(OmenaQueryCreateCustomPropertyActionV0 {
97                uri: style_uri.to_string(),
98                range: insertion_range,
99                new_text: format!("\n\n:root {{\n  {}: ;\n}}\n", candidate.name),
100                property_name: candidate.name.clone(),
101            }),
102        })
103        .collect()
104}
105
106pub fn summarize_omena_query_cascade_aware_style_diagnostics(
107    style_uri: &str,
108    source: &str,
109    candidates: &[OmenaQueryStyleHoverCandidateV0],
110) -> Vec<OmenaQueryStyleDiagnosticV0> {
111    summarize_omena_query_cascade_aware_style_diagnostics_with_deep_analysis(
112        style_uri, source, candidates, false,
113    )
114}
115
116/// Cascade-aware diagnostics with an explicit opt-in deep-analysis switch. With
117/// `deep_analysis == false` (the default surface) only the product cascade gate
118/// diagnostics are emitted; `deep_analysis == true` additionally surfaces the
119/// rg-flow / categorical theory hints, deduplicated against `circularVar`.
120pub fn summarize_omena_query_cascade_aware_style_diagnostics_with_deep_analysis(
121    style_uri: &str,
122    source: &str,
123    candidates: &[OmenaQueryStyleHoverCandidateV0],
124    deep_analysis: bool,
125) -> Vec<OmenaQueryStyleDiagnosticV0> {
126    let declarations_by_name = candidates
127        .iter()
128        .filter(|candidate| candidate.kind == "customPropertyDeclaration")
129        .map(|candidate| (candidate.name.as_str(), candidate.range))
130        .collect::<BTreeMap<_, _>>();
131
132    let dialect = omena_parser_dialect_for_style_path(style_uri);
133    let mut diagnostics =
134        summarize_static_css_custom_property_fixed_point_from_source(source, dialect)
135            .entries
136            .into_iter()
137            .filter(|entry| entry.guaranteed_invalid)
138            .filter_map(|entry| {
139                declarations_by_name
140                    .get(entry.name.as_str())
141                    .copied()
142                    .map(|range| OmenaQueryStyleDiagnosticV0 {
143                        code: "guaranteedInvalidCustomProperty",
144                        severity: "warning",
145                        provenance: vec![
146                            "omena-transform-passes.custom-property-lfp",
147                            "omena-query.cascade-aware-diagnostics",
148                        ],
149                        range,
150                        message: format!(
151                            "CSS custom property '{}' resolves to the guaranteed-invalid value.",
152                            entry.name
153                        ),
154                        tags: Vec::new(),
155                        create_custom_property: None,
156                    })
157            })
158            .collect::<Vec<_>>();
159
160    diagnostics.extend(
161        summarize_query_cascade_checker_diagnostics_with_deep_analysis(
162            style_uri,
163            source,
164            deep_analysis,
165        ),
166    );
167
168    diagnostics
169}
170
171pub fn summarize_omena_query_missing_keyframes_diagnostics(
172    style_uri: &str,
173    source: &str,
174) -> Vec<OmenaQueryStyleDiagnosticV0> {
175    let dialect = omena_parser_dialect_for_style_path(style_uri);
176    let facts = collect_omena_query_omena_parser_style_facts_raw(source, dialect);
177    let declared_keyframes = facts
178        .animations
179        .iter()
180        .filter(|animation| animation.kind == ParsedAnimationFactKind::KeyframesDeclaration)
181        .map(|animation| animation.name.clone())
182        .collect::<BTreeSet<_>>();
183    let mut emitted = BTreeSet::new();
184
185    facts
186        .animations
187        .into_iter()
188        .filter(|animation| animation.kind == ParsedAnimationFactKind::AnimationNameReference)
189        .filter(|animation| !declared_keyframes.contains(animation.name.as_str()))
190        .filter_map(|animation| {
191            let start: u32 = animation.range.start().into();
192            let end: u32 = animation.range.end().into();
193            let byte_span = ParserByteSpanV0 {
194                start: start as usize,
195                end: end as usize,
196            };
197            if !emitted.insert((animation.name.clone(), byte_span.start, byte_span.end)) {
198                return None;
199            }
200            Some((animation, parser_range_for_byte_span(source, byte_span)))
201        })
202        .map(|(animation, range)| OmenaQueryStyleDiagnosticV0 {
203            code: "missingKeyframes",
204            severity: "warning",
205            provenance: vec![
206                "omena-parser.animation-facts",
207                "omena-query.style-diagnostics",
208            ],
209            range,
210            message: format!("@keyframes '{}' not found in this file.", animation.name),
211            tags: Vec::new(),
212            create_custom_property: None,
213        })
214        .collect()
215}
216
217pub fn summarize_omena_query_missing_sass_symbol_diagnostics(
218    style_uri: &str,
219    source: &str,
220) -> Vec<OmenaQueryStyleDiagnosticV0> {
221    let dialect = omena_parser_dialect_for_style_path(style_uri);
222    let facts = collect_omena_query_omena_parser_style_facts_raw(source, dialect);
223    let mut declarations = BTreeSet::<SassSymbolKey>::new();
224    let mut emitted = BTreeSet::new();
225    let mut diagnostics = Vec::new();
226
227    for symbol in facts.sass_symbols {
228        // Route the single-file key tuple through the `sass_symbol_key` chokepoint so the
229        // hyphen/underscore fold (Sass treats `$a-b` and `$a_b` as the same identifier) is
230        // applied here too, not only on the cross-file/workspace path. (#48)
231        let key = sass_symbol_key(
232            symbol.symbol_kind,
233            symbol.namespace.clone(),
234            symbol.name.clone(),
235        );
236        if omena_query_sass_symbol_fact_kind_is_declaration(symbol.kind) {
237            declarations.insert(key);
238            continue;
239        }
240        if !omena_query_sass_symbol_fact_kind_is_reference(symbol.kind) {
241            continue;
242        }
243        if declarations.contains(&key) {
244            continue;
245        }
246        if is_omena_query_sass_builtin_symbol_reference_resolved(
247            &facts.sass_module_edges,
248            symbol.symbol_kind,
249            symbol.namespace.as_deref(),
250            symbol.name.as_str(),
251        ) {
252            continue;
253        }
254
255        let start: u32 = symbol.range.start().into();
256        let end: u32 = symbol.range.end().into();
257        let byte_span = ParserByteSpanV0 {
258            start: start as usize,
259            end: end as usize,
260        };
261        if !emitted.insert((
262            symbol.symbol_kind,
263            symbol.namespace.clone(),
264            symbol.name.clone(),
265            byte_span.start,
266            byte_span.end,
267        )) {
268            continue;
269        }
270        diagnostics.push(OmenaQueryStyleDiagnosticV0 {
271            code: "missingSassSymbol",
272            severity: "warning",
273            provenance: vec![
274                "omena-parser.sass-symbol-facts",
275                "omena-query.style-diagnostics",
276            ],
277            range: parser_range_for_byte_span(source, byte_span),
278            message: format!(
279                "{} not found in this file.",
280                format_query_sass_symbol_label(symbol.symbol_kind, symbol.name.as_str())
281            ),
282            tags: Vec::new(),
283            create_custom_property: None,
284        });
285    }
286
287    diagnostics
288}
289
290/// RFC-0007-E1 (#45): `@extend` target validation. dart-sass hard-errors on `@extend %nonexistent`
291/// / `@extend .missing` (`"%nonexistent" does not exist`); omena was silent because the
292/// `ScssExtendRule` target was parsed and discarded. The parser now captures each target as a
293/// `ParsedExtendTargetFact` (kind + name + `!optional` flag + range); this rule mirrors
294/// `missingSassSymbol`'s file-local structure: an `@extend` target that does not resolve to a
295/// declared placeholder/class **in this file** is flagged.
296///
297/// Scope and non-over-correction:
298/// - `!optional` targets are NEVER flagged — dart-sass permits a missing optional extend, and
299///   omena already (correctly) emitted nothing for them, so the flag is honored here.
300/// - A placeholder target is checked only against declared placeholders; a class target only
301///   against declared classes (Sass keeps the two namespaces distinct).
302/// - This is file-local (single-file surface), like the `missingSassSymbol` companion. A target
303///   declared in another file reachable via `@use`/`@forward`/`@import` is NOT yet validated here,
304///   so cross-file `@extend` resolution is out of scope for v0 (recorded as remaining) — that keeps
305///   the rule from inventing a false positive for a placeholder defined in an imported partial.
306pub fn summarize_omena_query_missing_extend_target_diagnostics(
307    style_uri: &str,
308    source: &str,
309) -> Vec<OmenaQueryStyleDiagnosticV0> {
310    let dialect = omena_parser_dialect_for_style_path(style_uri);
311    if !matches!(
312        dialect,
313        OmenaParserStyleDialect::Scss | OmenaParserStyleDialect::Sass
314    ) {
315        return Vec::new();
316    }
317
318    let facts = collect_omena_query_omena_parser_style_facts_raw(source, dialect);
319    if facts.extend_targets.is_empty() {
320        return Vec::new();
321    }
322
323    let mut declared_placeholders = BTreeSet::new();
324    let mut declared_classes = BTreeSet::new();
325    for selector in &facts.selectors {
326        match selector.kind {
327            ParsedSelectorFactKind::Placeholder => {
328                declared_placeholders.insert(selector.name.clone());
329            }
330            ParsedSelectorFactKind::Class => {
331                declared_classes.insert(selector.name.clone());
332            }
333            ParsedSelectorFactKind::Id => {}
334        }
335    }
336
337    let mut emitted = BTreeSet::new();
338    let mut diagnostics = Vec::new();
339    for target in &facts.extend_targets {
340        // An optional extend (`@extend %x !optional`) is allowed to miss — never flag it.
341        if target.optional {
342            continue;
343        }
344        let (resolved, label) = match target.kind {
345            ParsedExtendTargetFactKind::Placeholder => (
346                declared_placeholders.contains(&target.name),
347                format!("%{}", target.name),
348            ),
349            ParsedExtendTargetFactKind::Class => (
350                declared_classes.contains(&target.name),
351                format!(".{}", target.name),
352            ),
353        };
354        if resolved {
355            continue;
356        }
357        let start: u32 = target.range.start().into();
358        let end: u32 = target.range.end().into();
359        let byte_span = ParserByteSpanV0 {
360            start: start as usize,
361            end: end as usize,
362        };
363        if !emitted.insert((byte_span.start, byte_span.end)) {
364            continue;
365        }
366        diagnostics.push(OmenaQueryStyleDiagnosticV0 {
367            code: "missingExtendTarget",
368            severity: "error",
369            provenance: vec![
370                "omena-parser.extend-target-facts",
371                "omena-query.missing-extend-target-diagnostics",
372            ],
373            range: parser_range_for_byte_span(source, byte_span),
374            message: format!(
375                "@extend target '{label}' does not exist in this file. dart-sass rejects this as a hard error."
376            ),
377            tags: Vec::new(),
378            create_custom_property: None,
379        });
380    }
381
382    diagnostics
383}
384
385/// RFC-0007-E1 (#45) workspace variant: like the file-local rule, but a target is only flagged when
386/// it is absent from the placeholders/classes declared in files **reachable from the target's
387/// `@use`/`@forward`/`@import` import graph** (the target file plus its transitive module-graph
388/// closure), so a cross-file `@extend` of a placeholder defined in an imported partial is never a
389/// false positive — while an `@extend` of a placeholder that only exists in an UNRELATED,
390/// non-imported file still fires (dart-sass only sees declarations the loaded modules bring into
391/// scope, never the whole corpus). Optional extends are skipped.
392fn summarize_omena_query_missing_extend_target_diagnostics_for_workspace(
393    target_style_path: &str,
394    style_sources: &[OmenaQueryStyleSourceInputV0],
395) -> Vec<OmenaQueryStyleDiagnosticV0> {
396    let Some(target) = style_sources
397        .iter()
398        .find(|source| source.style_path == target_style_path)
399    else {
400        return Vec::new();
401    };
402    let dialect = omena_parser_dialect_for_style_path(target_style_path);
403    if !matches!(
404        dialect,
405        OmenaParserStyleDialect::Scss | OmenaParserStyleDialect::Sass
406    ) {
407        return Vec::new();
408    }
409
410    let target_facts =
411        collect_omena_query_omena_parser_style_facts_raw(target.style_source.as_str(), dialect);
412    if target_facts.extend_targets.is_empty() {
413        return Vec::new();
414    }
415
416    // Resolve the import graph so visibility tracks only the modules the target actually loads,
417    // not the whole corpus. `summarize_sass_module_cross_file_resolution` already gives us the
418    // resolved edges; `collect_sass_module_graph_reachable_style_paths` walks them from the target.
419    let style_source_refs = style_sources
420        .iter()
421        .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
422        .collect::<Vec<_>>();
423    let style_fact_entries = collect_omena_query_style_fact_entries(style_source_refs.as_slice());
424    let resolution = summarize_sass_module_cross_file_resolution(&style_fact_entries, &[]);
425    let reachable_paths =
426        collect_sass_module_graph_reachable_style_paths(target_style_path, &resolution);
427
428    // Declared placeholders/classes from the target plus every file reachable through its
429    // `@use`/`@forward`/`@import` graph. A placeholder declared only in an unrelated, non-imported
430    // file is NOT in this set, so an `@extend` of it correctly fires (matching dart-sass scope).
431    let mut declared_placeholders = BTreeSet::new();
432    let mut declared_classes = BTreeSet::new();
433    for source in style_sources {
434        if !reachable_paths.contains(source.style_path.as_str()) {
435            continue;
436        }
437        let facts = collect_omena_query_omena_parser_style_facts_raw(
438            source.style_source.as_str(),
439            omena_parser_dialect_for_style_path(source.style_path.as_str()),
440        );
441        for selector in facts.selectors {
442            match selector.kind {
443                ParsedSelectorFactKind::Placeholder => {
444                    declared_placeholders.insert(selector.name);
445                }
446                ParsedSelectorFactKind::Class => {
447                    declared_classes.insert(selector.name);
448                }
449                ParsedSelectorFactKind::Id => {}
450            }
451        }
452    }
453
454    let mut emitted = BTreeSet::new();
455    let mut diagnostics = Vec::new();
456    for extend_target in &target_facts.extend_targets {
457        if extend_target.optional {
458            continue;
459        }
460        let (resolved, label) = match extend_target.kind {
461            ParsedExtendTargetFactKind::Placeholder => (
462                declared_placeholders.contains(&extend_target.name),
463                format!("%{}", extend_target.name),
464            ),
465            ParsedExtendTargetFactKind::Class => (
466                declared_classes.contains(&extend_target.name),
467                format!(".{}", extend_target.name),
468            ),
469        };
470        if resolved {
471            continue;
472        }
473        let start: u32 = extend_target.range.start().into();
474        let end: u32 = extend_target.range.end().into();
475        let byte_span = ParserByteSpanV0 {
476            start: start as usize,
477            end: end as usize,
478        };
479        if !emitted.insert((byte_span.start, byte_span.end)) {
480            continue;
481        }
482        diagnostics.push(OmenaQueryStyleDiagnosticV0 {
483            code: "missingExtendTarget",
484            severity: "error",
485            provenance: vec![
486                "omena-parser.extend-target-facts",
487                "omena-query.missing-extend-target-diagnostics",
488            ],
489            range: parser_range_for_byte_span(target.style_source.as_str(), byte_span),
490            message: format!(
491                "@extend target '{label}' does not exist in the visible Sass module graph. dart-sass rejects this as a hard error."
492            ),
493            tags: Vec::new(),
494            create_custom_property: None,
495        });
496    }
497
498    diagnostics
499}
500
501/// RFC-0007-E1 (#45): the set of in-graph style paths reachable from `target_style_path` through
502/// the resolved `@use`/`@forward`/`@import` edges (the target itself plus its transitive module-graph
503/// closure). Used to scope cross-file `@extend` visibility to the modules the target actually loads
504/// rather than the whole corpus, so a placeholder declared only in an unrelated file is not
505/// (wrongly) treated as visible. Cycle-safe: each path is visited at most once.
506fn collect_sass_module_graph_reachable_style_paths<'a>(
507    target_style_path: &'a str,
508    resolution: &'a OmenaQuerySassModuleCrossFileResolutionV0,
509) -> BTreeSet<&'a str> {
510    let mut reachable = BTreeSet::new();
511    let mut stack = vec![target_style_path];
512    while let Some(current) = stack.pop() {
513        if !reachable.insert(current) {
514            continue;
515        }
516        for edge in resolution
517            .edges
518            .iter()
519            .filter(|edge| edge.from_style_path == current && edge.status == "resolved")
520        {
521            if let Some(next) = edge.resolved_style_path.as_deref() {
522                stack.push(next);
523            }
524        }
525    }
526    reachable
527}
528
529/// Surface the real cross-file replica-ensemble inconsistency diagnostic in the
530/// workspace style path (#33 / L0 / L2).
531///
532/// When the target file's resolved `@use`/`@forward`/`@import` graph closure spans
533/// two or more in-graph CSS modules, each module is treated as one *replica* of the
534/// shared design surface and its REAL per-`(selector, property)` cascade winners are
535/// extracted via `collect_query_replica_ensemble_site_outcomes` (genuine
536/// `cascade_property` ranking over the parsed declarations — no fabricated
537/// snapshots). `omena-ensemble`'s `build_cross_file_inconsistency_report` then
538/// computes the replica overlap-Q distribution over the modules' winners and the
539/// SBM detectability over the resolved module graph; the report's overlap statistics
540/// (recommendation, `meanQ`, genuine disagreement-pair count) drive the registered
541/// `replicaEnsembleInconsistency` checker rule through
542/// `run_omena_query_checker_replica_ensemble_gate_v0`.
543///
544/// The diagnostic depends entirely on the overlap statistics over the real winners:
545/// a workspace whose modules agree on every shared `(selector, property)` outcome
546/// has `meanQ == 1.0`, zero disagreement pairs, and a `noActionNeeded`
547/// recommendation, so the checker rule filters the report out and nothing is
548/// surfaced; a workspace where two modules resolve a shared site to different
549/// winning values drops `meanQ` below one and surfaces the diagnostic. The report is
550/// whole-graph, so the single emitted diagnostic is anchored on the target file's
551/// whole-file span.
552fn summarize_omena_query_replica_ensemble_inconsistency_diagnostics_for_workspace(
553    target_style_path: &str,
554    style_sources: &[OmenaQueryStyleSourceInputV0],
555    package_manifests: &[OmenaQueryStylePackageManifestV0],
556) -> Vec<OmenaQueryStyleDiagnosticV0> {
557    let Some(target) = style_sources
558        .iter()
559        .find(|source| source.style_path == target_style_path)
560    else {
561        return Vec::new();
562    };
563
564    let style_source_refs = style_sources
565        .iter()
566        .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
567        .collect::<Vec<_>>();
568    let style_fact_entries = collect_omena_query_style_fact_entries(style_source_refs.as_slice());
569    let resolution =
570        summarize_sass_module_cross_file_resolution(&style_fact_entries, package_manifests);
571    let reachable_paths =
572        collect_sass_module_graph_reachable_style_paths(target_style_path, &resolution);
573
574    // A single-module closure is not an ensemble: there is no second replica to
575    // overlap against, so there is no cross-file inconsistency to surface.
576    if reachable_paths.len() < 2 {
577        return Vec::new();
578    }
579
580    // Build one replica per in-graph module from its REAL cascade winners. A module
581    // that declares no comparable definite cascade site contributes an empty replica
582    // (it cannot agree or disagree with anything), so drop it from the ensemble.
583    let replicas = style_sources
584        .iter()
585        .filter(|source| reachable_paths.contains(source.style_path.as_str()))
586        .filter_map(|source| {
587            let sites = collect_query_replica_ensemble_site_outcomes(source.style_source.as_str());
588            if sites.is_empty() {
589                return None;
590            }
591            Some(ReplicaSnapshotV0 {
592                schema_version: REPLICA_ENSEMBLE_SCHEMA_VERSION_V0,
593                product: "omena-ensemble.replica-snapshot",
594                layer_marker: REPLICA_ENSEMBLE_LAYER_MARKER_V0,
595                feature_gate: REPLICA_ENSEMBLE_FEATURE_GATE_V0,
596                path: source.style_path.clone(),
597                sites,
598            })
599        })
600        .collect::<Vec<_>>();
601
602    // Fewer than two non-empty replicas => no shared cascade surface to compare.
603    if replicas.len() < 2 {
604        return Vec::new();
605    }
606
607    let module_graph = replica_ensemble_module_graph_from_resolution(
608        target_style_path,
609        &resolution,
610        &reachable_paths,
611        &replicas,
612    );
613    let report = build_cross_file_inconsistency_report(
614        target_style_path,
615        replicas.clone(),
616        &module_graph,
617        OutcomeMode::DefiniteOnly,
618        ReportOptionsV0::default(),
619        None,
620    );
621
622    // Genuine disagreement count: only replica pairs whose computed overlap-Q is
623    // strictly below 1.0 actually disagree on a shared cascade outcome. (The report's
624    // `top_disagreement_pairs` keeps the lowest-Q pairs even when every pair fully
625    // agrees, so counting that list directly would fire on a consistent ensemble.)
626    let genuine_disagreement_pair_count = report
627        .top_disagreement_pairs
628        .iter()
629        .filter(|pair| pair.shared_site_count > 0 && pair.overlap_q < 1.0)
630        .count();
631    let recommendation = replica_ensemble_recommendation_name(report.recommendation);
632
633    let gate =
634        run_omena_query_checker_replica_ensemble_gate_v0(OmenaCheckerReplicaEnsembleInputV0 {
635            reports: vec![OmenaCheckerReplicaEnsembleReportInputV0 {
636                workspace_root: target_style_path.to_string(),
637                recommendation: recommendation.to_string(),
638                mean_q: report.distribution.mean_q,
639                variance_q: report.distribution.variance_q,
640                top_disagreement_pair_count: genuine_disagreement_pair_count,
641            }],
642        });
643    if !gate.enforcement_passed {
644        return Vec::new();
645    }
646
647    let whole_file_range = parser_range_for_byte_span(
648        target.style_source.as_str(),
649        ParserByteSpanV0 {
650            start: 0,
651            end: target.style_source.len(),
652        },
653    );
654
655    gate.evaluations
656        .into_iter()
657        .map(|evaluation| {
658            let mut provenance = vec![
659                "omena-query-checker-orchestrator.replica-ensemble-gate",
660                "omena-checker.replica-ensemble-rules",
661                "omena-ensemble.cross-file-inconsistency-report",
662                "omena-query.cross-file-replica-ensemble",
663            ];
664            provenance.extend(evaluation.mechanism_products.iter().copied());
665            OmenaQueryStyleDiagnosticV0 {
666                code: "replicaEnsembleInconsistency",
667                severity: "hint",
668                provenance,
669                range: whole_file_range,
670                message: evaluation.message,
671                tags: Vec::new(),
672                create_custom_property: None,
673            }
674        })
675        .collect()
676}
677
678/// Build the replica-ensemble module graph from the target's resolved import graph:
679/// nodes are the in-graph modules that contributed a non-empty replica, and edges
680/// are the resolved `@use`/`@forward`/`@import` edges between those modules. This is
681/// the real dependency structure the SBM detectability reasons over — not a
682/// synthesized clique.
683fn replica_ensemble_module_graph_from_resolution(
684    workspace_root: &str,
685    resolution: &OmenaQuerySassModuleCrossFileResolutionV0,
686    reachable_paths: &BTreeSet<&str>,
687    replicas: &[ReplicaSnapshotV0],
688) -> ModuleGraphV0 {
689    let nodes = replicas
690        .iter()
691        .map(|replica| replica.path.clone())
692        .collect::<Vec<_>>();
693    let node_set = nodes.iter().map(String::as_str).collect::<BTreeSet<_>>();
694
695    let edges = resolution
696        .edges
697        .iter()
698        .filter(|edge| edge.status == "resolved")
699        .filter(|edge| reachable_paths.contains(edge.from_style_path.as_str()))
700        .filter_map(|edge| {
701            let to = edge.resolved_style_path.as_deref()?;
702            if node_set.contains(edge.from_style_path.as_str()) && node_set.contains(to) {
703                Some(ModuleGraphEdgeV0 {
704                    schema_version: REPLICA_ENSEMBLE_SCHEMA_VERSION_V0,
705                    product: "omena-ensemble.module-graph-edge",
706                    layer_marker: REPLICA_ENSEMBLE_LAYER_MARKER_V0,
707                    feature_gate: REPLICA_ENSEMBLE_FEATURE_GATE_V0,
708                    from_module: edge.from_style_path.clone(),
709                    to_module: to.to_string(),
710                    edge_kind: "resolvedModuleEdge",
711                })
712            } else {
713                None
714            }
715        })
716        .collect::<Vec<_>>();
717
718    ModuleGraphV0 {
719        schema_version: REPLICA_ENSEMBLE_SCHEMA_VERSION_V0,
720        product: "omena-ensemble.module-graph",
721        layer_marker: REPLICA_ENSEMBLE_LAYER_MARKER_V0,
722        feature_gate: REPLICA_ENSEMBLE_FEATURE_GATE_V0,
723        workspace_root: workspace_root.to_string(),
724        nodes,
725        edges,
726    }
727}
728
729fn replica_ensemble_recommendation_name(recommendation: ReportRecommendation) -> &'static str {
730    match recommendation {
731        ReportRecommendation::NoActionNeeded => "noActionNeeded",
732        ReportRecommendation::InvestigateRsbBroken => "investigateRsbBroken",
733        ReportRecommendation::UndetectablePhase => "undetectablePhase",
734    }
735}
736
737pub fn summarize_omena_query_sass_import_deprecation_hints(
738    style_uri: &str,
739    source: &str,
740) -> Vec<OmenaQueryStyleDiagnosticV0> {
741    let dialect = omena_parser_dialect_for_style_path(style_uri);
742    if !matches!(
743        dialect,
744        OmenaParserStyleDialect::Scss | OmenaParserStyleDialect::Sass
745    ) {
746        return Vec::new();
747    }
748
749    let facts = collect_omena_query_omena_parser_style_facts_raw(source, dialect);
750    facts
751        .sass_module_edges
752        .into_iter()
753        .filter(|edge| edge.kind == ParsedSassModuleEdgeFactKind::Import)
754        // Sass deprecated `@import` only for Sass partials. CSS-form imports
755        // (`url(...)`, `.css` targets, protocol/`//` URLs, media-qualified targets)
756        // are explicitly kept and must NOT be flagged. Classify per-edge (each
757        // comma-peer target is its own Import edge), so a partial that shares a
758        // multi-target statement with a CSS import still warns. (RFC-0007 D1, #44)
759        .filter(|edge| !edge.media_qualified && !sass_import_is_plain_css(edge.source.as_str()))
760        .map(|edge| {
761            let start: u32 = edge.range.start().into();
762            let end: u32 = edge.range.end().into();
763            OmenaQueryStyleDiagnosticV0 {
764                code: "deprecatedSassImport",
765                severity: "information",
766                provenance: vec![
767                    "omena-parser.sass-module-edges",
768                    "omena-query.sass-import-deprecation-hints",
769                ],
770                range: parser_range_for_byte_span(
771                    source,
772                    ParserByteSpanV0 {
773                        start: start as usize,
774                        end: end as usize,
775                    },
776                ),
777                message: "Sass @import is deprecated; prefer @use or @forward.".to_string(),
778                tags: vec![LSP_DIAGNOSTIC_TAG_DEPRECATED],
779                create_custom_property: None,
780            }
781        })
782        .collect()
783}
784
785/// Classify an `@import` target as plain CSS, which Sass explicitly keeps (NOT
786/// deprecated). Operates on the `source` already captured in the Import edge fact,
787/// so cross-file resolution (the edge collector) is unaffected.
788///
789/// Detects the CSS-form imports that are recoverable from the edge fact alone:
790/// - `url(...)` (unquoted url form, source retains the `url(` wrapper),
791/// - a `.css` extension target,
792/// - protocol (`scheme://`) or scheme-relative (`//host/...`) URLs.
793///
794/// The media-qualified form (`@import "foo" screen`) is now handled upstream via the
795/// `media_qualified` flag on the Import edge (captured in the parser, where the
796/// qualifier token is still available), so it is filtered out before this predicate
797/// runs and does not need detecting here. (RFC-0007 D1, #44)
798///
799/// Necessary-not-sufficient: the quoted-url-without-`.css` form (`@import url("foo")`,
800/// whose `url(...)` wrapper is lost during tokenization) remains NOT distinguishable
801/// from a Sass partial at the edge-fact level, so it is still treated as a Sass-form
802/// import here.
803fn sass_import_is_plain_css(source: &str) -> bool {
804    let trimmed = source.trim();
805    let lower = trimmed.to_ascii_lowercase();
806    // Unquoted `url(...)` form: the source still carries the `url(` prefix.
807    if lower.starts_with("url(") {
808        return true;
809    }
810    // Protocol (`https://`, `http://`, `data:`-less `scheme://`) and scheme-relative
811    // (`//cdn.example/...`) URLs are always plain CSS.
812    if lower.starts_with("//") || lower.contains("://") {
813        return true;
814    }
815    // Explicit `.css` extension target.
816    if lower.ends_with(".css") {
817        return true;
818    }
819    false
820}
821
822pub fn summarize_omena_query_missing_sass_symbol_diagnostics_for_workspace(
823    target_style_path: &str,
824    style_sources: &[OmenaQueryStyleSourceInputV0],
825    package_manifests: &[OmenaQueryStylePackageManifestV0],
826) -> Vec<OmenaQueryStyleDiagnosticV0> {
827    summarize_omena_query_missing_sass_symbol_diagnostics_for_workspace_with_sifs(
828        target_style_path,
829        style_sources,
830        package_manifests,
831        &[],
832    )
833}
834
835fn summarize_omena_query_missing_sass_symbol_diagnostics_for_workspace_with_sifs(
836    target_style_path: &str,
837    style_sources: &[OmenaQueryStyleSourceInputV0],
838    package_manifests: &[OmenaQueryStylePackageManifestV0],
839    external_sifs: &[OmenaQueryExternalSifInputV0],
840) -> Vec<OmenaQueryStyleDiagnosticV0> {
841    let Some(target) = style_sources
842        .iter()
843        .find(|source| source.style_path == target_style_path)
844    else {
845        return Vec::new();
846    };
847    let style_source_refs = style_sources
848        .iter()
849        .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
850        .collect::<Vec<_>>();
851    let style_fact_entries = collect_omena_query_style_fact_entries(style_source_refs.as_slice());
852    let facts_by_path = style_fact_entries
853        .iter()
854        .map(|entry| (entry.style_path.as_str(), &entry.facts))
855        .collect::<BTreeMap<_, _>>();
856    let resolution =
857        summarize_sass_module_cross_file_resolution(&style_fact_entries, package_manifests);
858    let visible_symbols = collect_visible_sass_symbol_keys(
859        target_style_path,
860        &facts_by_path,
861        &resolution,
862        external_sifs,
863    );
864    let facts = collect_omena_query_omena_parser_style_facts_raw(
865        target.style_source.as_str(),
866        omena_parser_dialect_for_style_path(target_style_path),
867    );
868    let mut emitted = BTreeSet::new();
869    let mut diagnostics = Vec::new();
870
871    for symbol in facts.sass_symbols {
872        if !omena_query_sass_symbol_fact_kind_is_reference(symbol.kind) {
873            continue;
874        }
875        let key = sass_symbol_key(
876            symbol.symbol_kind,
877            symbol.namespace.clone(),
878            symbol.name.clone(),
879        );
880        if visible_symbols.contains(&key) {
881            continue;
882        }
883        if is_omena_query_sass_builtin_symbol_reference_resolved(
884            &facts.sass_module_edges,
885            symbol.symbol_kind,
886            symbol.namespace.as_deref(),
887            symbol.name.as_str(),
888        ) {
889            continue;
890        }
891
892        let start: u32 = symbol.range.start().into();
893        let end: u32 = symbol.range.end().into();
894        let byte_span = ParserByteSpanV0 {
895            start: start as usize,
896            end: end as usize,
897        };
898        if !emitted.insert((
899            symbol.symbol_kind,
900            symbol.namespace.clone(),
901            symbol.name.clone(),
902            byte_span.start,
903            byte_span.end,
904        )) {
905            continue;
906        }
907        diagnostics.push(OmenaQueryStyleDiagnosticV0 {
908            code: "missingSassSymbol",
909            severity: "warning",
910            provenance: vec![
911                "omena-parser.sass-symbol-facts",
912                "omena-query.graph-aware-sass-diagnostics",
913            ],
914            range: parser_range_for_byte_span(target.style_source.as_str(), byte_span),
915            message: format!(
916                "{} not found in the visible Sass module graph.",
917                format_query_sass_symbol_label(symbol.symbol_kind, symbol.name.as_str())
918            ),
919            tags: Vec::new(),
920            create_custom_property: None,
921        });
922    }
923
924    diagnostics
925}
926
927/// RFC-0007-E2 (#45): `@use`/`@forward` module cycles. dart-sass hard-errors on a module loop
928/// (`a.scss: @use 'b'`; `b.scss: @use 'a'`) or a self-loop (`@use './self'`); omena was silent.
929///
930/// The cycle facts are ALREADY computed — `summarize_sass_module_cross_file_resolution` fills
931/// `resolution.cycles` (with `cycle_detection_ready: true`), but no diagnostic ever read them.
932/// This is pure last-mile consumer wiring: read the existing `cycles`, keep the ones whose path
933/// includes the target file, and anchor one diagnostic per such cycle to the outgoing
934/// `@use`/`@forward`/`@import` statement in the target that closes the loop.
935///
936/// Anchoring: each cycle `path` is a node list `[A, B, …, A]`; for the target `A` the next node is
937/// the module it loads (`B`). We map back to the resolved edge `from == target && resolved == B`,
938/// then to the parser fact carrying its source range, so the squiggle lands on the actual
939/// `@use 'b'` statement rather than the whole file. A cycle where the target is not a participant
940/// (only reachable *through* a cycle) emits nothing here — it is reported on the file that owns the
941/// looping statement, so each cycle is surfaced exactly once per participating edge.
942fn summarize_omena_query_sass_use_cycle_diagnostics_for_workspace(
943    target_style_path: &str,
944    style_sources: &[OmenaQueryStyleSourceInputV0],
945    package_manifests: &[OmenaQueryStylePackageManifestV0],
946) -> Vec<OmenaQueryStyleDiagnosticV0> {
947    let Some(target) = style_sources
948        .iter()
949        .find(|source| source.style_path == target_style_path)
950    else {
951        return Vec::new();
952    };
953    let style_source_refs = style_sources
954        .iter()
955        .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
956        .collect::<Vec<_>>();
957    let style_fact_entries = collect_omena_query_style_fact_entries(style_source_refs.as_slice());
958    let resolution =
959        summarize_sass_module_cross_file_resolution(&style_fact_entries, package_manifests);
960    if resolution.cycles.is_empty() {
961        return Vec::new();
962    }
963
964    // Parser facts for the target file: the resolution edges carry the loop topology but not source
965    // ranges, so we re-derive the `@use`/`@forward`/`@import` statement span by matching the edge's
966    // `source` text back to the fact that produced it.
967    let target_facts = collect_omena_query_omena_parser_style_facts_raw(
968        target.style_source.as_str(),
969        omena_parser_dialect_for_style_path(target_style_path),
970    );
971
972    let mut emitted = BTreeSet::new();
973    let mut diagnostics = Vec::new();
974
975    for cycle in &resolution.cycles {
976        // The target participates iff it appears in the loop node list.
977        if !cycle.path.iter().any(|node| node == target_style_path) {
978            continue;
979        }
980        // `RawAllPaths` emits every rotation of the same loop (`[a, b, a]` and `[b, a, b]`), so
981        // dedupe on a rotation-invariant key before emitting, otherwise `a <-> b` would surface
982        // twice on `a.scss`. The repeated closing node is dropped first, then we key on the
983        // lexicographically-smallest rotation of the node ring.
984        let canonical_cycle = canonical_sass_module_cycle(&cycle.path);
985        // The next node after the target in the loop is the module the target loads to close it.
986        // A self-loop (`@use './self'`) has the target as both the current and next node.
987        let Some(next_module) = cycle
988            .path
989            .windows(2)
990            .find(|window| window[0] == target_style_path)
991            .map(|window| window[1].clone())
992        else {
993            continue;
994        };
995        // Find the resolved edge target -> next_module to recover the `@use`/`@forward` source text.
996        let Some(loop_edge) = resolution.edges.iter().find(|edge| {
997            edge.from_style_path == target_style_path
998                && edge.resolved_style_path.as_deref() == Some(next_module.as_str())
999        }) else {
1000            continue;
1001        };
1002        // Map back to the parser fact carrying the statement range (match on source text + kind).
1003        let Some(fact) = target_facts.sass_module_edges.iter().find(|fact| {
1004            fact.source == loop_edge.source
1005                && parsed_sass_module_edge_fact_kind_matches(fact.kind, loop_edge.edge_kind)
1006        }) else {
1007            continue;
1008        };
1009        let start: u32 = fact.range.start().into();
1010        let end: u32 = fact.range.end().into();
1011        let byte_span = ParserByteSpanV0 {
1012            start: start as usize,
1013            end: end as usize,
1014        };
1015        if !emitted.insert((byte_span.start, byte_span.end, canonical_cycle.clone())) {
1016            continue;
1017        }
1018        diagnostics.push(OmenaQueryStyleDiagnosticV0 {
1019            code: "sassUseCycle",
1020            severity: "error",
1021            provenance: vec![
1022                "omena-query.sass-module-cross-file-resolution",
1023                "omena-query.sass-use-cycle-diagnostics",
1024            ],
1025            range: parser_range_for_byte_span(target.style_source.as_str(), byte_span),
1026            message: format!(
1027                "Sass module loop: {}. dart-sass rejects this as a hard error.",
1028                render_sass_module_cycle_from(&canonical_cycle, target_style_path)
1029            ),
1030            tags: Vec::new(),
1031            create_custom_property: None,
1032        });
1033    }
1034
1035    diagnostics
1036}
1037
1038/// RFC-0007-E3 (#45): an unresolved Sass module reference to a **workspace-local** path.
1039/// dart-sass hard-errors on `@import './missing'` / `@use '../gone'` (file not found); omena was
1040/// silent — only `deprecatedSassImport` ever surfaced, and the `missingModule` rule existed only
1041/// for the JS/TS-imports-CSS-Modules direction.
1042///
1043/// The resolution facts are ALREADY computed: `summarize_sass_module_cross_file_resolution` marks
1044/// each edge `status == "unresolved"` (resolver kind `unresolved`), `"external"` (the resolver
1045/// kind `externalIgnored` for `sass:`/`http(s)://`), or `"resolved"`. We read the existing
1046/// `unresolved` edges and emit a `missingModule` diagnostic, but ONLY for relative/absolute
1047/// specifiers (`./`, `../`, `/`):
1048///
1049/// - A relative/absolute specifier is unambiguously a workspace-local file reference — it can never
1050///   be an `npm` package or a `sass:` builtin — so an unresolved one is a genuine file-not-found
1051///   error, matching dart-sass.
1052/// - A *bare* specifier (`'no-such-file'`, `'bootstrap'`) is left untouched: it is indistinguishable
1053///   at this layer from an external bare-package import that has no SIF in scope (the #32/#34
1054///   external-wiring known limitation, NOT an error in `Ignored` mode). Flagging it would regress
1055///   the external case, so bare unresolved partials stay deferred (reported as a remaining item).
1056/// - `status == "external"` edges (`sass:`/`http(s)://`) are never flagged.
1057///
1058/// Anchoring mirrors the use-cycle rule: re-derive the statement span from the parser
1059/// `sass_module_edges` fact whose `source` + kind match the resolution edge.
1060fn summarize_omena_query_unresolved_sass_import_diagnostics_for_workspace(
1061    target_style_path: &str,
1062    style_sources: &[OmenaQueryStyleSourceInputV0],
1063    package_manifests: &[OmenaQueryStylePackageManifestV0],
1064) -> Vec<OmenaQueryStyleDiagnosticV0> {
1065    let Some(target) = style_sources
1066        .iter()
1067        .find(|source| source.style_path == target_style_path)
1068    else {
1069        return Vec::new();
1070    };
1071    let style_source_refs = style_sources
1072        .iter()
1073        .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
1074        .collect::<Vec<_>>();
1075    let style_fact_entries = collect_omena_query_style_fact_entries(style_source_refs.as_slice());
1076    let resolution =
1077        summarize_sass_module_cross_file_resolution(&style_fact_entries, package_manifests);
1078
1079    let target_facts = collect_omena_query_omena_parser_style_facts_raw(
1080        target.style_source.as_str(),
1081        omena_parser_dialect_for_style_path(target_style_path),
1082    );
1083
1084    let mut emitted = BTreeSet::new();
1085    let mut diagnostics = Vec::new();
1086
1087    for edge in resolution.edges.iter().filter(|edge| {
1088        edge.from_style_path == target_style_path
1089            && edge.status == "unresolved"
1090            && sass_module_source_is_workspace_local(edge.source.as_str())
1091    }) {
1092        let Some(fact) = target_facts.sass_module_edges.iter().find(|fact| {
1093            fact.source == edge.source
1094                && parsed_sass_module_edge_fact_kind_matches(fact.kind, edge.edge_kind)
1095        }) else {
1096            continue;
1097        };
1098        let start: u32 = fact.range.start().into();
1099        let end: u32 = fact.range.end().into();
1100        let byte_span = ParserByteSpanV0 {
1101            start: start as usize,
1102            end: end as usize,
1103        };
1104        if !emitted.insert((byte_span.start, byte_span.end)) {
1105            continue;
1106        }
1107        diagnostics.push(OmenaQueryStyleDiagnosticV0 {
1108            code: "missingModule",
1109            severity: "error",
1110            provenance: vec![
1111                "omena-query.sass-module-cross-file-resolution",
1112                "omena-query.unresolved-sass-import-diagnostics",
1113            ],
1114            range: parser_range_for_byte_span(target.style_source.as_str(), byte_span),
1115            message: format!(
1116                "Cannot resolve Sass module '{}'. dart-sass rejects this as a hard error.",
1117                edge.source
1118            ),
1119            tags: Vec::new(),
1120            create_custom_property: None,
1121        });
1122    }
1123
1124    diagnostics
1125}
1126
1127/// A Sass module specifier is workspace-local — and so a genuine file-not-found error when it does
1128/// not resolve — iff it is relative (`./`, `../`) or root-absolute (`/`). Bare specifiers
1129/// (`'partial'`, `'pkg'`) are excluded: they cannot be distinguished here from an external
1130/// bare-package import with no SIF in scope (RFC-0007-E3, #45).
1131fn sass_module_source_is_workspace_local(source: &str) -> bool {
1132    let trimmed = source.trim();
1133    trimmed.starts_with("./") || trimmed.starts_with("../") || trimmed.starts_with('/')
1134}
1135
1136fn parsed_sass_module_edge_fact_kind_matches(
1137    fact_kind: ParsedSassModuleEdgeFactKind,
1138    edge_kind: &str,
1139) -> bool {
1140    matches!(
1141        (fact_kind, edge_kind),
1142        (ParsedSassModuleEdgeFactKind::Use, "sassUse")
1143            | (ParsedSassModuleEdgeFactKind::Forward, "sassForward")
1144            | (ParsedSassModuleEdgeFactKind::Import, "sassImport")
1145    )
1146}
1147
1148/// Reduce a cycle `path` (a node ring whose first and last entries repeat, e.g. `[a, b, a]`) to a
1149/// rotation-invariant key: drop the repeated closing node, then return the lexicographically
1150/// smallest rotation. Two rotations of the same loop (`[a, b, a]` / `[b, a, b]`) collapse to one
1151/// key, so each distinct loop is surfaced exactly once per anchoring edge. A self-loop `[a, a]`
1152/// reduces to `[a]`.
1153fn canonical_sass_module_cycle(path: &[String]) -> Vec<String> {
1154    let ring: &[String] = match path.split_last() {
1155        Some((last, head)) if Some(last) == path.first() && !head.is_empty() => head,
1156        _ => path,
1157    };
1158    if ring.is_empty() {
1159        return path.to_vec();
1160    }
1161    let len = ring.len();
1162    (0..len)
1163        .map(|offset| {
1164            (0..len)
1165                .map(|index| ring[(offset + index) % len].clone())
1166                .collect::<Vec<_>>()
1167        })
1168        .min()
1169        .unwrap_or_else(|| ring.to_vec())
1170}
1171
1172/// Render a canonical cycle ring as a closed `start -> … -> start` path beginning at `start`, so
1173/// each participating file describes the loop from its own perspective. `start` is guaranteed to be
1174/// in the ring by the caller (the target participates in the cycle).
1175fn render_sass_module_cycle_from(canonical_cycle: &[String], start: &str) -> String {
1176    let len = canonical_cycle.len();
1177    let begin = canonical_cycle
1178        .iter()
1179        .position(|node| node == start)
1180        .unwrap_or(0);
1181    let mut ordered = (0..len)
1182        .map(|index| canonical_cycle[(begin + index) % len].clone())
1183        .collect::<Vec<_>>();
1184    // Re-close the ring so the loop reads `a -> b -> a` (or `a -> a` for a self-loop).
1185    ordered.push(canonical_cycle[begin].clone());
1186    ordered.join(" -> ")
1187}
1188
1189pub fn summarize_omena_query_style_diagnostics_for_file(
1190    style_uri: &str,
1191    source: &str,
1192    candidates: &[OmenaQueryStyleHoverCandidateV0],
1193) -> OmenaQueryStyleDiagnosticsForFileV0 {
1194    summarize_omena_query_style_diagnostics_for_file_with_deep_analysis(
1195        style_uri, source, candidates, false,
1196    )
1197}
1198
1199/// File-level diagnostics summary with an explicit opt-in deep-analysis switch.
1200/// `deep_analysis == false` (the default LSP/CLI surface) keeps only the product
1201/// cascade diagnostics; `deep_analysis == true` surfaces the rg-flow / categorical
1202/// theory hints, deduplicated against `circularVar`.
1203pub fn summarize_omena_query_style_diagnostics_for_file_with_deep_analysis(
1204    style_uri: &str,
1205    source: &str,
1206    candidates: &[OmenaQueryStyleHoverCandidateV0],
1207    deep_analysis: bool,
1208) -> OmenaQueryStyleDiagnosticsForFileV0 {
1209    let mut diagnostics =
1210        summarize_omena_query_missing_custom_property_diagnostics(style_uri, source, candidates);
1211    diagnostics.extend(
1212        summarize_omena_query_cascade_aware_style_diagnostics_with_deep_analysis(
1213            style_uri,
1214            source,
1215            candidates,
1216            deep_analysis,
1217        ),
1218    );
1219    diagnostics.extend(summarize_omena_query_missing_keyframes_diagnostics(
1220        style_uri, source,
1221    ));
1222    diagnostics.extend(summarize_omena_query_sass_import_deprecation_hints(
1223        style_uri, source,
1224    ));
1225    diagnostics.extend(summarize_omena_query_missing_sass_symbol_diagnostics(
1226        style_uri, source,
1227    ));
1228    diagnostics.extend(summarize_omena_query_missing_extend_target_diagnostics(
1229        style_uri, source,
1230    ));
1231    apply_omena_query_checker_product_gate_to_style_diagnostics(&mut diagnostics);
1232    let mut summary = OmenaQueryStyleDiagnosticsForFileV0 {
1233        schema_version: "0",
1234        product: "omena-query.diagnostics-for-file",
1235        file_uri: style_uri.to_string(),
1236        file_kind: "style",
1237        diagnostic_count: diagnostics.len(),
1238        diagnostics,
1239        ready_surfaces: vec![
1240            "missingCustomPropertyDiagnostics",
1241            "cascadeAwareDiagnostics",
1242            "missingKeyframesDiagnostics",
1243            "sassImportDeprecationHints",
1244            "missingSassSymbolDiagnostics",
1245            "missingExtendTargetDiagnostics",
1246            "checkerProductDiagnosticGate",
1247        ],
1248    };
1249    apply_omena_query_style_diagnostic_suppressions(source, &mut summary);
1250    summary
1251}
1252
1253/// RFC-0007-F (#46): single-file `style-diagnostics` (no `--source`) used to skip composes-target
1254/// validation entirely, so a bare invocation and one with any unrelated `--source` produced
1255/// different diagnostics for the same file. This variant augments the single-file summary with the
1256/// composes outcomes that are fully resolvable without cross-file context — only `composes: x`
1257/// (Local edges) against the file's own selectors. Global edges produce nothing and External edges
1258/// (`composes: x from './other'`) are deliberately left to the `--source`-backed workspace path, so
1259/// no false `missingComposedSelector`/`missingComposedModule` is invented for an unseen sibling.
1260pub fn summarize_omena_query_style_diagnostics_for_file_with_local_composes(
1261    style_uri: &str,
1262    source: &str,
1263    candidates: &[OmenaQueryStyleHoverCandidateV0],
1264) -> OmenaQueryStyleDiagnosticsForFileV0 {
1265    summarize_omena_query_style_diagnostics_for_file_with_local_composes_and_deep_analysis(
1266        style_uri, source, candidates, false,
1267    )
1268}
1269
1270/// Single-file (local composes) diagnostics summary with an explicit opt-in
1271/// deep-analysis switch. `deep_analysis == false` (the default surface) keeps only
1272/// the product cascade diagnostics; `deep_analysis == true` surfaces the rg-flow /
1273/// categorical theory hints, deduplicated against `circularVar`.
1274pub fn summarize_omena_query_style_diagnostics_for_file_with_local_composes_and_deep_analysis(
1275    style_uri: &str,
1276    source: &str,
1277    candidates: &[OmenaQueryStyleHoverCandidateV0],
1278    deep_analysis: bool,
1279) -> OmenaQueryStyleDiagnosticsForFileV0 {
1280    let mut summary = summarize_omena_query_style_diagnostics_for_file_with_deep_analysis(
1281        style_uri,
1282        source,
1283        candidates,
1284        deep_analysis,
1285    );
1286    let mut local_composes =
1287        summarize_omena_query_css_modules_local_composes_style_diagnostics(style_uri, source);
1288    apply_omena_query_checker_product_gate_to_style_diagnostics(&mut local_composes);
1289    if !local_composes.is_empty() {
1290        summary.diagnostics.extend(local_composes);
1291        push_omena_query_ready_surface(
1292            &mut summary.ready_surfaces,
1293            "cssModulesComposesResolutionDiagnostics",
1294        );
1295        // Re-run suppressions so the appended composes diagnostics honour the same inline directives.
1296        apply_omena_query_style_diagnostic_suppressions(source, &mut summary);
1297        summary.diagnostic_count = summary.diagnostics.len();
1298    }
1299    summary
1300}
1301
1302pub fn summarize_omena_query_style_diagnostics_for_workspace_file(
1303    target_style_path: &str,
1304    style_sources: &[OmenaQueryStyleSourceInputV0],
1305    source_documents: &[OmenaQuerySourceDocumentInputV0],
1306    package_manifests: &[OmenaQueryStylePackageManifestV0],
1307    classname_transform: Option<&str>,
1308) -> Option<OmenaQueryStyleDiagnosticsForFileV0> {
1309    summarize_omena_query_style_diagnostics_for_workspace_file_with_external_mode(
1310        target_style_path,
1311        style_sources,
1312        source_documents,
1313        package_manifests,
1314        classname_transform,
1315        OmenaQueryExternalModuleModeV0::Ignored,
1316    )
1317}
1318
1319pub fn summarize_omena_query_style_diagnostics_for_workspace_file_with_external_mode(
1320    target_style_path: &str,
1321    style_sources: &[OmenaQueryStyleSourceInputV0],
1322    source_documents: &[OmenaQuerySourceDocumentInputV0],
1323    package_manifests: &[OmenaQueryStylePackageManifestV0],
1324    classname_transform: Option<&str>,
1325    external_mode: OmenaQueryExternalModuleModeV0,
1326) -> Option<OmenaQueryStyleDiagnosticsForFileV0> {
1327    summarize_omena_query_style_diagnostics_for_workspace_file_with_external_mode_and_sifs(
1328        target_style_path,
1329        style_sources,
1330        source_documents,
1331        package_manifests,
1332        classname_transform,
1333        external_mode,
1334        &[],
1335    )
1336}
1337
1338pub fn summarize_omena_query_style_diagnostics_for_workspace_file_with_external_mode_and_sifs(
1339    target_style_path: &str,
1340    style_sources: &[OmenaQueryStyleSourceInputV0],
1341    source_documents: &[OmenaQuerySourceDocumentInputV0],
1342    package_manifests: &[OmenaQueryStylePackageManifestV0],
1343    classname_transform: Option<&str>,
1344    external_mode: OmenaQueryExternalModuleModeV0,
1345    external_sifs: &[OmenaQueryExternalSifInputV0],
1346) -> Option<OmenaQueryStyleDiagnosticsForFileV0> {
1347    summarize_omena_query_style_diagnostics_for_workspace_file_with_external_mode_and_sifs_and_resolution_inputs(
1348        target_style_path,
1349        style_sources,
1350        source_documents,
1351        package_manifests,
1352        classname_transform,
1353        external_mode,
1354        external_sifs,
1355        &OmenaQueryStyleResolutionInputsV0 {
1356            package_manifests: package_manifests.to_vec(),
1357            ..Default::default()
1358        },
1359    )
1360}
1361
1362/// Workspace-file style diagnostics variant that additionally carries the workspace's
1363/// tsconfig/bundler path mappings. RFC-0007-J (#50): the unused-selector usage collector resolves
1364/// source-document style imports through these mappings so an alias import (`@/styles/a.module.scss`)
1365/// is attributed to its real module — matching the reference/goto path — instead of leaving every
1366/// selector dimmed `unusedSelector`. Path mappings only affect alias resolution; with empty mappings
1367/// the behaviour is byte-for-byte the no-mappings entry above.
1368#[allow(clippy::too_many_arguments)]
1369pub fn summarize_omena_query_style_diagnostics_for_workspace_file_with_external_mode_and_sifs_and_resolution_inputs(
1370    target_style_path: &str,
1371    style_sources: &[OmenaQueryStyleSourceInputV0],
1372    source_documents: &[OmenaQuerySourceDocumentInputV0],
1373    package_manifests: &[OmenaQueryStylePackageManifestV0],
1374    classname_transform: Option<&str>,
1375    external_mode: OmenaQueryExternalModuleModeV0,
1376    external_sifs: &[OmenaQueryExternalSifInputV0],
1377    resolution_inputs: &OmenaQueryStyleResolutionInputsV0,
1378) -> Option<OmenaQueryStyleDiagnosticsForFileV0> {
1379    let target = style_sources
1380        .iter()
1381        .find(|source| source.style_path == target_style_path)?;
1382    let candidates =
1383        summarize_omena_query_style_hover_candidates(target_style_path, &target.style_source)?;
1384    let mut summary = summarize_omena_query_style_diagnostics_for_file(
1385        target_style_path,
1386        &target.style_source,
1387        candidates.candidates.as_slice(),
1388    );
1389    summary
1390        .diagnostics
1391        .retain(|diagnostic| diagnostic.code != "missingSassSymbol");
1392    // RFC-0007-E1 (#45): the file-local `missingExtendTarget` rule cannot see a placeholder/class
1393    // declared in another in-graph file reachable via `@use`/`@forward`/`@import`, so it would
1394    // false-positive on a cross-file `@extend`. In workspace mode we drop the file-local result and
1395    // re-emit only those whose target is also absent from EVERY other in-graph style source — a
1396    // conservative cross-file-aware pass that keeps a genuinely-missing target flagged while never
1397    // inventing a false positive for a target defined in an imported partial. (Single-file surface
1398    // keeps the file-local rule unchanged.)
1399    summary
1400        .diagnostics
1401        .retain(|diagnostic| diagnostic.code != "missingExtendTarget");
1402    summary.diagnostics.extend(
1403        summarize_omena_query_missing_extend_target_diagnostics_for_workspace(
1404            target_style_path,
1405            style_sources,
1406        ),
1407    );
1408    summary.diagnostics.extend(
1409        summarize_omena_query_missing_sass_symbol_diagnostics_for_workspace_with_sifs(
1410            target_style_path,
1411            style_sources,
1412            package_manifests,
1413            external_sifs,
1414        ),
1415    );
1416    summary.diagnostics.extend(
1417        summarize_omena_query_css_modules_resolution_style_diagnostics(
1418            target_style_path,
1419            &target.style_source,
1420            style_sources,
1421            package_manifests,
1422        ),
1423    );
1424    summary.diagnostics.extend(
1425        summarize_omena_query_sass_use_cycle_diagnostics_for_workspace(
1426            target_style_path,
1427            style_sources,
1428            package_manifests,
1429        ),
1430    );
1431    summary.diagnostics.extend(
1432        summarize_omena_query_unresolved_sass_import_diagnostics_for_workspace(
1433            target_style_path,
1434            style_sources,
1435            package_manifests,
1436        ),
1437    );
1438    summary.diagnostics.extend(
1439        summarize_omena_query_unused_selector_style_diagnostics_with_path_mappings(
1440            target_style_path,
1441            &target.style_source,
1442            style_sources,
1443            source_documents,
1444            package_manifests,
1445            classname_transform,
1446            resolution_inputs.bundler_path_mappings.as_slice(),
1447            resolution_inputs.tsconfig_path_mappings.as_slice(),
1448        ),
1449    );
1450    summary.diagnostics.extend(
1451        summarize_omena_query_replica_ensemble_inconsistency_diagnostics_for_workspace(
1452            target_style_path,
1453            style_sources,
1454            package_manifests,
1455        ),
1456    );
1457    summary.diagnostic_count = summary.diagnostics.len();
1458    push_omena_query_ready_surface(
1459        &mut summary.ready_surfaces,
1460        "cssModulesComposesResolutionDiagnostics",
1461    );
1462    push_omena_query_ready_surface(
1463        &mut summary.ready_surfaces,
1464        "cssModulesValueResolutionDiagnostics",
1465    );
1466    push_omena_query_ready_surface(&mut summary.ready_surfaces, "unusedSelectorDiagnostics");
1467    push_omena_query_ready_surface(&mut summary.ready_surfaces, "sassUseCycleDiagnostics");
1468    push_omena_query_ready_surface(
1469        &mut summary.ready_surfaces,
1470        "unresolvedSassImportDiagnostics",
1471    );
1472    push_omena_query_ready_surface(
1473        &mut summary.ready_surfaces,
1474        "missingExtendTargetDiagnostics",
1475    );
1476    push_omena_query_ready_surface(
1477        &mut summary.ready_surfaces,
1478        "graphAwareSassSymbolDiagnostics",
1479    );
1480    push_omena_query_ready_surface(
1481        &mut summary.ready_surfaces,
1482        "crossFileReplicaEnsembleDiagnostics",
1483    );
1484    if external_mode == OmenaQueryExternalModuleModeV0::Sif {
1485        // RFC 0004 #28 / #35: the file-scoped `@omena-strict: <level>` sigil dials the
1486        // external-boundary lattice behaviour. Absent/malformed sigil => `Standard`, which
1487        // keeps every branch below a no-op (byte-for-byte identical to the un-sigiled flow).
1488        let strictness = parse_omena_query_style_strictness_level(&target.style_source);
1489        let top_any_external_symbol_ranges =
1490            collect_omena_query_external_top_any_sass_symbol_ranges(
1491                target_style_path,
1492                style_sources,
1493                package_manifests,
1494                external_sifs,
1495            );
1496        if strictness.suppresses_top_any_external_symbols() {
1497            summary.diagnostics.retain(|diagnostic| {
1498                diagnostic.code != "missingSassSymbol"
1499                    || !top_any_external_symbol_ranges.contains(&diagnostic.range)
1500            });
1501        } else {
1502            // `Closed` (#35): `TopOpaque` everywhere — genuinely-unknown external symbols are no
1503            // longer suppressed and are escalated to `error` rather than left as warnings.
1504            for diagnostic in summary.diagnostics.iter_mut() {
1505                if diagnostic.code == "missingSassSymbol"
1506                    && top_any_external_symbol_ranges.contains(&diagnostic.range)
1507                {
1508                    diagnostic.severity = "error";
1509                }
1510            }
1511        }
1512        if strictness.emits_external_boundary_diagnostics() {
1513            summary
1514                .diagnostics
1515                .extend(summarize_omena_query_external_sif_boundary_diagnostics(
1516                    target_style_path,
1517                    style_sources,
1518                    package_manifests,
1519                    external_sifs,
1520                    strictness,
1521                ));
1522        }
1523        push_omena_query_ready_surface(
1524            &mut summary.ready_surfaces,
1525            "externalSifBoundaryDiagnostics",
1526        );
1527        push_omena_query_ready_surface(&mut summary.ready_surfaces, "strictnessSigilGating");
1528    }
1529    apply_omena_query_checker_product_gate_to_style_diagnostics(&mut summary.diagnostics);
1530    push_omena_query_ready_surface(&mut summary.ready_surfaces, "checkerProductDiagnosticGate");
1531    apply_omena_query_style_diagnostic_suppressions(&target.style_source, &mut summary);
1532    summary.diagnostic_count = summary.diagnostics.len();
1533    Some(summary)
1534}
1535
1536fn collect_omena_query_external_top_any_sass_symbol_ranges(
1537    target_style_path: &str,
1538    style_sources: &[OmenaQueryStyleSourceInputV0],
1539    package_manifests: &[OmenaQueryStylePackageManifestV0],
1540    external_sifs: &[OmenaQueryExternalSifInputV0],
1541) -> BTreeSet<ParserRangeV0> {
1542    let Some(target) = style_sources
1543        .iter()
1544        .find(|source| source.style_path == target_style_path)
1545    else {
1546        return BTreeSet::new();
1547    };
1548    let style_source_refs = style_sources
1549        .iter()
1550        .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
1551        .collect::<Vec<_>>();
1552    let style_fact_entries = collect_omena_query_style_fact_entries(style_source_refs.as_slice());
1553    let resolution =
1554        summarize_sass_module_cross_file_resolution(&style_fact_entries, package_manifests);
1555    let external_sources = resolution
1556        .edges
1557        .iter()
1558        .filter(|edge| edge.from_style_path == target_style_path)
1559        .filter(|edge| edge.status == "external")
1560        .map(|edge| edge.source.as_str())
1561        .collect::<BTreeSet<_>>();
1562    if external_sources.is_empty() {
1563        return BTreeSet::new();
1564    }
1565
1566    let facts = collect_omena_query_omena_parser_style_facts_raw(
1567        target.style_source.as_str(),
1568        omena_parser_dialect_for_style_path(target_style_path),
1569    );
1570    // The protocol lattice is the single source of truth: a namespace is TopAny iff its
1571    // external edge classifies to a `top == TopAny` state (Missing/Partial/Stale). A
1572    // Resolved (TopOpaque) edge — i.e. one backed by a complete SIF — is *not* TopAny, so
1573    // its symbols stay subject to ordinary missing-symbol checking. (#34)
1574    let top_any_namespaces = facts
1575        .sass_module_edges
1576        .iter()
1577        .filter(|edge| external_sources.contains(edge.source.as_str()))
1578        .filter(|edge| {
1579            let sif = find_omena_query_external_sif(edge.source.as_str(), external_sifs);
1580            classify_external_boundary_state(edge, sif, &facts, external_sifs).top
1581                == OmenaResolverBoundaryTopV0::TopAny
1582        })
1583        .filter_map(|edge| match edge.kind {
1584            ParsedSassModuleEdgeFactKind::Use
1585                if edge.namespace_kind == Some("default")
1586                    || edge.namespace_kind == Some("alias") =>
1587            {
1588                edge.namespace.clone().map(Some)
1589            }
1590            ParsedSassModuleEdgeFactKind::Use if edge.namespace_kind == Some("wildcard") => {
1591                Some(None)
1592            }
1593            ParsedSassModuleEdgeFactKind::Import => Some(None),
1594            _ => None,
1595        })
1596        .collect::<BTreeSet<_>>();
1597    if top_any_namespaces.is_empty() {
1598        return BTreeSet::new();
1599    }
1600
1601    facts
1602        .sass_symbols
1603        .into_iter()
1604        .filter(|symbol| omena_query_sass_symbol_fact_kind_is_reference(symbol.kind))
1605        .filter(|symbol| top_any_namespaces.contains(&symbol.namespace))
1606        .map(|symbol| {
1607            let start: u32 = symbol.range.start().into();
1608            let end: u32 = symbol.range.end().into();
1609            parser_range_for_byte_span(
1610                target.style_source.as_str(),
1611                ParserByteSpanV0 {
1612                    start: start as usize,
1613                    end: end as usize,
1614                },
1615            )
1616        })
1617        .collect()
1618}
1619
1620/// Classify a single external (`status == "external"`) Sass module edge onto the
1621/// resolver's five-state boundary lattice (#34).
1622///
1623/// Four of the five states are derivable today, with no new transport:
1624/// - **Missing** — no local SIF artifact is in scope for the edge's canonical URL.
1625/// - **Stale** — a SIF is present but one of its declared dependency interface
1626///   hashes no longer matches the SIF actually in scope for that dependency.
1627/// - **Partial** — a SIF is present but only some of the symbols referenced through
1628///   this edge's namespace appear in its exported interface.
1629/// - **Resolved** — a SIF is present and every referenced symbol (or no symbol at
1630///   all) is covered by its exported interface.
1631///
1632/// The fifth state (`Unresolved`) is classified by the caller, not here: an unresolved edge
1633/// has no SIF lattice to reason over, so it folds through the resolver-error channel via
1634/// `omena_resolver_boundary_state_for_unresolved_reference_v0` (#34).
1635fn classify_external_boundary_state(
1636    edge: &ParsedSassModuleEdgeFact,
1637    sif: Option<&OmenaQueryExternalSifInputV0>,
1638    target_facts: &omena_parser::ParsedStyleFacts,
1639    external_sifs: &[OmenaQueryExternalSifInputV0],
1640) -> OmenaResolverBoundaryStateV0 {
1641    let Some(sif) = sif else {
1642        return OmenaResolverBoundaryStateV0::missing(
1643            None,
1644            "SIF mode requires a local SIF artifact for this external Sass module",
1645        );
1646    };
1647
1648    let canonical_url = OmenaResolverCanonicalUrlV0 {
1649        url: edge.source.clone(),
1650    };
1651
1652    // Stale: a declared dependency's recorded interface hash no longer agrees with the
1653    // SIF currently in scope for that dependency canonical URL.
1654    if let Some(dependency) = sif.sif.dependencies.iter().find(|dependency| {
1655        find_omena_query_external_sif(dependency.canonical_url.as_str(), external_sifs)
1656            .map(|dependency_sif| {
1657                dependency_sif.sif.fingerprints.interface_hash != dependency.interface_hash
1658            })
1659            .unwrap_or(false)
1660    }) {
1661        return OmenaResolverBoundaryStateV0::stale(
1662            canonical_url,
1663            format!(
1664                "external SIF dependency '{}' interface hash drifted from the lockfile-recorded hash",
1665                dependency.canonical_url
1666            ),
1667        );
1668    }
1669
1670    // Partial vs Resolved: do all symbols referenced through this edge's namespace
1671    // appear in the SIF's exported interface?
1672    let exported = collect_sif_exported_sass_symbol_keys(&sif.sif);
1673    let mut referenced = 0usize;
1674    let mut covered = 0usize;
1675    for symbol in &target_facts.sass_symbols {
1676        if !omena_query_sass_symbol_fact_kind_is_reference(symbol.kind) {
1677            continue;
1678        }
1679        if !sass_symbol_reference_belongs_to_edge(edge, symbol.namespace.as_deref()) {
1680            continue;
1681        }
1682        referenced += 1;
1683        if exported.contains(&(symbol.symbol_kind, fold_sass_symbol_name(&symbol.name))) {
1684            covered += 1;
1685        }
1686    }
1687
1688    if referenced > 0 && covered < referenced {
1689        return OmenaResolverBoundaryStateV0::partial(format!(
1690            "external SIF for '{}' exports only {}/{} referenced symbol(s)",
1691            edge.source, covered, referenced
1692        ));
1693    }
1694
1695    OmenaResolverBoundaryStateV0::resolved(canonical_url)
1696}
1697
1698/// Does a Sass symbol reference (with the given `@use` namespace) flow through `edge`?
1699///
1700/// Mirrors the namespace-binding rules already used by the visible-symbol collector:
1701/// a default/alias `@use` binds references under its namespace, while a wildcard
1702/// `@use` or an `@import`/`@forward` binds bare (namespace-less) references.
1703fn sass_symbol_reference_belongs_to_edge(
1704    edge: &ParsedSassModuleEdgeFact,
1705    reference_namespace: Option<&str>,
1706) -> bool {
1707    match edge.kind {
1708        ParsedSassModuleEdgeFactKind::Use
1709            if edge.namespace_kind == Some("default") || edge.namespace_kind == Some("alias") =>
1710        {
1711            edge.namespace.as_deref() == reference_namespace
1712        }
1713        ParsedSassModuleEdgeFactKind::Use if edge.namespace_kind == Some("wildcard") => {
1714            reference_namespace.is_none()
1715        }
1716        ParsedSassModuleEdgeFactKind::Import | ParsedSassModuleEdgeFactKind::Forward => {
1717            reference_namespace.is_none()
1718        }
1719        _ => false,
1720    }
1721}
1722
1723fn summarize_omena_query_external_sif_boundary_diagnostics(
1724    target_style_path: &str,
1725    style_sources: &[OmenaQueryStyleSourceInputV0],
1726    package_manifests: &[OmenaQueryStylePackageManifestV0],
1727    external_sifs: &[OmenaQueryExternalSifInputV0],
1728    strictness: OmenaStrictnessLevelV0,
1729) -> Vec<OmenaQueryStyleDiagnosticV0> {
1730    let Some(target) = style_sources
1731        .iter()
1732        .find(|source| source.style_path == target_style_path)
1733    else {
1734        return Vec::new();
1735    };
1736    let style_source_refs = style_sources
1737        .iter()
1738        .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
1739        .collect::<Vec<_>>();
1740    let style_fact_entries = collect_omena_query_style_fact_entries(style_source_refs.as_slice());
1741    let resolution =
1742        summarize_sass_module_cross_file_resolution(&style_fact_entries, package_manifests);
1743    // Every external edge is now classified on the real lattice — including ones that
1744    // *do* have a SIF in scope (the `.is_none()` pre-filter is gone, #34). Missing edges
1745    // warn; Stale/Partial edges warn with distinct codes; Resolved edges emit nothing.
1746    let external_sources = resolution
1747        .edges
1748        .iter()
1749        .filter(|edge| edge.from_style_path == target_style_path)
1750        .filter(|edge| edge.status == "external")
1751        .map(|edge| edge.source.as_str())
1752        .collect::<BTreeSet<_>>();
1753    // The fifth state (`Unresolved`, #34): an edge whose canonical URL the resolver could
1754    // not canonicalize at all (`status == "unresolved"`). The resolver-error channel now
1755    // reaches this layer through `omena_resolver_boundary_state_for_unresolved_reference_v0`.
1756    // We only adopt the *bare* unresolved edges here: workspace-local unresolved specifiers
1757    // (`./`, `../`, `/`) are already a hard `missingModule` error elsewhere, so re-flagging
1758    // them as a boundary state would double-emit. Bare unresolved edges (`'bootstrap'` with
1759    // no SIF in scope) are the ones the boundary diagnostic previously left silent.
1760    let unresolved_sources = resolution
1761        .edges
1762        .iter()
1763        .filter(|edge| edge.from_style_path == target_style_path)
1764        .filter(|edge| edge.status == "unresolved")
1765        .filter(|edge| !sass_module_source_is_workspace_local(edge.source.as_str()))
1766        .map(|edge| edge.source.as_str())
1767        .collect::<BTreeSet<_>>();
1768    if external_sources.is_empty() && unresolved_sources.is_empty() {
1769        return Vec::new();
1770    }
1771
1772    let facts = collect_omena_query_omena_parser_style_facts_raw(
1773        target.style_source.as_str(),
1774        omena_parser_dialect_for_style_path(target_style_path),
1775    );
1776    let mut emitted = BTreeSet::new();
1777    let mut diagnostics = Vec::new();
1778    for edge in &facts.sass_module_edges {
1779        let is_external = external_sources.contains(edge.source.as_str());
1780        let is_unresolved = unresolved_sources.contains(edge.source.as_str());
1781        if !is_external && !is_unresolved {
1782            continue;
1783        }
1784        if !emitted.insert((edge.kind, edge.source.clone())) {
1785            continue;
1786        }
1787        // An unresolved edge folds through the resolver-error channel onto the `Unresolved`
1788        // boundary state; an external edge is classified against the SIF lattice (#34).
1789        let state = if is_unresolved {
1790            omena_resolver_boundary_state_for_unresolved_reference_v0(edge.source.as_str())
1791        } else {
1792            let sif = find_omena_query_external_sif(edge.source.as_str(), external_sifs);
1793            classify_external_boundary_state(edge, sif, &facts, external_sifs)
1794        };
1795        let (code, default_severity) = match state.state {
1796            // A fully-resolved boundary has no diagnostic to emit.
1797            OmenaResolverBoundaryStateKindV0::Resolved => continue,
1798            OmenaResolverBoundaryStateKindV0::Stale => ("staleExternalSif", "warning"),
1799            OmenaResolverBoundaryStateKindV0::Partial => ("partialExternalSif", "information"),
1800            OmenaResolverBoundaryStateKindV0::Missing => ("missingExternalSif", "warning"),
1801            OmenaResolverBoundaryStateKindV0::Unresolved => {
1802                ("unresolvedExternalReference", "warning")
1803            }
1804        };
1805        // The strictness sigil (#35) multiplies into the severity decision: `Strict`/`Closed`
1806        // escalate the boundary to `error`; `Standard`/`Relaxed` pass the default through.
1807        let severity = strictness.boundary_severity(default_severity);
1808        let start: u32 = edge.range.start().into();
1809        let end: u32 = edge.range.end().into();
1810        diagnostics.push(OmenaQueryStyleDiagnosticV0 {
1811            code,
1812            severity,
1813            provenance: vec![
1814                "omena-resolver.boundary-state",
1815                "omena-query.external-sif-boundary-diagnostics",
1816            ],
1817            range: parser_range_for_byte_span(
1818                target.style_source.as_str(),
1819                ParserByteSpanV0 {
1820                    start: start as usize,
1821                    end: end as usize,
1822                },
1823            ),
1824            message: format!(
1825                "External Sass module '{}' is {} ({}); {}",
1826                edge.source,
1827                state.state_name,
1828                state.top_name,
1829                external_boundary_remediation_hint(state.state)
1830            ),
1831            tags: Vec::new(),
1832            create_custom_property: None,
1833        });
1834    }
1835    diagnostics
1836}
1837
1838/// Per-state remediation hint appended to the boundary diagnostic message.
1839fn external_boundary_remediation_hint(state: OmenaResolverBoundaryStateKindV0) -> &'static str {
1840    match state {
1841        OmenaResolverBoundaryStateKindV0::Missing => {
1842            "generate or provide a SIF artifact, or use --external ignored."
1843        }
1844        OmenaResolverBoundaryStateKindV0::Stale => {
1845            "regenerate the SIF/lockfile so its dependency interface hashes match."
1846        }
1847        OmenaResolverBoundaryStateKindV0::Partial => {
1848            "some referenced symbols are absent from the SIF interface; regenerate the SIF or fix the reference."
1849        }
1850        OmenaResolverBoundaryStateKindV0::Unresolved => {
1851            "the resolver cannot canonicalize this reference; fix the specifier or add it to the workspace."
1852        }
1853        OmenaResolverBoundaryStateKindV0::Resolved => "",
1854    }
1855}
1856
1857type SassSymbolKey = (&'static str, Option<String>, String);
1858
1859/// Fold the Sass-identifier name component so `_` and `-` compare equal.
1860///
1861/// Sass treats `$a-b` and `$a_b` (and likewise mixin/function names) as the *same*
1862/// identifier, so the symbol key must canonicalize the name before lookup; otherwise
1863/// a reference spelled `$ns-token` is flagged missing against a `$ns_token` definition
1864/// (and vice versa). Only the name is folded — the namespace (`@use` alias) is matched
1865/// elsewhere, and CSS custom properties (`--a-b` ≠ `--a_b`) never flow through this key
1866/// space, so they are untouched. (#48)
1867fn fold_sass_symbol_name(name: &str) -> String {
1868    name.replace('_', "-")
1869}
1870
1871fn sass_symbol_key(
1872    symbol_kind: &'static str,
1873    namespace: Option<String>,
1874    name: String,
1875) -> SassSymbolKey {
1876    let folded = fold_sass_symbol_name(&name);
1877    (symbol_kind, namespace, folded)
1878}
1879
1880fn collect_visible_sass_symbol_keys(
1881    target_style_path: &str,
1882    facts_by_path: &BTreeMap<&str, &OmenaQueryOmenaParserStyleFactsV0>,
1883    resolution: &OmenaQuerySassModuleCrossFileResolutionV0,
1884    external_sifs: &[OmenaQueryExternalSifInputV0],
1885) -> BTreeSet<SassSymbolKey> {
1886    let mut visible = BTreeSet::new();
1887    if let Some(facts) = facts_by_path.get(target_style_path) {
1888        visible.extend(
1889            own_sass_symbol_declaration_keys(facts)
1890                .into_iter()
1891                .map(|(symbol_kind, name)| sass_symbol_key(symbol_kind, None, name)),
1892        );
1893    }
1894
1895    for edge in resolution
1896        .edges
1897        .iter()
1898        .filter(|edge| edge.from_style_path == target_style_path)
1899    {
1900        let exported = if let Some(module_name) = sass_builtin_module_name(edge.source.as_str()) {
1901            builtin_sass_symbol_exports(module_name)
1902        } else if edge.status == "resolved" {
1903            let mut visiting = BTreeSet::new();
1904            edge.resolved_style_path
1905                .as_deref()
1906                .map(|path| {
1907                    collect_exported_sass_symbol_keys(
1908                        path,
1909                        facts_by_path,
1910                        resolution,
1911                        external_sifs,
1912                        &mut visiting,
1913                    )
1914                })
1915                .unwrap_or_default()
1916        } else if edge.status == "external" {
1917            find_omena_query_external_sif(edge.source.as_str(), external_sifs)
1918                .map(|sif| collect_sif_exported_sass_symbol_keys(&sif.sif))
1919                .unwrap_or_default()
1920        } else {
1921            BTreeSet::new()
1922        };
1923
1924        match edge.edge_kind {
1925            "sassUse"
1926                if edge.namespace_kind == Some("default")
1927                    || edge.namespace_kind == Some("alias") =>
1928            {
1929                if let Some(namespace) = edge.namespace.clone() {
1930                    visible.extend(exported.into_iter().map(|(symbol_kind, name)| {
1931                        sass_symbol_key(symbol_kind, Some(namespace.clone()), name)
1932                    }));
1933                }
1934            }
1935            "sassUse" if edge.namespace_kind == Some("wildcard") => {
1936                visible.extend(
1937                    exported
1938                        .into_iter()
1939                        .map(|(symbol_kind, name)| sass_symbol_key(symbol_kind, None, name)),
1940                );
1941            }
1942            "sassImport" => {
1943                visible.extend(
1944                    exported
1945                        .into_iter()
1946                        .map(|(symbol_kind, name)| sass_symbol_key(symbol_kind, None, name)),
1947                );
1948            }
1949            _ => {}
1950        }
1951    }
1952
1953    visible
1954}
1955
1956fn collect_exported_sass_symbol_keys(
1957    style_path: &str,
1958    facts_by_path: &BTreeMap<&str, &OmenaQueryOmenaParserStyleFactsV0>,
1959    resolution: &OmenaQuerySassModuleCrossFileResolutionV0,
1960    external_sifs: &[OmenaQueryExternalSifInputV0],
1961    visiting: &mut BTreeSet<String>,
1962) -> BTreeSet<(&'static str, String)> {
1963    if !visiting.insert(style_path.to_string()) {
1964        return BTreeSet::new();
1965    }
1966
1967    let mut exported = facts_by_path
1968        .get(style_path)
1969        .map(|facts| own_sass_symbol_declaration_keys(facts))
1970        .unwrap_or_default();
1971
1972    for edge in resolution
1973        .edges
1974        .iter()
1975        .filter(|edge| edge.from_style_path == style_path)
1976        .filter(|edge| edge.edge_kind == "sassForward" || edge.edge_kind == "sassImport")
1977    {
1978        let module_exports =
1979            if let Some(module_name) = sass_builtin_module_name(edge.source.as_str()) {
1980                builtin_sass_symbol_exports(module_name)
1981            } else if edge.status == "resolved" {
1982                edge.resolved_style_path
1983                    .as_deref()
1984                    .map(|path| {
1985                        collect_exported_sass_symbol_keys(
1986                            path,
1987                            facts_by_path,
1988                            resolution,
1989                            external_sifs,
1990                            visiting,
1991                        )
1992                    })
1993                    .unwrap_or_default()
1994            } else if edge.status == "external" {
1995                find_omena_query_external_sif(edge.source.as_str(), external_sifs)
1996                    .map(|sif| collect_sif_exported_sass_symbol_keys(&sif.sif))
1997                    .unwrap_or_default()
1998            } else {
1999                BTreeSet::new()
2000            };
2001
2002        for (symbol_kind, name) in module_exports {
2003            if !sass_forward_visibility_allows(edge, symbol_kind, name.as_str()) {
2004                continue;
2005            }
2006            let exported_name = if edge.edge_kind == "sassForward" {
2007                apply_sass_forward_prefix(edge.forward_prefix.as_deref(), name.as_str())
2008            } else {
2009                name
2010            };
2011            exported.insert((symbol_kind, exported_name));
2012        }
2013    }
2014
2015    visiting.remove(style_path);
2016    exported
2017}
2018
2019fn own_sass_symbol_declaration_keys(
2020    facts: &OmenaQueryOmenaParserStyleFactsV0,
2021) -> BTreeSet<(&'static str, String)> {
2022    facts
2023        .sass_symbol_facts
2024        .iter()
2025        .filter(|fact| is_omena_query_sass_symbol_declaration_kind(fact.kind))
2026        .map(|fact| (fact.symbol_kind, fact.name.clone()))
2027        .collect()
2028}
2029
2030fn collect_sif_exported_sass_symbol_keys(
2031    sif: &omena_sif::OmenaSifV1,
2032) -> BTreeSet<(&'static str, String)> {
2033    let mut exported = BTreeSet::new();
2034    exported.extend(sif.exports.variables.iter().map(|variable| {
2035        (
2036            "variable",
2037            variable.name.trim_start_matches('$').to_string(),
2038        )
2039    }));
2040    exported.extend(
2041        sif.exports
2042            .mixins
2043            .iter()
2044            .map(|mixin| ("mixin", mixin.name.clone())),
2045    );
2046    exported.extend(
2047        sif.exports
2048            .functions
2049            .iter()
2050            .map(|function| ("function", function.name.clone())),
2051    );
2052    exported
2053}
2054
2055fn find_omena_query_external_sif<'a>(
2056    canonical_url: &str,
2057    external_sifs: &'a [OmenaQueryExternalSifInputV0],
2058) -> Option<&'a OmenaQueryExternalSifInputV0> {
2059    external_sifs.iter().find(|input| {
2060        input.canonical_url == canonical_url || input.sif.canonical_url == canonical_url
2061    })
2062}
2063
2064fn sass_forward_visibility_allows(
2065    edge: &OmenaQuerySassModuleEdgeResolutionV0,
2066    symbol_kind: &'static str,
2067    name: &str,
2068) -> bool {
2069    let prefixed = apply_sass_forward_prefix(edge.forward_prefix.as_deref(), name);
2070    let matches_filter = |filter_name: &String| {
2071        filter_name == name
2072            || filter_name == prefixed.as_str()
2073            || filter_name.trim_start_matches('$') == name
2074            || filter_name.trim_start_matches('$') == prefixed.as_str()
2075            || (symbol_kind != "variable" && filter_name.trim_start_matches('@') == name)
2076    };
2077    match edge.visibility_filter_kind {
2078        Some("show") => edge.visibility_filter_names.iter().any(matches_filter),
2079        Some("hide") => !edge.visibility_filter_names.iter().any(matches_filter),
2080        _ => true,
2081    }
2082}
2083
2084fn apply_sass_forward_prefix(prefix: Option<&str>, name: &str) -> String {
2085    match prefix {
2086        Some(prefix) if prefix.contains('*') => prefix.replace('*', name),
2087        Some(prefix) => format!("{prefix}{name}"),
2088        None => name.to_string(),
2089    }
2090}
2091
2092fn is_omena_query_sass_builtin_symbol_reference_resolved(
2093    edges: &[omena_parser::ParsedSassModuleEdgeFact],
2094    symbol_kind: &'static str,
2095    namespace: Option<&str>,
2096    name: &str,
2097) -> bool {
2098    edges
2099        .iter()
2100        .filter(|edge| edge.kind == ParsedSassModuleEdgeFactKind::Use)
2101        .filter_map(|edge| {
2102            sass_builtin_module_name(edge.source.as_str()).map(|module| (edge, module))
2103        })
2104        .any(|(edge, module)| {
2105            let namespace_matches =
2106                match (namespace, edge.namespace_kind, edge.namespace.as_deref()) {
2107                    (Some(reference_namespace), Some("default" | "alias"), Some(use_namespace)) => {
2108                        reference_namespace == use_namespace
2109                    }
2110                    (None, Some("wildcard"), _) => true,
2111                    _ => false,
2112                };
2113            namespace_matches && sass_builtin_module_has_symbol(module, symbol_kind, name)
2114        })
2115}
2116
2117fn sass_builtin_module_name(source: &str) -> Option<&str> {
2118    source.strip_prefix("sass:")
2119}
2120
2121fn builtin_sass_symbol_exports(module: &str) -> BTreeSet<(&'static str, String)> {
2122    let mut exports = BTreeSet::new();
2123    for name in sass_builtin_module_function_names(module) {
2124        exports.insert(("function", (*name).to_string()));
2125    }
2126    for name in sass_builtin_module_mixin_names(module) {
2127        exports.insert(("mixin", (*name).to_string()));
2128    }
2129    for name in sass_builtin_module_variable_names(module) {
2130        exports.insert(("variable", (*name).to_string()));
2131    }
2132    exports
2133}
2134
2135fn sass_builtin_module_has_symbol(module: &str, symbol_kind: &'static str, name: &str) -> bool {
2136    match symbol_kind {
2137        "function" => sass_builtin_module_function_names(module).contains(&name),
2138        "mixin" => sass_builtin_module_mixin_names(module).contains(&name),
2139        "variable" => sass_builtin_module_variable_names(module).contains(&name),
2140        _ => false,
2141    }
2142}
2143
2144fn sass_builtin_module_function_names(module: &str) -> &'static [&'static str] {
2145    match module {
2146        "color" => &[
2147            "adjust",
2148            "alpha",
2149            "blue",
2150            "channel",
2151            "change",
2152            "complement",
2153            "desaturate",
2154            "fade-in",
2155            "fade-out",
2156            "grayscale",
2157            "green",
2158            "hsl",
2159            "hsla",
2160            "hue",
2161            "ie-hex-str",
2162            "invert",
2163            "is-legacy",
2164            "is-missing",
2165            "is-powerless",
2166            "lighten",
2167            "lightness",
2168            "mix",
2169            "opacify",
2170            "opacity",
2171            "red",
2172            "same",
2173            "saturate",
2174            "saturation",
2175            "scale",
2176            "space",
2177            "to-gamut",
2178            "to-space",
2179            "transparentize",
2180        ],
2181        "math" => &[
2182            "abs",
2183            "acos",
2184            "asin",
2185            "atan",
2186            "atan2",
2187            "ceil",
2188            "clamp",
2189            "compatible",
2190            "cos",
2191            "div",
2192            "floor",
2193            "hypot",
2194            "is-unitless",
2195            "log",
2196            "max",
2197            "min",
2198            "percentage",
2199            "pow",
2200            "random",
2201            "round",
2202            "sin",
2203            "sqrt",
2204            "tan",
2205            "unit",
2206        ],
2207        "list" => &[
2208            "append",
2209            "index",
2210            "is-bracketed",
2211            "join",
2212            "length",
2213            "separator",
2214            "set-nth",
2215            "slash",
2216            "nth",
2217            "zip",
2218        ],
2219        "map" => &[
2220            "deep-merge",
2221            "deep-remove",
2222            "get",
2223            "has-key",
2224            "keys",
2225            "merge",
2226            "remove",
2227            "set",
2228            "values",
2229        ],
2230        "string" => &[
2231            "index",
2232            "insert",
2233            "length",
2234            "quote",
2235            "slice",
2236            "split",
2237            "to-lower-case",
2238            "to-upper-case",
2239            "unique-id",
2240            "unquote",
2241        ],
2242        "selector" => &[
2243            "append",
2244            "extend",
2245            "is-superselector",
2246            "nest",
2247            "parse",
2248            "replace",
2249            "simple-selectors",
2250            "unify",
2251        ],
2252        "meta" => &[
2253            "accepts-content",
2254            "calc-args",
2255            "calc-name",
2256            "call",
2257            "content-exists",
2258            "feature-exists",
2259            "function-exists",
2260            "get-function",
2261            // `meta.get-mixin` is a real `sass:meta` function added in Sass 1.77. (#44 D2)
2262            "get-mixin",
2263            "global-variable-exists",
2264            "inspect",
2265            "keywords",
2266            "mixin-exists",
2267            "module-functions",
2268            "module-mixins",
2269            "module-variables",
2270            "type-of",
2271            "variable-exists",
2272        ],
2273        _ => &[],
2274    }
2275}
2276
2277fn sass_builtin_module_mixin_names(module: &str) -> &'static [&'static str] {
2278    match module {
2279        // `meta.apply` is a real `sass:meta` mixin added in Sass 1.77. (#44 D2)
2280        "meta" => &["apply", "load-css"],
2281        _ => &[],
2282    }
2283}
2284
2285fn sass_builtin_module_variable_names(module: &str) -> &'static [&'static str] {
2286    match module {
2287        "math" => &["e", "epsilon", "max-safe-integer", "min-safe-integer", "pi"],
2288        _ => &[],
2289    }
2290}
2291
2292/// RFC-0007-F (#46): composes-target validation restricted to outcomes that are fully resolvable
2293/// from a single file with no cross-file `--source` context. Only `composes: x` / `composes: x, y`
2294/// (Local edges) target the file's own selectors and can be checked here; `composes: x from global`
2295/// (Global edges) reference no concrete selector and produce nothing; `composes: x from './other'`
2296/// (External edges) require the sibling module's facts, which a single-file invocation does not have,
2297/// so they are deliberately skipped to avoid a false `missingComposedSelector`/`missingComposedModule`.
2298/// @value imports are always cross-file and are likewise excluded.
2299pub fn summarize_omena_query_css_modules_local_composes_style_diagnostics(
2300    target_style_path: &str,
2301    target_source: &str,
2302) -> Vec<OmenaQueryStyleDiagnosticV0> {
2303    let dialect = omena_parser_dialect_for_style_path(target_style_path);
2304    let target_facts = collect_omena_query_omena_parser_style_facts_raw(target_source, dialect);
2305    let target_class_names = target_facts
2306        .selectors
2307        .iter()
2308        .filter(|selector| selector.kind == ParsedSelectorFactKind::Class)
2309        .map(|selector| selector.name.as_str())
2310        .collect::<BTreeSet<_>>();
2311    let mut diagnostics = Vec::new();
2312
2313    for edge in target_facts.css_module_composes_edges {
2314        // Global edges resolve to no concrete selector; External edges need cross-file facts.
2315        // Both are outside the single-file-resolvable surface, so only Local edges are validated.
2316        if edge.kind != ParsedCssModuleComposesEdgeKind::Local {
2317            continue;
2318        }
2319        let start: u32 = edge.range.start().into();
2320        let end: u32 = edge.range.end().into();
2321        let range = parser_range_for_byte_span(
2322            target_source,
2323            ParserByteSpanV0 {
2324                start: start as usize,
2325                end: end as usize,
2326            },
2327        );
2328        for target_name in edge.target_names {
2329            if target_class_names.contains(target_name.as_str()) {
2330                continue;
2331            }
2332            diagnostics.push(OmenaQueryStyleDiagnosticV0 {
2333                code: "missingComposedSelector",
2334                severity: "warning",
2335                provenance: vec![
2336                    "omena-parser.css-modules-composes-facts",
2337                    "omena-query.css-modules-resolution-diagnostics",
2338                ],
2339                range,
2340                message: format!(
2341                    "Selector '.{}' not found in this file for composes.",
2342                    target_name
2343                ),
2344                tags: Vec::new(),
2345                create_custom_property: None,
2346            });
2347        }
2348    }
2349
2350    diagnostics
2351}
2352
2353pub fn summarize_omena_query_css_modules_resolution_style_diagnostics(
2354    target_style_path: &str,
2355    target_source: &str,
2356    style_sources: &[OmenaQueryStyleSourceInputV0],
2357    package_manifests: &[OmenaQueryStylePackageManifestV0],
2358) -> Vec<OmenaQueryStyleDiagnosticV0> {
2359    let style_source_refs = style_sources
2360        .iter()
2361        .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
2362        .collect::<Vec<_>>();
2363    let style_fact_entries = collect_omena_query_style_fact_entries(style_source_refs.as_slice());
2364    let available_style_paths = style_fact_entries
2365        .iter()
2366        .map(|entry| entry.style_path.as_str())
2367        .collect::<BTreeSet<_>>();
2368    let facts_by_path = style_fact_entries
2369        .iter()
2370        .map(|entry| (entry.style_path.as_str(), entry.facts.clone()))
2371        .collect::<BTreeMap<_, _>>();
2372    let dialect = omena_parser_dialect_for_style_path(target_style_path);
2373    let target_facts = collect_omena_query_omena_parser_style_facts_raw(target_source, dialect);
2374    let mut diagnostics = Vec::new();
2375
2376    for edge in target_facts.css_module_composes_edges {
2377        if edge.kind == ParsedCssModuleComposesEdgeKind::Global {
2378            continue;
2379        }
2380        let start: u32 = edge.range.start().into();
2381        let end: u32 = edge.range.end().into();
2382        let range = parser_range_for_byte_span(
2383            target_source,
2384            ParserByteSpanV0 {
2385                start: start as usize,
2386                end: end as usize,
2387            },
2388        );
2389        let target_style = if edge.kind == ParsedCssModuleComposesEdgeKind::External {
2390            let Some(source) = edge.import_source.as_deref() else {
2391                continue;
2392            };
2393            let Some(resolved_style_path) = resolve_style_module_source(
2394                target_style_path,
2395                source,
2396                &available_style_paths,
2397                package_manifests,
2398            ) else {
2399                diagnostics.push(OmenaQueryStyleDiagnosticV0 {
2400                    code: "missingComposedModule",
2401                    severity: "warning",
2402                    provenance: vec![
2403                        "omena-parser.css-modules-composes-facts",
2404                        "omena-resolver.style-module-resolution",
2405                    ],
2406                    range,
2407                    message: format!("Cannot resolve composed CSS Module '{}'.", source),
2408                    tags: Vec::new(),
2409                    create_custom_property: None,
2410                });
2411                continue;
2412            };
2413            resolved_style_path
2414        } else {
2415            target_style_path.to_string()
2416        };
2417        let target_class_names = facts_by_path
2418            .get(target_style.as_str())
2419            .map(|facts| facts.class_selector_names.as_slice())
2420            .unwrap_or(&[])
2421            .iter()
2422            .map(String::as_str)
2423            .collect::<BTreeSet<_>>();
2424
2425        for target_name in edge.target_names {
2426            if target_class_names.contains(target_name.as_str()) {
2427                continue;
2428            }
2429            let message = if let Some(source) = edge.import_source.as_deref() {
2430                format!(
2431                    "Selector '.{}' not found in composed module '{}'.",
2432                    target_name, source
2433                )
2434            } else {
2435                format!(
2436                    "Selector '.{}' not found in this file for composes.",
2437                    target_name
2438                )
2439            };
2440            diagnostics.push(OmenaQueryStyleDiagnosticV0 {
2441                code: "missingComposedSelector",
2442                severity: "warning",
2443                provenance: vec![
2444                    "omena-parser.css-modules-composes-facts",
2445                    "omena-query.css-modules-resolution-diagnostics",
2446                ],
2447                range,
2448                message,
2449                tags: Vec::new(),
2450                create_custom_property: None,
2451            });
2452        }
2453    }
2454
2455    let mut reported_missing_value_modules = BTreeSet::new();
2456    for edge in target_facts.css_module_value_import_edges {
2457        let start: u32 = edge.range.start().into();
2458        let end: u32 = edge.range.end().into();
2459        let range = parser_range_for_byte_span(
2460            target_source,
2461            ParserByteSpanV0 {
2462                start: start as usize,
2463                end: end as usize,
2464            },
2465        );
2466        let Some(resolved_style_path) = resolve_style_module_source(
2467            target_style_path,
2468            &edge.import_source,
2469            &available_style_paths,
2470            package_manifests,
2471        ) else {
2472            if reported_missing_value_modules.insert(edge.import_source.clone()) {
2473                diagnostics.push(OmenaQueryStyleDiagnosticV0 {
2474                    code: "missingValueModule",
2475                    severity: "warning",
2476                    provenance: vec![
2477                        "omena-parser.css-modules-value-facts",
2478                        "omena-resolver.style-module-resolution",
2479                    ],
2480                    range,
2481                    message: format!(
2482                        "Cannot resolve imported @value module '{}'.",
2483                        edge.import_source
2484                    ),
2485                    tags: Vec::new(),
2486                    create_custom_property: None,
2487                });
2488            }
2489            continue;
2490        };
2491        let target_value_names = facts_by_path
2492            .get(resolved_style_path.as_str())
2493            .map(|facts| facts.css_module_value_definition_names.as_slice())
2494            .unwrap_or(&[])
2495            .iter()
2496            .map(String::as_str)
2497            .collect::<BTreeSet<_>>();
2498        if target_value_names.contains(edge.remote_name.as_str()) {
2499            continue;
2500        }
2501        let message = if edge.local_name == edge.remote_name {
2502            format!(
2503                "@value '{}' not found in '{}'.",
2504                edge.remote_name, edge.import_source
2505            )
2506        } else {
2507            format!(
2508                "@value '{}' not found in '{}' for local binding '{}'.",
2509                edge.remote_name, edge.import_source, edge.local_name
2510            )
2511        };
2512        diagnostics.push(OmenaQueryStyleDiagnosticV0 {
2513            code: "missingImportedValue",
2514            severity: "warning",
2515            provenance: vec![
2516                "omena-parser.css-modules-value-facts",
2517                "omena-query.css-modules-resolution-diagnostics",
2518            ],
2519            range,
2520            message,
2521            tags: Vec::new(),
2522            create_custom_property: None,
2523        });
2524    }
2525
2526    diagnostics
2527}
2528
2529pub fn summarize_omena_query_unused_selector_style_diagnostics(
2530    target_style_path: &str,
2531    target_source: &str,
2532    style_sources: &[OmenaQueryStyleSourceInputV0],
2533    source_documents: &[OmenaQuerySourceDocumentInputV0],
2534    package_manifests: &[OmenaQueryStylePackageManifestV0],
2535    classname_transform: Option<&str>,
2536) -> Vec<OmenaQueryStyleDiagnosticV0> {
2537    summarize_omena_query_unused_selector_style_diagnostics_with_path_mappings(
2538        target_style_path,
2539        target_source,
2540        style_sources,
2541        source_documents,
2542        package_manifests,
2543        classname_transform,
2544        &[],
2545        &[],
2546    )
2547}
2548
2549#[allow(clippy::too_many_arguments)]
2550pub fn summarize_omena_query_unused_selector_style_diagnostics_with_path_mappings(
2551    target_style_path: &str,
2552    target_source: &str,
2553    style_sources: &[OmenaQueryStyleSourceInputV0],
2554    source_documents: &[OmenaQuerySourceDocumentInputV0],
2555    package_manifests: &[OmenaQueryStylePackageManifestV0],
2556    classname_transform: Option<&str>,
2557    bundler_path_mappings: &[OmenaResolverBundlerPathAliasMappingV0],
2558    tsconfig_path_mappings: &[OmenaResolverTsconfigPathMappingV0],
2559) -> Vec<OmenaQueryStyleDiagnosticV0> {
2560    if source_documents.is_empty() {
2561        return Vec::new();
2562    }
2563
2564    let style_source_refs = style_sources
2565        .iter()
2566        .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
2567        .collect::<Vec<_>>();
2568    let style_fact_entries = collect_omena_query_style_fact_entries(style_source_refs.as_slice());
2569    let available_style_paths = style_fact_entries
2570        .iter()
2571        .map(|entry| entry.style_path.as_str())
2572        .collect::<BTreeSet<_>>();
2573    let facts_by_path = style_fact_entries
2574        .iter()
2575        .map(|entry| (entry.style_path.as_str(), entry.facts.clone()))
2576        .collect::<BTreeMap<_, _>>();
2577    let aliases_by_path = collect_classname_transform_aliases(&facts_by_path, classname_transform);
2578    let (mut used_selectors, unresolved_dynamic_usage, has_unresolved_style_import) =
2579        collect_omena_query_source_selector_usage_by_style(
2580            &available_style_paths,
2581            source_documents,
2582            package_manifests,
2583            &aliases_by_path,
2584            bundler_path_mappings,
2585            tsconfig_path_mappings,
2586        );
2587    if unresolved_dynamic_usage.contains(target_style_path) {
2588        return Vec::new();
2589    }
2590    // RFC-0007-J (#50): when a source document imports a style module via a specifier we cannot
2591    // resolve (e.g. a workspace alias `@/styles/a.module.scss` with no tsconfig/bundler path
2592    // mapping wired in), we do not know which module its `cx('foo')`/`styles.foo` references point
2593    // at — so we cannot prove any selector is unused. References/goto stay lenient with that
2594    // ambiguity; the negative assertion (`unusedSelector`) must be conservative to match, instead
2595    // of dimming every selector in the file. Treat such documents as "possibly using" and skip the
2596    // lint for this target rather than emitting a wall of false positives.
2597    if has_unresolved_style_import {
2598        return Vec::new();
2599    }
2600
2601    let composes_graph = collect_css_modules_composes_adjacency(
2602        &facts_by_path,
2603        &available_style_paths,
2604        package_manifests,
2605    );
2606    propagate_omena_query_composes_usage(&composes_graph, &mut used_selectors);
2607
2608    let dialect = omena_parser_dialect_for_style_path(target_style_path);
2609    let target_facts = collect_omena_query_omena_parser_style_facts_raw(target_source, dialect);
2610    let used_in_target = used_selectors
2611        .get(target_style_path)
2612        .cloned()
2613        .unwrap_or_default();
2614    let mut emitted = BTreeSet::new();
2615
2616    target_facts
2617        .selectors
2618        .into_iter()
2619        .filter(|selector| selector.kind == ParsedSelectorFactKind::Class)
2620        .filter(|selector| !used_in_target.contains(selector.name.as_str()))
2621        .filter_map(|selector| {
2622            let start: u32 = selector.range.start().into();
2623            let end: u32 = selector.range.end().into();
2624            if !emitted.insert(selector.name.clone()) {
2625                return None;
2626            }
2627            Some(OmenaQueryStyleDiagnosticV0 {
2628                code: "unusedSelector",
2629                severity: "hint",
2630                provenance: vec![
2631                    "omena-parser.selector-facts",
2632                    "omena-query.source-selector-usage",
2633                ],
2634                range: parser_range_for_byte_span(
2635                    target_source,
2636                    ParserByteSpanV0 {
2637                        start: start as usize,
2638                        end: end as usize,
2639                    },
2640                ),
2641                message: format!("Selector '.{}' is declared but never used.", selector.name),
2642                tags: vec![LSP_DIAGNOSTIC_TAG_UNNECESSARY],
2643                create_custom_property: None,
2644            })
2645        })
2646        .collect()
2647}
2648
2649fn collect_omena_query_source_selector_usage_by_style(
2650    available_style_paths: &BTreeSet<&str>,
2651    source_documents: &[OmenaQuerySourceDocumentInputV0],
2652    package_manifests: &[OmenaQueryStylePackageManifestV0],
2653    aliases_by_path: &BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
2654    bundler_path_mappings: &[OmenaResolverBundlerPathAliasMappingV0],
2655    tsconfig_path_mappings: &[OmenaResolverTsconfigPathMappingV0],
2656) -> (BTreeMap<String, BTreeSet<String>>, BTreeSet<String>, bool) {
2657    let mut used_selectors = BTreeMap::<String, BTreeSet<String>>::new();
2658    let mut unresolved_dynamic_usage = BTreeSet::<String>::new();
2659    // RFC-0007-J (#50): tracks whether any document imports a style-like specifier we failed to
2660    // resolve (an unwired workspace alias). Such a document's selector usages cannot be attributed
2661    // to a concrete module, so the caller treats the file as "possibly used" instead of dimming
2662    // every selector.
2663    let mut has_unresolved_style_import = false;
2664
2665    for document in source_documents {
2666        let imports = summarize_omena_query_source_import_declarations_for_source_language(
2667            document.source_path.as_str(),
2668            &document.source_source,
2669            None,
2670        );
2671        let mut imported_style_bindings = Vec::new();
2672        let mut classnames_bind_bindings = Vec::new();
2673        for import in imports.imports {
2674            if import.specifier == "classnames/bind" {
2675                classnames_bind_bindings.push(import.binding);
2676                continue;
2677            }
2678            let Some(style_path) = resolve_style_module_source_with_path_mappings(
2679                &document.source_path,
2680                &import.specifier,
2681                available_style_paths,
2682                package_manifests,
2683                bundler_path_mappings,
2684                tsconfig_path_mappings,
2685            ) else {
2686                if specifier_targets_style_module(&import.specifier) {
2687                    has_unresolved_style_import = true;
2688                }
2689                continue;
2690            };
2691            imported_style_bindings.push(OmenaQuerySourceImportedStyleBindingV0 {
2692                binding: import.binding,
2693                style_uri: style_path,
2694            });
2695        }
2696        if imported_style_bindings.is_empty() {
2697            continue;
2698        }
2699
2700        let index = summarize_omena_query_source_syntax_index_for_source_language(
2701            document.source_path.as_str(),
2702            &document.source_source,
2703            None,
2704            imported_style_bindings,
2705            classnames_bind_bindings,
2706        );
2707        for reference in index.selector_references {
2708            let Some(target_style_path) = reference.target_style_uri else {
2709                continue;
2710            };
2711            let Some(selector_name) = reference.selector_name.or_else(|| {
2712                source_reference_text_selector_name(&document.source_source, reference.byte_span)
2713            }) else {
2714                unresolved_dynamic_usage.insert(target_style_path);
2715                continue;
2716            };
2717            let used_for_style = used_selectors.entry(target_style_path.clone()).or_default();
2718            if let Some(canonical_names) = aliases_by_path
2719                .get(target_style_path.as_str())
2720                .and_then(|aliases| aliases.get(selector_name.as_str()))
2721            {
2722                used_for_style.extend(canonical_names.iter().cloned());
2723            } else {
2724                used_for_style.insert(selector_name);
2725            }
2726        }
2727    }
2728
2729    (
2730        used_selectors,
2731        unresolved_dynamic_usage,
2732        has_unresolved_style_import,
2733    )
2734}
2735
2736/// Whether an import specifier names a CSS-family style module (so failing to resolve it is a
2737/// style-resolution gap worth treating conservatively, RFC-0007-J #50) rather than an ordinary
2738/// JS/TS dependency. A query string or hash on the specifier (e.g. `?inline`) is ignored.
2739fn specifier_targets_style_module(specifier: &str) -> bool {
2740    let path = specifier
2741        .split(['?', '#'])
2742        .next()
2743        .unwrap_or(specifier)
2744        .to_ascii_lowercase();
2745    path.ends_with(".css")
2746        || path.ends_with(".scss")
2747        || path.ends_with(".sass")
2748        || path.ends_with(".less")
2749}
2750
2751fn collect_classname_transform_aliases(
2752    facts_by_path: &BTreeMap<&str, OmenaQueryOmenaParserStyleFactsV0>,
2753    classname_transform: Option<&str>,
2754) -> BTreeMap<String, BTreeMap<String, BTreeSet<String>>> {
2755    let mut aliases_by_path = BTreeMap::<String, BTreeMap<String, BTreeSet<String>>>::new();
2756    for (style_path, facts) in facts_by_path {
2757        let aliases = aliases_by_path
2758            .entry((*style_path).to_string())
2759            .or_default();
2760        for selector_name in &facts.class_selector_names {
2761            for alias in classname_transform_aliases(selector_name.as_str(), classname_transform) {
2762                aliases
2763                    .entry(alias)
2764                    .or_default()
2765                    .insert(selector_name.clone());
2766            }
2767        }
2768    }
2769    aliases_by_path
2770}
2771
2772fn classname_transform_aliases(name: &str, classname_transform: Option<&str>) -> Vec<String> {
2773    match classname_transform.unwrap_or("asIs") {
2774        "camelCase" => keep_original_plus_transformed(name, to_ascii_camel_case(name)),
2775        "camelCaseOnly" => vec![to_ascii_camel_case(name)],
2776        "dashes" => keep_original_plus_transformed(name, dashes_to_ascii_camel(name)),
2777        "dashesOnly" => vec![dashes_to_ascii_camel(name)],
2778        _ => vec![name.to_string()],
2779    }
2780}
2781
2782fn keep_original_plus_transformed(name: &str, transformed: String) -> Vec<String> {
2783    if transformed == name {
2784        vec![name.to_string()]
2785    } else {
2786        vec![name.to_string(), transformed]
2787    }
2788}
2789
2790fn dashes_to_ascii_camel(name: &str) -> String {
2791    transform_ascii_separated_name(name, |byte| byte == b'-')
2792}
2793
2794fn to_ascii_camel_case(name: &str) -> String {
2795    transform_ascii_separated_name(name, |byte| byte == b'-' || byte == b'_' || byte == b' ')
2796}
2797
2798fn transform_ascii_separated_name(name: &str, is_separator: impl Fn(u8) -> bool) -> String {
2799    let mut output = String::with_capacity(name.len());
2800    let mut capitalize_next = false;
2801    for byte in name.bytes() {
2802        if is_separator(byte) {
2803            capitalize_next = true;
2804            continue;
2805        }
2806        if capitalize_next {
2807            output.push((byte as char).to_ascii_uppercase());
2808            capitalize_next = false;
2809            continue;
2810        }
2811        output.push(byte as char);
2812    }
2813    output
2814}
2815
2816fn propagate_omena_query_composes_usage(
2817    composes_graph: &BTreeMap<CssModulesComposesNode, BTreeSet<CssModulesComposesNode>>,
2818    used_selectors: &mut BTreeMap<String, BTreeSet<String>>,
2819) {
2820    let mut used_nodes = used_selectors
2821        .iter()
2822        .flat_map(|(style_path, selectors)| {
2823            selectors
2824                .iter()
2825                .map(|selector_name| CssModulesComposesNode {
2826                    style_path: style_path.clone(),
2827                    selector_name: selector_name.clone(),
2828                })
2829        })
2830        .collect::<BTreeSet<_>>();
2831
2832    let mut changed = true;
2833    while changed {
2834        changed = false;
2835        for (owner, targets) in composes_graph {
2836            if !used_nodes.contains(owner) {
2837                continue;
2838            }
2839            for target in targets {
2840                if used_nodes.insert(target.clone()) {
2841                    used_selectors
2842                        .entry(target.style_path.clone())
2843                        .or_default()
2844                        .insert(target.selector_name.clone());
2845                    changed = true;
2846                }
2847            }
2848        }
2849    }
2850}
2851
2852#[cfg(test)]
2853mod tests {
2854    use super::*;
2855
2856    #[test]
2857    fn sass_import_is_plain_css_classifies_css_forms() {
2858        // CSS-form imports Sass explicitly KEEPS (must NOT be flagged).
2859        assert!(sass_import_is_plain_css("url(theme.css)"));
2860        assert!(sass_import_is_plain_css("url(theme.scss)")); // unquoted url is always CSS
2861        assert!(sass_import_is_plain_css("vendor.css"));
2862        assert!(sass_import_is_plain_css("VENDOR.CSS")); // case-insensitive
2863        assert!(sass_import_is_plain_css("//cdn.example/x.css"));
2864        assert!(sass_import_is_plain_css("https://x.com/y.css"));
2865        assert!(sass_import_is_plain_css("http://x.com/y")); // protocol URL, no .css
2866    }
2867
2868    #[test]
2869    fn sass_import_is_plain_css_keeps_partials_flaggable() {
2870        // Over-correction guard: genuine Sass-form partials must STILL be classified
2871        // as Sass imports (i.e. NOT plain CSS), so the deprecation hint still fires.
2872        assert!(!sass_import_is_plain_css("partial"));
2873        assert!(!sass_import_is_plain_css("./legacy"));
2874        assert!(!sass_import_is_plain_css("foundation/buttons"));
2875        assert!(!sass_import_is_plain_css("legacy")); // bare partial name
2876    }
2877
2878    #[test]
2879    fn media_qualified_import_is_not_deprecated() {
2880        // RFC-0007-D1 (#44): `@import "foo" screen` and `@import "foo" (min-width: ...)`
2881        // are kept as plain CSS by Sass; the media qualifier must suppress the hint.
2882        let screen = summarize_omena_query_sass_import_deprecation_hints(
2883            "theme.scss",
2884            "@import \"foo\" screen;",
2885        );
2886        assert!(
2887            screen.is_empty(),
2888            "media-qualified (Ident) @import must NOT warn deprecatedSassImport, got {screen:?}"
2889        );
2890        let feature = summarize_omena_query_sass_import_deprecation_hints(
2891            "theme.scss",
2892            "@import \"foo\" (min-width: 100px);",
2893        );
2894        assert!(
2895            feature.is_empty(),
2896            "media-feature-qualified @import must NOT warn deprecatedSassImport, got {feature:?}"
2897        );
2898    }
2899
2900    #[test]
2901    fn bare_partial_import_still_deprecated() {
2902        // Over-correction guard: a genuine Sass-form `@import 'partial'` (no media, no
2903        // url, no `.css`) MUST still warn deprecatedSassImport.
2904        let diagnostics =
2905            summarize_omena_query_sass_import_deprecation_hints("theme.scss", "@import 'partial';");
2906        assert_eq!(
2907            diagnostics.len(),
2908            1,
2909            "bare Sass partial @import must still warn, got {diagnostics:?}"
2910        );
2911        assert_eq!(diagnostics[0].code, "deprecatedSassImport");
2912    }
2913
2914    #[test]
2915    fn media_qualified_comma_peer_classifies_per_target() {
2916        // `@import "a", "b" screen`: only `"b"` is media-qualified; `"a"` stays a Sass
2917        // partial and must still warn. Per-target classification, not per-statement.
2918        let diagnostics = summarize_omena_query_sass_import_deprecation_hints(
2919            "theme.scss",
2920            "@import \"a\", \"b\" screen;",
2921        );
2922        assert_eq!(
2923            diagnostics.len(),
2924            1,
2925            "exactly the bare partial peer must warn, got {diagnostics:?}"
2926        );
2927    }
2928
2929    /// Durable CI drift check: pin the full `sass:meta` module surface against a
2930    /// known-good Sass 1.77 member set. If a future edit adds or removes a member
2931    /// (or the upstream module surface changes and we update one site but not the
2932    /// pinned set), this fails loudly instead of silently rotting. (#44 D2)
2933    ///
2934    /// Functions and mixins are tracked separately because Sass distinguishes them
2935    /// (`meta.get-mixin` is a function returning a mixin reference; `meta.apply` is a
2936    /// mixin invoked via `@include`).
2937    #[test]
2938    fn sass_meta_allowlist_matches_pinned_1_77_surface() {
2939        let pinned_functions: BTreeSet<&str> = [
2940            "accepts-content",
2941            "calc-args",
2942            "calc-name",
2943            "call",
2944            "content-exists",
2945            "feature-exists",
2946            "function-exists",
2947            "get-function",
2948            "get-mixin",
2949            "global-variable-exists",
2950            "inspect",
2951            "keywords",
2952            "mixin-exists",
2953            "module-functions",
2954            "module-mixins",
2955            "module-variables",
2956            "type-of",
2957            "variable-exists",
2958        ]
2959        .into_iter()
2960        .collect();
2961        let pinned_mixins: BTreeSet<&str> = ["apply", "load-css"].into_iter().collect();
2962
2963        let actual_functions: BTreeSet<&str> = sass_builtin_module_function_names("meta")
2964            .iter()
2965            .copied()
2966            .collect();
2967        let actual_mixins: BTreeSet<&str> = sass_builtin_module_mixin_names("meta")
2968            .iter()
2969            .copied()
2970            .collect();
2971
2972        assert_eq!(
2973            actual_functions, pinned_functions,
2974            "sass:meta function allowlist drifted from pinned Sass 1.77 surface; \
2975             update both the allowlist and this pinned set together"
2976        );
2977        assert_eq!(
2978            actual_mixins, pinned_mixins,
2979            "sass:meta mixin allowlist drifted from pinned Sass 1.77 surface; \
2980             update both the allowlist and this pinned set together"
2981        );
2982    }
2983
2984    // RFC-0007-F (#46): single-file local-composes validation.
2985
2986    #[test]
2987    fn local_composes_flags_real_same_file_typo() {
2988        // True positive: `composes: missing` references a class that does not exist in this
2989        // file. With no cross-file context, this is fully resolvable and MUST be flagged.
2990        let source = ".base { color: red; }\n.button { composes: missing; }\n";
2991        let diagnostics = summarize_omena_query_css_modules_local_composes_style_diagnostics(
2992            "/tmp/foo.module.scss",
2993            source,
2994        );
2995        assert_eq!(diagnostics.len(), 1, "expected one missingComposedSelector");
2996        assert_eq!(diagnostics[0].code, "missingComposedSelector");
2997        assert!(
2998            diagnostics[0].message.contains("not found in this file"),
2999            "message should reference same-file resolution, got: {}",
3000            diagnostics[0].message
3001        );
3002    }
3003
3004    #[test]
3005    fn local_composes_keeps_resolvable_target_silent() {
3006        // A local composes target that DOES exist in the file must NOT be flagged.
3007        let source = ".base { color: red; }\n.button { composes: base; }\n";
3008        let diagnostics = summarize_omena_query_css_modules_local_composes_style_diagnostics(
3009            "/tmp/foo.module.scss",
3010            source,
3011        );
3012        assert!(
3013            diagnostics.is_empty(),
3014            "resolvable local composes target should not be flagged, got: {diagnostics:?}"
3015        );
3016    }
3017
3018    #[test]
3019    fn local_composes_does_not_flag_external_target_without_source() {
3020        // Over-correction guard: `composes: x from './other'` is an External edge that needs the
3021        // sibling module's facts. In single-file mode we have no access to `./other`, so we must
3022        // NOT invent a missingComposedSelector/missingComposedModule for it.
3023        let source = ".button { composes: shared from './other.module.scss'; color: blue; }\n";
3024        let diagnostics = summarize_omena_query_css_modules_local_composes_style_diagnostics(
3025            "/tmp/foo.module.scss",
3026            source,
3027        );
3028        assert!(
3029            diagnostics.is_empty(),
3030            "external composes target must not be flagged without cross-file source, got: {diagnostics:?}"
3031        );
3032    }
3033
3034    #[test]
3035    fn local_composes_does_not_flag_global_target() {
3036        // `composes: x from global` references no concrete selector and must produce nothing.
3037        let source = ".button { composes: someGlobal from global; color: blue; }\n";
3038        let diagnostics = summarize_omena_query_css_modules_local_composes_style_diagnostics(
3039            "/tmp/foo.module.scss",
3040            source,
3041        );
3042        assert!(
3043            diagnostics.is_empty(),
3044            "global composes target must not be flagged, got: {diagnostics:?}"
3045        );
3046    }
3047
3048    #[test]
3049    fn single_file_summary_includes_local_composes_typo() -> Result<(), String> {
3050        // The CLI bare path (`style-diagnostics foo` with no --source) routes through this
3051        // wrapper. A real same-file composes typo must surface even without --source, closing
3052        // the invocation-mode inconsistency the issue describes.
3053        let style_uri = "/tmp/foo.module.scss";
3054        let source = ".base { color: red; }\n.button { composes: missing; }\n";
3055        let candidates = summarize_omena_query_style_hover_candidates(style_uri, source)
3056            .ok_or("hover candidates")?;
3057        let summary = summarize_omena_query_style_diagnostics_for_file_with_local_composes(
3058            style_uri,
3059            source,
3060            candidates.candidates.as_slice(),
3061        );
3062        assert!(
3063            summary
3064                .diagnostics
3065                .iter()
3066                .any(|diagnostic| diagnostic.code == "missingComposedSelector"),
3067            "bare single-file summary should surface the local composes typo, got: {:?}",
3068            summary.diagnostics
3069        );
3070        assert_eq!(summary.diagnostic_count, summary.diagnostics.len());
3071        Ok(())
3072    }
3073
3074    #[test]
3075    fn single_file_summary_does_not_flag_external_composes() -> Result<(), String> {
3076        // Over-correction guard at the wrapper level: a clean file whose only composes target is
3077        // cross-file must stay free of composes diagnostics in single-file mode.
3078        let style_uri = "/tmp/foo.module.scss";
3079        let source = ".button { composes: shared from './other.module.scss'; color: blue; }\n";
3080        let candidates = summarize_omena_query_style_hover_candidates(style_uri, source)
3081            .ok_or("hover candidates")?;
3082        let summary = summarize_omena_query_style_diagnostics_for_file_with_local_composes(
3083            style_uri,
3084            source,
3085            candidates.candidates.as_slice(),
3086        );
3087        assert!(
3088            !summary.diagnostics.iter().any(|diagnostic| {
3089                diagnostic.code == "missingComposedSelector"
3090                    || diagnostic.code == "missingComposedModule"
3091            }),
3092            "external composes target must not be flagged in single-file mode, got: {:?}",
3093            summary.diagnostics
3094        );
3095        Ok(())
3096    }
3097}