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/multiscale-complexity-heuristic 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, top_class_value,
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 => top_class_value(),
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    source_syntax_index: &OmenaQuerySourceSyntaxIndexV0,
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 &source_syntax_index.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    for skipped in &source_syntax_index.type_fact_target_skipped {
229        let exact_references = source_syntax_index
230            .selector_references
231            .iter()
232            .filter(|reference| {
233                reference.match_kind == OmenaQuerySourceSelectorReferenceMatchKindV0::Exact
234                    && reference.surface
235                        == OmenaQuerySourceSelectorReferenceSurfaceV0::OmenaQuerySourceSyntaxIndex
236                    && reference.target_style_uri == skipped.target_style_uri
237                    && reference.byte_span.start <= skipped.byte_span.start
238                    && skipped.byte_span.end <= reference.byte_span.end
239            })
240            .collect::<Vec<_>>();
241        let Some(reference_span) = exact_references
242            .iter()
243            .map(|reference| reference.byte_span)
244            .min_by_key(|span| (span.end.saturating_sub(span.start), span.start, span.end))
245        else {
246            continue;
247        };
248        let mut values = exact_references
249            .into_iter()
250            .filter(|reference| reference.byte_span == reference_span)
251            .filter_map(|reference| reference.selector_name.clone())
252            .collect::<Vec<_>>();
253        values.sort();
254        values.dedup();
255        if values.is_empty() {
256            continue;
257        }
258        let exit_value = match values.as_slice() {
259            [value] => OmenaQueryDynamicClassValueInputV0::Exact {
260                value: value.clone(),
261            },
262            _ => OmenaQueryDynamicClassValueInputV0::FiniteSet { values },
263        };
264        call_sites_by_scope
265            .entry(skipped.target_style_uri.clone())
266            .or_default()
267            .push(OmenaQueryDynamicClassnameCallSiteV0 {
268                callee_key: harvested_callee_key(skipped.expression_id.as_str()),
269                call_site_stack: vec![
270                    source_path.to_string(),
271                    format!("{}:{}", skipped.byte_span.start, skipped.byte_span.end),
272                ],
273                exit_value,
274                reference_range: parser_range_for_byte_span(source, reference_span),
275            });
276    }
277
278    if call_sites_by_scope.is_empty() {
279        return Vec::new();
280    }
281
282    let mut diagnostics = Vec::new();
283    for (scope_uri, call_sites) in &call_sites_by_scope {
284        let scoped_universe = match scope_uri {
285            Some(uri) => selector_universe_by_uri
286                .get(uri)
287                .map(Vec::as_slice)
288                .unwrap_or(union_selector_universe),
289            None => union_selector_universe,
290        };
291        diagnostics.extend(collect_omena_query_dynamic_classname_m_tier_diagnostics(
292            call_sites,
293            scoped_universe,
294            max_context_depth,
295        ));
296    }
297
298    // Suppress the information-free `no-imprecise-value` hint on harvested affix
299    // templates: an interpolation being imprecise is expected and non-actionable.
300    diagnostics.retain(|diagnostic| diagnostic.code != "noImpreciseValue");
301    diagnostics
302}
303
304/// Map a harvested template projection (`prefix${expr}suffix`) to the abstract
305/// class value the interpolation guarantees. A bare `${expr}` with neither a
306/// prefix nor a suffix carries no static structure and is skipped (no M-tier
307/// obligation to discharge).
308fn harvested_abstract_class_value(
309    prefix: &str,
310    suffix: &str,
311) -> Option<OmenaQueryDynamicClassValueInputV0> {
312    match (prefix.is_empty(), suffix.is_empty()) {
313        (true, true) => None,
314        (false, true) => Some(OmenaQueryDynamicClassValueInputV0::Prefix {
315            prefix: prefix.to_string(),
316        }),
317        (true, false) => Some(OmenaQueryDynamicClassValueInputV0::Suffix {
318            suffix: suffix.to_string(),
319        }),
320        (false, false) => Some(OmenaQueryDynamicClassValueInputV0::PrefixSuffix {
321            prefix: prefix.to_string(),
322            suffix: suffix.to_string(),
323            min_length: prefix.len() + suffix.len(),
324        }),
325    }
326}
327
328/// Recover the projected expression's binding path from a syntax-index
329/// type-fact expression id of the form
330/// `omena-bridge-source-type-fact:{path}:{start}:{end}`. The path is the callee
331/// identity that lets call sites interpolating the same binding share a context.
332fn harvested_callee_key(expression_id: &str) -> String {
333    let trimmed = expression_id
334        .strip_prefix("omena-bridge-source-type-fact:")
335        .unwrap_or(expression_id);
336    // Drop the trailing `:{start}:{end}` span suffix, keeping the binding path.
337    let mut segments = trimmed.rsplitn(3, ':');
338    let _end = segments.next();
339    let _start = segments.next();
340    match segments.next() {
341        Some(path) if !path.is_empty() => path.to_string(),
342        _ => trimmed.to_string(),
343    }
344}
345
346/// Run the real k-limited (k-CFA) call-string M-tier analysis on the supplied
347/// dynamic-className call sites and lower each per-context M-tier evaluation into
348/// an unsorted, ungated list of consumer source diagnostics anchored at the
349/// originating call site's reference range.
350///
351/// This is the shared core used by both the explicit input-contract surface and
352/// the default workspace diagnostic assembly. The `max_context_depth` bound `k`
353/// drives `analyze_k_limited_call_site_flows`: at a low `k`, call sites that
354/// share a callee collapse into one context and their exit values are joined, so
355/// the emitted diagnostics differ from a higher-`k` run that keeps the call sites
356/// separate. The caller is responsible for sorting and applying the checker
357/// product diagnostic gate.
358pub(super) fn collect_omena_query_dynamic_classname_m_tier_diagnostics(
359    call_sites: &[OmenaQueryDynamicClassnameCallSiteV0],
360    selector_universe: &[String],
361    max_context_depth: usize,
362) -> Vec<OmenaQuerySourceDiagnosticV0> {
363    let mut range_by_context: BTreeMap<(String, Vec<String>), ParserRangeV0> = BTreeMap::new();
364    let contexts = call_sites
365        .iter()
366        .map(|call_site| {
367            range_by_context.insert(
368                (
369                    call_site.callee_key.clone(),
370                    call_site.call_site_stack.clone(),
371                ),
372                call_site.reference_range,
373            );
374            OmenaQueryCheckerKLimitedFlowContextV0 {
375                callee_key: call_site.callee_key.clone(),
376                call_site_stack: call_site.call_site_stack.clone(),
377                exit_value: call_site.exit_value.clone().into_abstract_class_value(),
378            }
379        })
380        .collect::<Vec<_>>();
381
382    let gate = run_omena_query_checker_k_limited_flow_m_tier_gate_v0(
383        &contexts,
384        selector_universe,
385        max_context_depth,
386    );
387
388    let mut diagnostics = Vec::new();
389    if gate.enforcement_passed {
390        for context in &gate.contexts {
391            let range = range_by_context
392                .get(&(context.callee_key.clone(), context.call_site_stack.clone()))
393                .copied()
394                .unwrap_or_default();
395            for evaluation in &context.evaluations {
396                diagnostics.push(OmenaQuerySourceDiagnosticV0 {
397                    code: dynamic_classname_m_tier_diagnostic_code(evaluation.rule_code_name),
398                    severity: evaluation.severity_name,
399                    provenance: omena_query_evidence_graph_provenance![
400                        "omena-query-checker-orchestrator.k-limited-flow-m-tier-gate",
401                        "omena-abstract-value.k-limited-call-site-flow",
402                        "omena-checker.m-tier-rules",
403                        "omena-query.dynamic-classname",
404                    ],
405                    range,
406                    message: evaluation.message.clone(),
407                    precision: Some(source_diagnostic_precision(
408                        "classValueFlow",
409                        "kLimitedCallSiteFlow",
410                        "kLimitedDynamicClassname",
411                    )),
412                    suggestion: None,
413                    create_selector: None,
414                });
415            }
416        }
417    }
418    diagnostics
419}
420
421fn dynamic_classname_m_tier_diagnostic_code(rule_code_name: &str) -> &'static str {
422    match rule_code_name {
423        "no-unknown-dynamic-class" => "noUnknownDynamicClass",
424        "no-imprecise-value" => "noImpreciseValue",
425        "no-impossible-selector" => "noImpossibleSelector",
426        _ => "dynamicClassDomain",
427    }
428}