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, 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 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: omena_query_evidence_graph_provenance![
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 precision: Some(source_diagnostic_precision(
359 "classValueFlow",
360 "kLimitedCallSiteFlow",
361 "kLimitedDynamicClassname",
362 )),
363 suggestion: None,
364 create_selector: None,
365 });
366 }
367 }
368 }
369 diagnostics
370}
371
372fn dynamic_classname_m_tier_diagnostic_code(rule_code_name: &str) -> &'static str {
373 match rule_code_name {
374 "no-unknown-dynamic-class" => "noUnknownDynamicClass",
375 "no-imprecise-value" => "noImpreciseValue",
376 "no-impossible-selector" => "noImpossibleSelector",
377 _ => "dynamicClassDomain",
378 }
379}