Skip to main content

omena_query/style/
dynamic_classname.rs

1//! Context-sensitive (k-CFA) dynamic-className M-tier diagnostics for the
2//! consumer-facing `omena-query` source path.
3//!
4//! The cascade/RG-flow query diagnostics already route through the checker
5//! orchestrator gate. This module adds the missing M-tier handoff: dynamic
6//! className call sites are flowed through the real k-limited call-string
7//! analysis (`analyze_k_limited_call_site_flows`) and the joined per-context
8//! exit values are fed into the checker M-tier rules. The emitted
9//! `no-unknown-dynamic-class` / `no-imprecise-value` / `no-impossible-selector`
10//! diagnostics therefore reflect the k-CFA-joined values that the LSP surface
11//! consumes, not only the 0/1-CFA result, and the diagnostic set changes when
12//! the context-depth bound `k` changes.
13//!
14//! The call sites can either be supplied through the explicit input contract
15//! (`OmenaQueryDynamicClassnameMTierInputV0`) or harvested directly from a source
16//! file's syntax-index template type-fact targets, so the default workspace
17//! diagnostic path raises these M-tier diagnostics without an external producer.
18
19use super::*;
20
21use omena_query_checker_orchestrator::{
22    AbstractClassValueV0, OmenaQueryCheckerKLimitedFlowContextV0,
23    run_omena_query_checker_k_limited_flow_m_tier_gate_v0,
24};
25
26/// Deserializable abstract class value used to seed a dynamic-className call-site
27/// exit value for context-sensitive M-tier analysis.
28#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
29#[serde(
30    tag = "kind",
31    rename_all = "camelCase",
32    rename_all_fields = "camelCase"
33)]
34pub enum OmenaQueryDynamicClassValueInputV0 {
35    Bottom,
36    Exact {
37        value: String,
38    },
39    FiniteSet {
40        values: Vec<String>,
41    },
42    Prefix {
43        prefix: String,
44    },
45    Suffix {
46        suffix: String,
47    },
48    PrefixSuffix {
49        prefix: String,
50        suffix: String,
51        #[serde(default)]
52        min_length: usize,
53    },
54    Top,
55}
56
57impl OmenaQueryDynamicClassValueInputV0 {
58    fn into_abstract_class_value(self) -> AbstractClassValueV0 {
59        match self {
60            Self::Bottom => AbstractClassValueV0::Bottom,
61            Self::Exact { value } => AbstractClassValueV0::Exact { value },
62            Self::FiniteSet { values } => AbstractClassValueV0::FiniteSet { values },
63            Self::Prefix { prefix } => AbstractClassValueV0::Prefix {
64                prefix,
65                provenance: None,
66            },
67            Self::Suffix { suffix } => AbstractClassValueV0::Suffix {
68                suffix,
69                provenance: None,
70            },
71            Self::PrefixSuffix {
72                prefix,
73                suffix,
74                min_length,
75            } => AbstractClassValueV0::PrefixSuffix {
76                prefix,
77                suffix,
78                min_length,
79                provenance: None,
80            },
81            Self::Top => AbstractClassValueV0::Top,
82        }
83    }
84}
85
86/// A dynamic-className call site observed in the analysed source document.
87#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
88#[serde(rename_all = "camelCase")]
89pub struct OmenaQueryDynamicClassnameCallSiteV0 {
90    pub callee_key: String,
91    pub call_site_stack: Vec<String>,
92    pub exit_value: OmenaQueryDynamicClassValueInputV0,
93    pub reference_range: ParserRangeV0,
94}
95
96/// Input contract for the consumer-facing context-sensitive M-tier diagnostic
97/// surface.
98#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
99#[serde(rename_all = "camelCase")]
100pub struct OmenaQueryDynamicClassnameMTierInputV0 {
101    pub source_uri: String,
102    pub selector_universe: Vec<String>,
103    pub max_context_depth: usize,
104    pub call_sites: Vec<OmenaQueryDynamicClassnameCallSiteV0>,
105}
106
107/// Run the context-sensitive M-tier dynamic-className analysis at the supplied
108/// call-string bound `k` and lower the result into consumer source diagnostics.
109///
110/// Each call site contributes a context whose exit value is joined per the
111/// k-limited context key inside `analyze_k_limited_call_site_flows`. The joined
112/// value drives the checker M-tier rules, so increasing `k` separates call sites
113/// that share a callee and removes the over-approximating join — which removes
114/// diagnostics that were only present because of the join.
115pub fn summarize_omena_query_dynamic_classname_m_tier_diagnostics_with_context_depth(
116    input: &OmenaQueryDynamicClassnameMTierInputV0,
117) -> OmenaQuerySourceDiagnosticsForFileV0 {
118    let mut diagnostics = collect_omena_query_dynamic_classname_m_tier_diagnostics(
119        &input.call_sites,
120        &input.selector_universe,
121        input.max_context_depth,
122    );
123
124    diagnostics.sort_by(|left, right| {
125        (
126            left.range.start.line,
127            left.range.start.character,
128            left.code,
129            &left.message,
130        )
131            .cmp(&(
132                right.range.start.line,
133                right.range.start.character,
134                right.code,
135                &right.message,
136            ))
137    });
138    apply_omena_query_checker_product_gate_to_source_diagnostics(&mut diagnostics);
139
140    OmenaQuerySourceDiagnosticsForFileV0 {
141        schema_version: "0",
142        product: "omena-query.diagnostics-for-file",
143        file_uri: input.source_uri.clone(),
144        file_kind: "source",
145        diagnostic_count: diagnostics.len(),
146        diagnostics,
147        ready_surfaces: vec![
148            "dynamicClassnameMTierDiagnostics",
149            "kLimitedCallSiteFlow",
150            "checkerMTierEvaluation",
151            "checkerProductDiagnosticGate",
152        ],
153    }
154}
155
156/// Default call-string bound `k` used by the workspace diagnostic path. The LSP
157/// default is context-sensitive (k = 2): dynamic-className call sites that share
158/// a callee binding are kept apart so their abstract exit values are not joined
159/// into an over-approximating root context. A context-insensitive run (k = 0)
160/// would merge them and emit a different diagnostic set.
161pub(super) const OMENA_QUERY_WORKSPACE_DYNAMIC_CLASSNAME_CONTEXT_DEPTH: usize = 2;
162
163/// Harvest dynamic-className call-site contexts from a source file's syntax-index
164/// type-fact targets (the same template-interpolation projections the engine
165/// expression-domain producer consumes) and lower them into context-sensitive
166/// M-tier source diagnostics for the default workspace path.
167///
168/// Each `type_fact_target` is a `prefix${expr}suffix` className projection. The
169/// harvested callee key is the projected expression's binding path, so two
170/// template call sites that interpolate the same binding share a callee and are
171/// joined at `k = 0` but separated at the workspace default `k`. The per-target
172/// byte span becomes the diagnostic range and the distinguishing tail of the
173/// call-string, so increasing `k` genuinely re-partitions the contexts.
174///
175/// Soundness of `no-unknown-dynamic-class` requires the selector universe to be
176/// scoped to the module the className is actually bound to. A target that carries
177/// a resolved `target_style_uri` (e.g. a `cx(`prefix-${x}`)` call bound to a
178/// specific imported CSS Module) is evaluated against ONLY that module's selectors
179/// (`selector_universe_by_uri`), so a `btn-` prefix is matched against the bound
180/// module, not the union of every imported module — which would otherwise let a
181/// `btn-*` selector in a different module mask a genuinely-empty intersection. A
182/// target with no resolved URI (a bare `className={`btn-${x}`}` literal with no
183/// binding context) has no single module to scope to, so it falls back to the
184/// union (`union_selector_universe`); `no-unknown-dynamic-class` then fires only
185/// when the prefix is provably empty against the whole union, the conservative
186/// stopgap that never cross-attributes a match to the wrong module.
187///
188/// `no-imprecise-value` is suppressed for harvested affix templates: an
189/// interpolation is inherently Top/imprecise, so a hint per template is
190/// information-free noise. The k-CFA precision is used to NARROW the candidate set
191/// (drive `no-unknown-dynamic-class` / `no-impossible-selector`), not to restate
192/// that an interpolation is imprecise.
193pub(super) fn harvest_omena_query_dynamic_classname_m_tier_diagnostics(
194    source_path: &str,
195    source: &str,
196    type_fact_targets: &[OmenaQuerySourceTypeFactTargetV0],
197    union_selector_universe: &[String],
198    selector_universe_by_uri: &BTreeMap<String, Vec<String>>,
199    max_context_depth: usize,
200) -> Vec<OmenaQuerySourceDiagnosticV0> {
201    // Partition harvested call sites by the resolved module they are bound to so
202    // each scope is evaluated against its CORRECTLY-scoped selector universe. A
203    // `None` scope (no resolved binding) is evaluated against the union.
204    let mut call_sites_by_scope: BTreeMap<
205        Option<String>,
206        Vec<OmenaQueryDynamicClassnameCallSiteV0>,
207    > = BTreeMap::new();
208    for target in type_fact_targets {
209        let Some(exit_value) =
210            harvested_abstract_class_value(target.prefix.as_str(), target.suffix.as_str())
211        else {
212            continue;
213        };
214        let callee_key = harvested_callee_key(target.expression_id.as_str());
215        call_sites_by_scope
216            .entry(target.target_style_uri.clone())
217            .or_default()
218            .push(OmenaQueryDynamicClassnameCallSiteV0 {
219                callee_key,
220                call_site_stack: vec![
221                    source_path.to_string(),
222                    format!("{}:{}", target.byte_span.start, target.byte_span.end),
223                ],
224                exit_value,
225                reference_range: parser_range_for_byte_span(source, target.byte_span),
226            });
227    }
228
229    if call_sites_by_scope.is_empty() {
230        return Vec::new();
231    }
232
233    let mut diagnostics = Vec::new();
234    for (scope_uri, call_sites) in &call_sites_by_scope {
235        let scoped_universe = match scope_uri {
236            Some(uri) => selector_universe_by_uri
237                .get(uri)
238                .map(Vec::as_slice)
239                .unwrap_or(union_selector_universe),
240            None => union_selector_universe,
241        };
242        diagnostics.extend(collect_omena_query_dynamic_classname_m_tier_diagnostics(
243            call_sites,
244            scoped_universe,
245            max_context_depth,
246        ));
247    }
248
249    // Suppress the information-free `no-imprecise-value` hint on harvested affix
250    // templates: an interpolation being imprecise is expected and non-actionable.
251    diagnostics.retain(|diagnostic| diagnostic.code != "noImpreciseValue");
252    diagnostics
253}
254
255/// Map a harvested template projection (`prefix${expr}suffix`) to the abstract
256/// class value the interpolation guarantees. A bare `${expr}` with neither a
257/// prefix nor a suffix carries no static structure and is skipped (no M-tier
258/// obligation to discharge).
259fn harvested_abstract_class_value(
260    prefix: &str,
261    suffix: &str,
262) -> Option<OmenaQueryDynamicClassValueInputV0> {
263    match (prefix.is_empty(), suffix.is_empty()) {
264        (true, true) => None,
265        (false, true) => Some(OmenaQueryDynamicClassValueInputV0::Prefix {
266            prefix: prefix.to_string(),
267        }),
268        (true, false) => Some(OmenaQueryDynamicClassValueInputV0::Suffix {
269            suffix: suffix.to_string(),
270        }),
271        (false, false) => Some(OmenaQueryDynamicClassValueInputV0::PrefixSuffix {
272            prefix: prefix.to_string(),
273            suffix: suffix.to_string(),
274            min_length: prefix.len() + suffix.len(),
275        }),
276    }
277}
278
279/// Recover the projected expression's binding path from a syntax-index
280/// type-fact expression id of the form
281/// `omena-bridge-source-type-fact:{path}:{start}:{end}`. The path is the callee
282/// identity that lets call sites interpolating the same binding share a context.
283fn harvested_callee_key(expression_id: &str) -> String {
284    let trimmed = expression_id
285        .strip_prefix("omena-bridge-source-type-fact:")
286        .unwrap_or(expression_id);
287    // Drop the trailing `:{start}:{end}` span suffix, keeping the binding path.
288    let mut segments = trimmed.rsplitn(3, ':');
289    let _end = segments.next();
290    let _start = segments.next();
291    match segments.next() {
292        Some(path) if !path.is_empty() => path.to_string(),
293        _ => trimmed.to_string(),
294    }
295}
296
297/// Run the real k-limited (k-CFA) call-string M-tier analysis on the supplied
298/// dynamic-className call sites and lower each per-context M-tier evaluation into
299/// an unsorted, ungated list of consumer source diagnostics anchored at the
300/// originating call site's reference range.
301///
302/// This is the shared core used by both the explicit input-contract surface and
303/// the default workspace diagnostic assembly. The `max_context_depth` bound `k`
304/// drives `analyze_k_limited_call_site_flows`: at a low `k`, call sites that
305/// share a callee collapse into one context and their exit values are joined, so
306/// the emitted diagnostics differ from a higher-`k` run that keeps the call sites
307/// separate. The caller is responsible for sorting and applying the checker
308/// product diagnostic gate.
309pub(super) fn collect_omena_query_dynamic_classname_m_tier_diagnostics(
310    call_sites: &[OmenaQueryDynamicClassnameCallSiteV0],
311    selector_universe: &[String],
312    max_context_depth: usize,
313) -> Vec<OmenaQuerySourceDiagnosticV0> {
314    let mut range_by_context: BTreeMap<(String, Vec<String>), ParserRangeV0> = BTreeMap::new();
315    let contexts = call_sites
316        .iter()
317        .map(|call_site| {
318            range_by_context.insert(
319                (
320                    call_site.callee_key.clone(),
321                    call_site.call_site_stack.clone(),
322                ),
323                call_site.reference_range,
324            );
325            OmenaQueryCheckerKLimitedFlowContextV0 {
326                callee_key: call_site.callee_key.clone(),
327                call_site_stack: call_site.call_site_stack.clone(),
328                exit_value: call_site.exit_value.clone().into_abstract_class_value(),
329            }
330        })
331        .collect::<Vec<_>>();
332
333    let gate = run_omena_query_checker_k_limited_flow_m_tier_gate_v0(
334        &contexts,
335        selector_universe,
336        max_context_depth,
337    );
338
339    let mut diagnostics = Vec::new();
340    if gate.enforcement_passed {
341        for context in &gate.contexts {
342            let range = range_by_context
343                .get(&(context.callee_key.clone(), context.call_site_stack.clone()))
344                .copied()
345                .unwrap_or_default();
346            for evaluation in &context.evaluations {
347                diagnostics.push(OmenaQuerySourceDiagnosticV0 {
348                    code: dynamic_classname_m_tier_diagnostic_code(evaluation.rule_code_name),
349                    severity: evaluation.severity_name,
350                    provenance: vec![
351                        "omena-query-checker-orchestrator.k-limited-flow-m-tier-gate",
352                        "omena-abstract-value.k-limited-call-site-flow",
353                        "omena-checker.m-tier-rules",
354                        "omena-query.dynamic-classname",
355                    ],
356                    range,
357                    message: evaluation.message.clone(),
358                    create_selector: None,
359                });
360            }
361        }
362    }
363    diagnostics
364}
365
366fn dynamic_classname_m_tier_diagnostic_code(rule_code_name: &str) -> &'static str {
367    match rule_code_name {
368        "no-unknown-dynamic-class" => "noUnknownDynamicClass",
369        "no-imprecise-value" => "noImpreciseValue",
370        "no-impossible-selector" => "noImpossibleSelector",
371        _ => "dynamicClassDomain",
372    }
373}