Skip to main content

code_moniker_check/check/eval/
mod.rs

1// code-moniker: ignore-file[smell-clone-reflex]
2// Rule compilation builds owned executable specs from borrowed configuration entries.
3use std::cell::OnceCell;
4use std::collections::HashMap;
5
6mod collection;
7mod layout;
8mod local;
9mod metrics;
10mod pairs;
11pub(in crate::check) mod stats;
12mod value;
13
14use crate::check::config::{Config, ConfigError, KindRules, RuleSeverity, config_section};
15use crate::check::expr::{
16	self, Atom, Domain, Lhs, LhsExpr, Node, NumberExpr, Op, QuantKind, Rhs, SegmentScope,
17	VerticalLayout,
18};
19use code_moniker_core::core::code_graph::{CodeGraph, DefRecord};
20use code_moniker_core::core::kinds::{KIND_COMMENT, REF_CALLS, REF_METHOD_CALL};
21use code_moniker_core::core::moniker::query::bare_callable_name;
22use code_moniker_core::core::shape::Shape;
23use code_moniker_core::core::uri::{UriConfig, to_uri};
24use code_moniker_core::lang::Lang;
25use code_moniker_workspace::lines::line_range;
26
27use collection::{collection_has_pair_binding, eval_collection_size, eval_collection_subset};
28use layout::eval_vertical_layout;
29use local::{
30	AggregateEval, DomainItem, domain_items, eval_aggregate, eval_entropy, eval_mode,
31	project_def_lhs_value,
32};
33use metrics::eval_metric;
34use pairs::{eval_pair_count, eval_pair_quantifier};
35use value::{Value, apply_op, apply_op_values, number_expr_label};
36
37fn is_call_ref_kind(kind: &[u8]) -> bool {
38	matches!(kind, REF_CALLS | REF_METHOD_CALL)
39}
40
41#[derive(Debug, Clone, serde::Serialize)]
42pub struct Violation {
43	pub rule_id: String,
44	pub severity: RuleSeverity,
45	pub moniker: String,
46	#[serde(skip_serializing_if = "Option::is_none")]
47	pub srcset: Option<String>,
48	pub kind: String,
49	#[serde(serialize_with = "serialize_lines")]
50	pub lines: (u32, u32),
51	pub message: String,
52	#[serde(skip_serializing_if = "Option::is_none")]
53	pub explanation: Option<String>,
54}
55
56fn serialize_lines<S: serde::Serializer>(v: &(u32, u32), s: S) -> Result<S::Ok, S::Error> {
57	use serde::ser::SerializeTuple;
58	let mut t = s.serialize_tuple(2)?;
59	t.serialize_element(&v.0)?;
60	t.serialize_element(&v.1)?;
61	t.end()
62}
63
64#[cfg(test)]
65pub(crate) fn evaluate(
66	graph: &CodeGraph,
67	source: &str,
68	lang: Lang,
69	cfg: &Config,
70	scheme: &str,
71) -> Result<Vec<Violation>, ConfigError> {
72	let compiled = compile_rules(cfg, lang, scheme)?;
73	Ok(evaluate_compiled(graph, source, lang, scheme, &compiled))
74}
75
76/// Build the compiled rule set for a single `lang`. Parses every rule
77/// expression, resolves aliases. Call once per language and reuse across
78/// many files of that language — the eval pipeline is shaped so the heavy
79/// work happens here, not per-file.
80pub fn compile_rules(cfg: &Config, lang: Lang, scheme: &str) -> Result<CompiledRules, ConfigError> {
81	CompiledRules::for_lang(cfg, lang, scheme)
82}
83
84pub fn evaluate_compiled(
85	graph: &CodeGraph,
86	source: &str,
87	lang: Lang,
88	scheme: &str,
89	compiled: &CompiledRules,
90) -> Vec<Violation> {
91	evaluate_compiled_with_requirements(graph, source, lang, scheme, compiled, None)
92}
93
94pub(in crate::check) trait RequirementResolver: Sync {
95	fn exists(&self, pattern: &str, source: &DefRecord, scheme: &str) -> bool;
96	fn descendant_defs<'a>(&'a self, _owner: &DefRecord, _inner: &Domain) -> Vec<&'a DefRecord> {
97		Vec::new()
98	}
99}
100
101pub(in crate::check) fn evaluate_compiled_with_requirements(
102	graph: &CodeGraph,
103	source: &str,
104	lang: Lang,
105	scheme: &str,
106	compiled: &CompiledRules,
107	requirements: Option<&dyn RequirementResolver>,
108) -> Vec<Violation> {
109	let need_doc_anchors = compiled
110		.by_kind
111		.values()
112		.any(|r| r.require_doc_for_vis.is_some())
113		|| compiled
114			.by_shape
115			.values()
116			.any(|r| r.require_doc_for_vis.is_some());
117	let ctx = EvalCtx {
118		graph,
119		requirements,
120		source,
121		lang,
122		uri_cfg: UriConfig { scheme },
123		parent_counts: parent_counts_by_kind(graph),
124		children_by_parent: children_by_parent(graph),
125		out_refs_by_source: out_refs_by_source(graph),
126		in_refs_by_target: in_refs_by_target(graph),
127		comment_ends: if need_doc_anchors {
128			comment_end_bytes(graph)
129		} else {
130			Vec::new()
131		},
132		doc_anchors: if need_doc_anchors {
133			doc_anchors_by_def(graph)
134		} else {
135			HashMap::new()
136		},
137		def_index: OnceCell::new(),
138	};
139	let mut out = Vec::new();
140
141	for (idx, d) in graph.defs().enumerate() {
142		let Ok(kind_str) = std::str::from_utf8(&d.kind) else {
143			continue;
144		};
145		let kind_rules = compiled.for_kind(kind_str);
146		if let Some(rules) = kind_rules {
147			let target = RuleTarget {
148				scope: DefScope { record: d, idx },
149				kind: kind_str,
150			};
151			for rule in &rules.rules {
152				eval_rule(rule, d, idx, kind_str, &ctx, &mut out);
153			}
154			check_require_doc_comment(target, rules, &ctx, &mut out);
155		}
156		if let Some(shape) = d.shape()
157			&& let Some(rules) = compiled.for_shape(shape)
158		{
159			for rule in &rules.rules {
160				if rule.explicit_id
161					&& kind_rules
162						.is_some_and(|kind_rules| kind_rules.has_explicit_rule_id(&rule.id))
163				{
164					continue;
165				}
166				eval_shape_rule(rule, d, idx, kind_str, &ctx, &mut out);
167			}
168			if kind_rules.is_none_or(|kind_rules| kind_rules.require_doc_for_vis.is_none()) {
169				if let Some(rule_id) = &rules.require_doc_rule_id {
170					let target = RuleTarget {
171						scope: DefScope { record: d, idx },
172						kind: kind_str,
173					};
174					check_require_doc_comment_with_id(
175						target,
176						rules,
177						rule_id.clone(),
178						&ctx,
179						&mut out,
180					);
181				}
182			}
183		}
184	}
185
186	for r in graph.refs() {
187		for rule in &compiled.refs {
188			eval_ref_rule(rule, r, graph, &ctx, &mut out);
189		}
190	}
191
192	out
193}
194
195#[derive(Debug, Clone, Copy, Eq, PartialEq, serde::Serialize)]
196#[serde(rename_all = "snake_case")]
197pub enum RuleVerdict {
198	Pass,
199	Fail,
200	Inconclusive,
201}
202
203#[derive(Debug, Clone, Eq, PartialEq, serde::Serialize)]
204pub struct RuleCoverage {
205	pub total: usize,
206	pub decided: usize,
207	pub resolved: usize,
208	pub external: usize,
209	pub candidate: usize,
210	pub dynamic: usize,
211	pub blocked: usize,
212	pub unresolved: usize,
213	pub percent: usize,
214	pub min_percent: usize,
215}
216
217#[derive(Debug, Clone, Eq, PartialEq, serde::Serialize)]
218pub struct RulePathStep {
219	pub source: String,
220	pub target: String,
221	pub relation: String,
222	pub reference: String,
223	pub file: String,
224	pub line_range: Option<(u32, u32)>,
225}
226
227#[derive(Debug, Clone, Eq, PartialEq, serde::Serialize)]
228pub struct RulePathReport {
229	pub expectation: String,
230	pub relation: Vec<String>,
231	pub max_depth: usize,
232	pub max_symbols: usize,
233	pub max_edges: usize,
234	pub max_pairs: usize,
235	pub min_coverage: usize,
236	pub source_symbols: usize,
237	pub target_symbols: usize,
238	pub via_symbols: usize,
239	pub evaluated_pairs: usize,
240	pub explored_symbols: usize,
241	pub explored_edges: usize,
242	pub depth_limit_reached: bool,
243	pub symbol_limit_reached: bool,
244	pub edge_limit_reached: bool,
245	pub pair_limit_reached: bool,
246	pub reasons: Vec<String>,
247	pub witness: Vec<RulePathStep>,
248}
249
250#[derive(Debug, Clone, Eq, PartialEq, serde::Serialize)]
251pub struct RuleReport {
252	pub rule_id: String,
253	pub severity: RuleSeverity,
254	pub domain: String,
255	pub evaluated: usize,
256	pub matches: usize,
257	pub violations: usize,
258	#[serde(skip_serializing_if = "Option::is_none")]
259	pub antecedent_matches: Option<usize>,
260	#[serde(skip_serializing_if = "Option::is_none")]
261	pub warning: Option<String>,
262	#[serde(skip_serializing_if = "Option::is_none")]
263	pub inconclusive: Option<usize>,
264	#[serde(skip_serializing_if = "Option::is_none")]
265	pub verdict: Option<RuleVerdict>,
266	#[serde(skip_serializing_if = "Option::is_none")]
267	pub coverage: Option<RuleCoverage>,
268	#[serde(skip_serializing_if = "Option::is_none")]
269	pub path: Option<RulePathReport>,
270}
271
272pub fn rule_report_compiled(
273	graph: &CodeGraph,
274	source: &str,
275	lang: Lang,
276	scheme: &str,
277	compiled: &CompiledRules,
278) -> Vec<RuleReport> {
279	rule_report_compiled_with_requirements(graph, source, lang, scheme, compiled, None)
280}
281
282pub(in crate::check) fn rule_report_compiled_with_requirements(
283	graph: &CodeGraph,
284	source: &str,
285	lang: Lang,
286	scheme: &str,
287	compiled: &CompiledRules,
288	requirements: Option<&dyn RequirementResolver>,
289) -> Vec<RuleReport> {
290	let need_doc_anchors = compiled
291		.by_kind
292		.values()
293		.any(|r| r.require_doc_for_vis.is_some())
294		|| compiled
295			.by_shape
296			.values()
297			.any(|r| r.require_doc_for_vis.is_some());
298	let ctx = EvalCtx {
299		graph,
300		requirements,
301		source,
302		lang,
303		uri_cfg: UriConfig { scheme },
304		parent_counts: parent_counts_by_kind(graph),
305		children_by_parent: children_by_parent(graph),
306		out_refs_by_source: out_refs_by_source(graph),
307		in_refs_by_target: in_refs_by_target(graph),
308		comment_ends: if need_doc_anchors {
309			comment_end_bytes(graph)
310		} else {
311			Vec::new()
312		},
313		doc_anchors: if need_doc_anchors {
314			doc_anchors_by_def(graph)
315		} else {
316			HashMap::new()
317		},
318		def_index: OnceCell::new(),
319	};
320	let mut out = Vec::new();
321	push_kind_rule_reports(&mut out, graph, lang, &ctx, compiled);
322	push_shape_rule_reports(&mut out, graph, &ctx, compiled);
323	push_ref_rule_reports(&mut out, graph, &ctx, compiled);
324	out.sort_by(|a, b| a.rule_id.cmp(&b.rule_id));
325	out
326}
327
328fn push_kind_rule_reports(
329	out: &mut Vec<RuleReport>,
330	graph: &CodeGraph,
331	lang: Lang,
332	ctx: &EvalCtx<'_, '_>,
333	compiled: &CompiledRules,
334) {
335	for (kind, rules) in &compiled.by_kind {
336		for rule in &rules.rules {
337			let mut report = RuleReport::new(rule_id(lang, kind, &rule.id), kind.clone(), rule);
338			for (idx, d) in graph.defs().enumerate() {
339				if d.kind.as_ref() != kind.as_bytes() {
340					continue;
341				}
342				report.evaluated += 1;
343				let premise =
344					implication_premise(rule).map(|premise| eval_node(premise, d, idx, ctx));
345				report.record(eval_node(&rule.root, d, idx, ctx), premise);
346			}
347			out.push(report);
348		}
349		if rules.require_doc_for_vis.is_some() {
350			let mut report = RuleReport::new_require_doc(
351				rule_id(lang, kind, "require_doc_comment"),
352				kind.clone(),
353			);
354			for (idx, d) in graph.defs().enumerate() {
355				if d.kind.as_ref() != kind.as_bytes() {
356					continue;
357				}
358				report.evaluated += 1;
359				report.record(
360					eval_require_doc_comment(d, idx, rules, ctx).map_or(
361						NodeOutcome::NotApplicable,
362						|has_doc| {
363							if has_doc {
364								NodeOutcome::Pass
365							} else {
366								NodeOutcome::Fail(Failure {
367									atom_raw: "require_doc_comment".to_string(),
368									lhs_label: "doc_comment".to_string(),
369									actual: "missing".to_string(),
370									expected: "present".to_string(),
371									def_idx: None,
372									details: None,
373								})
374							}
375						},
376					),
377					None,
378				);
379			}
380			out.push(report);
381		}
382	}
383}
384
385fn push_shape_rule_reports(
386	out: &mut Vec<RuleReport>,
387	graph: &CodeGraph,
388	ctx: &EvalCtx<'_, '_>,
389	compiled: &CompiledRules,
390) {
391	for (shape, rules) in &compiled.by_shape {
392		for rule in &rules.rules {
393			let mut report =
394				RuleReport::new(rule.rule_id.clone(), format!("shape:{shape} defs"), rule);
395			for (idx, d) in graph.defs().enumerate() {
396				if !def_has_shape(d, shape) {
397					continue;
398				}
399				let Ok(kind_str) = std::str::from_utf8(&d.kind) else {
400					continue;
401				};
402				if compiled.for_kind(kind_str).is_some_and(|kind_rules| {
403					rule.explicit_id && kind_rules.has_explicit_rule_id(&rule.id)
404				}) {
405					continue;
406				}
407				report.evaluated += 1;
408				let premise =
409					implication_premise(rule).map(|premise| eval_node(premise, d, idx, ctx));
410				report.record(eval_node(&rule.root, d, idx, ctx), premise);
411			}
412			out.push(report);
413		}
414		if let Some(rule_id) = &rules.require_doc_rule_id {
415			let mut report =
416				RuleReport::new_require_doc(rule_id.clone(), format!("shape:{shape} defs"));
417			for (idx, d) in graph.defs().enumerate() {
418				if !def_has_shape(d, shape) {
419					continue;
420				}
421				let Ok(kind_str) = std::str::from_utf8(&d.kind) else {
422					continue;
423				};
424				if compiled
425					.for_kind(kind_str)
426					.is_some_and(|kind_rules| kind_rules.require_doc_for_vis.is_some())
427				{
428					continue;
429				}
430				report.evaluated += 1;
431				report.record(
432					eval_require_doc_comment(d, idx, rules, ctx).map_or(
433						NodeOutcome::NotApplicable,
434						|has_doc| {
435							if has_doc {
436								NodeOutcome::Pass
437							} else {
438								NodeOutcome::Fail(Failure {
439									atom_raw: "require_doc_comment".to_string(),
440									lhs_label: "doc_comment".to_string(),
441									actual: "missing".to_string(),
442									expected: "present".to_string(),
443									def_idx: None,
444									details: None,
445								})
446							}
447						},
448					),
449					None,
450				);
451			}
452			out.push(report);
453		}
454	}
455}
456
457fn push_ref_rule_reports(
458	out: &mut Vec<RuleReport>,
459	graph: &CodeGraph,
460	ctx: &EvalCtx<'_, '_>,
461	compiled: &CompiledRules,
462) {
463	for rule in &compiled.refs {
464		let mut report = RuleReport::new(rule.rule_id.clone(), "refs".to_string(), rule);
465		for r in graph.refs() {
466			report.evaluated += 1;
467			let premise = implication_premise(rule).map(|premise| eval_ref_node(premise, r, ctx));
468			report.record(eval_ref_node(&rule.root, r, ctx), premise);
469		}
470		out.push(report);
471	}
472}
473
474impl RuleReport {
475	fn new(rule_id: String, domain: String, rule: &CompiledRule) -> Self {
476		Self {
477			rule_id,
478			severity: rule.severity,
479			domain,
480			evaluated: 0,
481			matches: 0,
482			violations: 0,
483			antecedent_matches: implication_premise(rule).map(|_| 0),
484			warning: None,
485			inconclusive: None,
486			verdict: None,
487			coverage: None,
488			path: None,
489		}
490	}
491
492	fn new_require_doc(rule_id: String, domain: String) -> Self {
493		Self {
494			rule_id,
495			severity: RuleSeverity::Error,
496			domain,
497			evaluated: 0,
498			matches: 0,
499			violations: 0,
500			antecedent_matches: None,
501			warning: None,
502			inconclusive: None,
503			verdict: None,
504			coverage: None,
505			path: None,
506		}
507	}
508
509	fn record(&mut self, outcome: NodeOutcome, premise: Option<NodeOutcome>) {
510		if matches!(premise, Some(NodeOutcome::Pass)) {
511			self.antecedent_matches = Some(self.antecedent_matches.unwrap_or(0) + 1);
512		}
513		match outcome {
514			NodeOutcome::Pass => {
515				if premise.is_none() || matches!(premise, Some(NodeOutcome::Pass)) {
516					self.matches += 1;
517				}
518			}
519			NodeOutcome::Fail(_) => self.violations += 1,
520			NodeOutcome::NotApplicable => {}
521		}
522	}
523}
524
525fn implication_premise(rule: &CompiledRule) -> Option<&Node> {
526	match &rule.root {
527		Node::Implies(premise, _) => Some(premise),
528		_ => None,
529	}
530}
531
532struct EvalCtx<'g, 'src> {
533	graph: &'g CodeGraph,
534	requirements: Option<&'g dyn RequirementResolver>,
535	source: &'src str,
536	lang: Lang,
537	uri_cfg: UriConfig<'src>,
538	parent_counts: HashMap<(usize, &'g [u8]), u32>,
539	children_by_parent: HashMap<usize, Vec<usize>>,
540	out_refs_by_source: HashMap<usize, Vec<usize>>,
541	in_refs_by_target: HashMap<Vec<u8>, Vec<usize>>,
542	comment_ends: Vec<u32>,
543	doc_anchors: HashMap<usize, u32>,
544	def_index: OnceCell<HashMap<Vec<u8>, usize>>,
545}
546
547#[derive(Debug)]
548struct CompiledRule {
549	id: String,
550	explicit_id: bool,
551	rule_id: String,
552	raw_expr: String,
553	expanded_expr: String,
554	root: Node,
555	severity: RuleSeverity,
556	message: Option<String>,
557	rationale: Option<String>,
558}
559
560#[derive(Default)]
561struct CompiledKindRules {
562	rules: Vec<CompiledRule>,
563	require_doc_for_vis: Option<String>,
564	require_doc_rule_id: Option<String>,
565}
566
567pub struct CompiledRules {
568	by_kind: HashMap<String, CompiledKindRules>,
569	by_shape: HashMap<String, CompiledKindRules>,
570	refs: Vec<CompiledRule>,
571}
572
573#[derive(Debug, Clone, serde::Serialize)]
574pub struct CompiledRuleSpec {
575	pub rule_id: String,
576	pub severity: RuleSeverity,
577	pub lang: String,
578	pub root: String,
579	pub subject: String,
580	pub plan: String,
581	pub capabilities: Vec<String>,
582	pub group_by: Vec<String>,
583	pub domain: String,
584	pub kind: Option<String>,
585	pub expr: String,
586	pub expanded_expr: String,
587	pub message: Option<String>,
588	#[serde(skip_serializing_if = "Option::is_none")]
589	pub rationale: Option<String>,
590	pub require_doc_comment: Option<String>,
591}
592
593impl CompiledRules {
594	fn for_lang(cfg: &Config, lang: Lang, scheme: &str) -> Result<Self, ConfigError> {
595		compile_rules_for_lang(cfg, lang, scheme)
596	}
597
598	fn for_kind(&self, kind: &str) -> Option<&CompiledKindRules> {
599		self.by_kind.get(kind)
600	}
601
602	fn for_shape(&self, shape: Shape) -> Option<&CompiledKindRules> {
603		self.by_shape.get(shape.as_str())
604	}
605
606	pub fn specs(&self, lang: Lang) -> Vec<CompiledRuleSpec> {
607		compiled_rule_specs(self, lang)
608	}
609}
610
611fn compile_rules_for_lang(
612	cfg: &Config,
613	lang: Lang,
614	scheme: &str,
615) -> Result<CompiledRules, ConfigError> {
616	let section = config_section(lang);
617	let allowed = crate::check::config::allowed_kinds_for(lang);
618	let aliases = crate::check::config::resolve_aliases(&cfg.aliases)?;
619	let mut by_kind: HashMap<String, CompiledKindRules> = HashMap::new();
620	let mut by_shape: HashMap<String, CompiledKindRules> = HashMap::new();
621	let mut per_lang_refs: Vec<&crate::check::config::RuleEntry> = Vec::new();
622	for (kind, rules) in cfg.for_lang(lang).kinds.iter() {
623		if kind == "refs" {
624			per_lang_refs.extend(rules.rules.iter());
625			continue;
626		}
627		by_kind.insert(
628			kind.clone(),
629			compile(rules, section, kind, scheme, &allowed, &aliases)?,
630		);
631	}
632	for (kind, rules) in cfg.default.kinds.iter() {
633		if kind == "refs" {
634			continue;
635		}
636		if !allowed.contains(&kind.as_str()) {
637			continue;
638		}
639		if !by_kind.contains_key(kind.as_str()) {
640			by_kind.insert(
641				kind.clone(),
642				compile(rules, "default", kind, scheme, &allowed, &aliases)?,
643			);
644		}
645	}
646	compile_shape_rules_into(
647		&mut by_shape,
648		&cfg.shape,
649		"shape",
650		scheme,
651		&allowed,
652		&aliases,
653	)?;
654	compile_shape_rules_into(
655		&mut by_shape,
656		&cfg.for_lang(lang).shape,
657		&format!("{section}.shape"),
658		scheme,
659		&allowed,
660		&aliases,
661	)?;
662	let mut refs = Vec::with_capacity(cfg.refs.rules.len() + per_lang_refs.len());
663	for (idx, entry) in cfg.refs.rules.iter().enumerate() {
664		let id = entry.fallback_id(idx);
665		let at = format!("refs.{id}");
666		refs.push(compile_rule_entry(
667			entry, id, at, scheme, &allowed, &aliases,
668		)?);
669	}
670	for (idx, entry) in per_lang_refs.iter().enumerate() {
671		let id = entry.fallback_id(idx);
672		let at = format!("{section}.refs.{id}");
673		refs.push(compile_rule_entry(
674			entry, id, at, scheme, &allowed, &aliases,
675		)?);
676	}
677	Ok(CompiledRules {
678		by_kind,
679		by_shape,
680		refs,
681	})
682}
683
684fn compiled_rule_specs(rules: &CompiledRules, lang: Lang) -> Vec<CompiledRuleSpec> {
685	let mut out = Vec::new();
686	for (kind, rules) in &rules.by_kind {
687		for rule in &rules.rules {
688			out.push(CompiledRuleSpec {
689				rule_id: rule_id(lang, kind, &rule.id),
690				lang: lang.tag().to_string(),
691				root: "local".to_string(),
692				subject: "symbol".to_string(),
693				plan: "t0_local".to_string(),
694				capabilities: Vec::new(),
695				group_by: Vec::new(),
696				domain: format!("{kind} defs"),
697				kind: Some(kind.clone()),
698				expr: rule.raw_expr.clone(),
699				expanded_expr: rule.expanded_expr.clone(),
700				message: rule.message.clone(),
701				severity: rule.severity,
702				rationale: rule.rationale.clone(),
703				require_doc_comment: None,
704			});
705		}
706		if let Some(value) = &rules.require_doc_for_vis {
707			out.push(CompiledRuleSpec {
708				rule_id: rule_id(lang, kind, "require_doc_comment"),
709				lang: lang.tag().to_string(),
710				root: "local".to_string(),
711				subject: "symbol".to_string(),
712				plan: "t0_local".to_string(),
713				capabilities: vec!["doc_comment".to_string()],
714				group_by: Vec::new(),
715				domain: format!("{kind} defs"),
716				kind: Some(kind.clone()),
717				expr: format!("require_doc_comment = \"{value}\""),
718				expanded_expr: format!("require_doc_comment = \"{value}\""),
719				message: None,
720				severity: RuleSeverity::Error,
721				rationale: None,
722				require_doc_comment: Some(value.clone()),
723			});
724		}
725	}
726	for (shape, rules) in &rules.by_shape {
727		for rule in &rules.rules {
728			out.push(CompiledRuleSpec {
729				rule_id: rule.rule_id.clone(),
730				lang: lang.tag().to_string(),
731				root: "local".to_string(),
732				subject: "symbol".to_string(),
733				plan: "t0_local".to_string(),
734				capabilities: Vec::new(),
735				group_by: Vec::new(),
736				domain: format!("shape:{shape} defs"),
737				kind: None,
738				expr: rule.raw_expr.clone(),
739				expanded_expr: rule.expanded_expr.clone(),
740				message: rule.message.clone(),
741				severity: rule.severity,
742				rationale: rule.rationale.clone(),
743				require_doc_comment: None,
744			});
745		}
746		if let (Some(value), Some(rule_id)) =
747			(&rules.require_doc_for_vis, &rules.require_doc_rule_id)
748		{
749			out.push(CompiledRuleSpec {
750				rule_id: rule_id.clone(),
751				lang: lang.tag().to_string(),
752				root: "local".to_string(),
753				subject: "symbol".to_string(),
754				plan: "t0_local".to_string(),
755				capabilities: vec!["doc_comment".to_string()],
756				group_by: Vec::new(),
757				domain: format!("shape:{shape} defs"),
758				kind: None,
759				expr: format!("require_doc_comment = \"{value}\""),
760				expanded_expr: format!("require_doc_comment = \"{value}\""),
761				message: None,
762				severity: RuleSeverity::Error,
763				rationale: None,
764				require_doc_comment: Some(value.clone()),
765			});
766		}
767	}
768	for rule in &rules.refs {
769		out.push(CompiledRuleSpec {
770			rule_id: rule.rule_id.clone(),
771			lang: lang.tag().to_string(),
772			root: "local".to_string(),
773			subject: "reference".to_string(),
774			plan: "t0_local".to_string(),
775			capabilities: Vec::new(),
776			group_by: Vec::new(),
777			domain: "refs".to_string(),
778			kind: None,
779			expr: rule.raw_expr.clone(),
780			expanded_expr: rule.expanded_expr.clone(),
781			message: rule.message.clone(),
782			severity: rule.severity,
783			rationale: rule.rationale.clone(),
784			require_doc_comment: None,
785		});
786	}
787	out.sort_by(|a, b| a.rule_id.cmp(&b.rule_id));
788	out
789}
790
791fn compile_rule_entry(
792	entry: &crate::check::config::RuleEntry,
793	id: String,
794	at: String,
795	scheme: &str,
796	allowed_kinds: &[&str],
797	aliases: &HashMap<String, String>,
798) -> Result<CompiledRule, ConfigError> {
799	let expanded = crate::check::config::substitute_aliases(&entry.expr, aliases, &at)?;
800	let parsed = expr::parse(&expanded, scheme, allowed_kinds).map_err(|error| {
801		ConfigError::InvalidExpr {
802			at: at.clone(),
803			error,
804		}
805	})?;
806	Ok(CompiledRule {
807		id,
808		explicit_id: entry.id.is_some(),
809		rule_id: at,
810		raw_expr: entry.expr.clone(),
811		expanded_expr: expanded,
812		root: parsed.root,
813		message: entry.message.clone(),
814		severity: entry.severity,
815		rationale: entry.rationale.clone(),
816	})
817}
818
819fn compile(
820	rules: &KindRules,
821	section: &str,
822	kind: &str,
823	scheme: &str,
824	allowed_kinds: &[&str],
825	aliases: &HashMap<String, String>,
826) -> Result<CompiledKindRules, ConfigError> {
827	let mut compiled = Vec::with_capacity(rules.rules.len());
828	for (idx, entry) in rules.rules.iter().enumerate() {
829		let id = entry.fallback_id(idx);
830		let at = format!("{section}.{kind}.{id}");
831		let expanded = crate::check::config::substitute_aliases(&entry.expr, aliases, &at)?;
832		let parsed = expr::parse(&expanded, scheme, allowed_kinds).map_err(|error| {
833			ConfigError::InvalidExpr {
834				at: at.clone(),
835				error,
836			}
837		})?;
838		compiled.push(CompiledRule {
839			id,
840			explicit_id: entry.id.is_some(),
841			rule_id: at,
842			raw_expr: entry.expr.clone(),
843			expanded_expr: expanded,
844			root: parsed.root,
845			message: entry.message.clone(),
846			severity: entry.severity,
847			rationale: entry.rationale.clone(),
848		});
849	}
850	Ok(CompiledKindRules {
851		rules: compiled,
852		require_doc_for_vis: rules.require_doc_comment.clone(),
853		require_doc_rule_id: rules
854			.require_doc_comment
855			.as_ref()
856			.map(|_| format!("{section}.{kind}.require_doc_comment")),
857	})
858}
859
860fn compile_shape_rules_into(
861	dst: &mut HashMap<String, CompiledKindRules>,
862	src: &HashMap<String, KindRules>,
863	section: &str,
864	scheme: &str,
865	allowed_kinds: &[&str],
866	aliases: &HashMap<String, String>,
867) -> Result<(), ConfigError> {
868	for (shape, rules) in src {
869		let compiled = compile(rules, section, shape, scheme, allowed_kinds, aliases)?;
870		match dst.get_mut(shape) {
871			Some(existing) => merge_compiled_kind_rules(existing, compiled),
872			None => {
873				dst.insert(shape.clone(), compiled);
874			}
875		}
876	}
877	Ok(())
878}
879
880fn merge_compiled_kind_rules(base: &mut CompiledKindRules, ov: CompiledKindRules) {
881	for rule in ov.rules {
882		match rule
883			.explicit_id
884			.then(|| {
885				base.rules
886					.iter()
887					.position(|r| r.explicit_id && r.id == rule.id)
888			})
889			.flatten()
890		{
891			Some(idx) => base.rules[idx] = rule,
892			None => base.rules.push(rule),
893		}
894	}
895	if ov.require_doc_for_vis.is_some() {
896		base.require_doc_for_vis = ov.require_doc_for_vis;
897		base.require_doc_rule_id = ov.require_doc_rule_id;
898	}
899}
900
901impl CompiledKindRules {
902	fn has_explicit_rule_id(&self, id: &str) -> bool {
903		self.rules
904			.iter()
905			.any(|rule| rule.explicit_id && rule.id == id)
906	}
907}
908
909fn rule_id(lang: Lang, kind: &str, rule: &str) -> String {
910	format!("{}.{}.{}", config_section(lang), kind, rule)
911}
912
913fn lines_of(d: &DefRecord, source: &str) -> (u32, u32) {
914	match d.position {
915		Some((s, e)) => line_range(source, s, e),
916		None => (0, 0),
917	}
918}
919
920fn def_name(d: &DefRecord) -> Option<String> {
921	let last = d.moniker.as_view().segments().last()?;
922	let bare = bare_callable_name(last.name);
923	std::str::from_utf8(bare).ok().map(|s| s.to_string())
924}
925
926fn render_template(tpl: &str, vars: &[(&str, &str)]) -> String {
927	let mut out = tpl.to_string();
928	for (k, v) in vars {
929		let placeholder = format!("{{{k}}}");
930		if out.contains(&placeholder) {
931			out = out.replace(&placeholder, v);
932		}
933	}
934	out
935}
936
937#[derive(Clone, Copy)]
938struct DefScope<'a> {
939	record: &'a DefRecord,
940	idx: usize,
941}
942
943#[derive(Clone, Copy)]
944struct RuleTarget<'a> {
945	scope: DefScope<'a>,
946	kind: &'a str,
947}
948
949fn eval_rule(
950	rule: &CompiledRule,
951	d: &DefRecord,
952	def_idx: usize,
953	kind: &str,
954	ctx: &EvalCtx<'_, '_>,
955	out: &mut Vec<Violation>,
956) {
957	let target = RuleTarget {
958		scope: DefScope {
959			record: d,
960			idx: def_idx,
961		},
962		kind,
963	};
964	eval_rule_with_id(rule, target, rule_id(ctx.lang, kind, &rule.id), ctx, out);
965}
966
967fn eval_shape_rule(
968	rule: &CompiledRule,
969	d: &DefRecord,
970	def_idx: usize,
971	kind: &str,
972	ctx: &EvalCtx<'_, '_>,
973	out: &mut Vec<Violation>,
974) {
975	let target = RuleTarget {
976		scope: DefScope {
977			record: d,
978			idx: def_idx,
979		},
980		kind,
981	};
982	eval_rule_with_id(rule, target, rule.rule_id.clone(), ctx, out);
983}
984
985fn eval_rule_with_id(
986	rule: &CompiledRule,
987	target: RuleTarget<'_>,
988	rule_id: String,
989	ctx: &EvalCtx<'_, '_>,
990	out: &mut Vec<Violation>,
991) {
992	let Failure {
993		atom_raw,
994		lhs_label,
995		actual,
996		expected,
997		def_idx,
998		details,
999	} = match eval_node(&rule.root, target.scope.record, target.scope.idx, ctx) {
1000		NodeOutcome::Pass | NodeOutcome::NotApplicable => return,
1001		NodeOutcome::Fail(f) => f,
1002	};
1003	let diagnostic = def_idx
1004		.map(|idx| ctx.graph.def_at(idx))
1005		.unwrap_or(target.scope.record);
1006	let diagnostic_kind = std::str::from_utf8(&diagnostic.kind).unwrap_or(target.kind);
1007	let name = def_name(diagnostic).unwrap_or_default();
1008	let name_snake = to_snake_case(&name);
1009	let moniker = to_uri(&diagnostic.moniker, &ctx.uri_cfg);
1010	let (start_line, end_line) = lines_of(diagnostic, ctx.source);
1011	let message = format!(
1012		"{diagnostic_kind} `{name}` fails `{atom_raw}` ({lhs_label} = {actual}, expected {expected})",
1013	);
1014	let explanation = rule
1015		.message
1016		.as_ref()
1017		.map(|tpl| {
1018			let mut rendered = render_template(
1019				tpl,
1020				&[
1021					("name", &name),
1022					("name.snake", &name_snake),
1023					("kind", diagnostic_kind),
1024					("moniker", &moniker),
1025					("expr", &rule.raw_expr),
1026					("actual", &actual),
1027					("value", &actual),
1028					("expected", &expected),
1029					("pattern", &expected),
1030					("lines", &actual),
1031					("limit", &expected),
1032					("count", &actual),
1033				],
1034			);
1035			if let Some(details) = &details {
1036				if !rendered.is_empty() {
1037					rendered.push('\n');
1038				}
1039				rendered.push_str(details);
1040			}
1041			rendered
1042		})
1043		.or(details);
1044	out.push(Violation {
1045		rule_id,
1046		severity: rule.severity,
1047		moniker,
1048		srcset: non_empty(first_segment_name(&target.scope.record.moniker, b"srcset")),
1049		kind: diagnostic_kind.to_string(),
1050		lines: (start_line, end_line),
1051		message,
1052		explanation,
1053	});
1054}
1055
1056fn eval_ref_rule(
1057	rule: &CompiledRule,
1058	r: &code_moniker_core::core::code_graph::RefRecord,
1059	graph: &CodeGraph,
1060	ctx: &EvalCtx<'_, '_>,
1061	out: &mut Vec<Violation>,
1062) {
1063	let Failure {
1064		atom_raw,
1065		lhs_label,
1066		actual,
1067		expected,
1068		def_idx: _,
1069		details,
1070	} = match eval_ref_node(&rule.root, r, ctx) {
1071		NodeOutcome::Pass | NodeOutcome::NotApplicable => return,
1072		NodeOutcome::Fail(f) => f,
1073	};
1074	let source_def = graph.def_at(r.source);
1075	let source_uri = to_uri(&source_def.moniker, &ctx.uri_cfg);
1076	let target_uri = to_uri(&r.target, &ctx.uri_cfg);
1077	let ref_kind = std::str::from_utf8(&r.kind).unwrap_or_default();
1078	let (start_line, end_line) = match r.position {
1079		Some((s, e)) => line_range(ctx.source, s, e),
1080		None => (0, 0),
1081	};
1082	let message = format!(
1083		"ref {ref_kind} {source_uri} → {target_uri} fails `{atom_raw}` ({lhs_label} = {actual}, expected {expected})"
1084	);
1085	let source_name = name_of(&source_def.moniker).unwrap_or_default();
1086	let source_kind = last_segment_kind(&source_def.moniker).unwrap_or_default();
1087	let source_shape = shape_name_of_last_segment(&source_def.moniker);
1088	let target_name = name_of(&r.target).unwrap_or_default();
1089	let target_kind = last_segment_kind(&r.target).unwrap_or_default();
1090	let target_shape = shape_name_of_last_segment(&r.target);
1091	let explanation = rule
1092		.message
1093		.as_ref()
1094		.map(|tpl| {
1095			let mut rendered = render_template(
1096				tpl,
1097				&[
1098					("kind", ref_kind),
1099					("source.name", &source_name),
1100					("source.kind", &source_kind),
1101					("source.shape", &source_shape),
1102					("source.moniker", &source_uri),
1103					("target.name", &target_name),
1104					("target.kind", &target_kind),
1105					("target.shape", &target_shape),
1106					("target.moniker", &target_uri),
1107					("atom", &atom_raw),
1108					("actual", &actual),
1109					("expected", &expected),
1110				],
1111			);
1112			if let Some(details) = &details {
1113				if !rendered.is_empty() {
1114					rendered.push('\n');
1115				}
1116				rendered.push_str(details);
1117			}
1118			rendered
1119		})
1120		.or(details);
1121	out.push(Violation {
1122		rule_id: rule.rule_id.clone(),
1123		severity: rule.severity,
1124		moniker: target_uri,
1125		srcset: non_empty(first_segment_name(
1126			&graph.def_at(r.source).moniker,
1127			b"srcset",
1128		)),
1129		kind: ref_kind.to_string(),
1130		lines: (start_line, end_line),
1131		message,
1132		explanation,
1133	});
1134}
1135
1136fn eval_ref_node(
1137	node: &Node,
1138	r: &code_moniker_core::core::code_graph::RefRecord,
1139	ctx: &EvalCtx<'_, '_>,
1140) -> NodeOutcome {
1141	eval_ref_node_with_current(node, r, r, ctx)
1142}
1143
1144fn eval_ref_node_with_current(
1145	node: &Node,
1146	r: &code_moniker_core::core::code_graph::RefRecord,
1147	current: &code_moniker_core::core::code_graph::RefRecord,
1148	ctx: &EvalCtx<'_, '_>,
1149) -> NodeOutcome {
1150	walk_node(
1151		node,
1152		&|a| eval_ref_atom(a, r, current, ctx),
1153		&|kind, domain, filter| eval_quantifier_ref(kind, domain, filter, r, ctx),
1154		&|_| NodeOutcome::NotApplicable,
1155		&|_| NodeOutcome::NotApplicable,
1156	)
1157}
1158
1159fn eval_ref_atom(
1160	atom: &Atom,
1161	r: &code_moniker_core::core::code_graph::RefRecord,
1162	current: &code_moniker_core::core::code_graph::RefRecord,
1163	ctx: &EvalCtx<'_, '_>,
1164) -> AtomOutcome {
1165	let Some(value) = eval_ref_lhs_expr_value(&atom.lhs, r, ctx) else {
1166		return AtomOutcome::NotApplicable;
1167	};
1168	if let Rhs::Projection(other) = &atom.rhs {
1169		let Some(rhs_val) = resolve_ref_lhs(*other, r, ctx) else {
1170			return AtomOutcome::NotApplicable;
1171		};
1172		return apply_op_values(&value, atom.op, &rhs_val);
1173	}
1174	if let Rhs::CurrentProjection(other) = &atom.rhs {
1175		let Some(rhs_val) = resolve_ref_lhs(*other, current, ctx) else {
1176			return AtomOutcome::NotApplicable;
1177		};
1178		return apply_op_values(&value, atom.op, &rhs_val);
1179	}
1180	if let Rhs::Number(expr) = &atom.rhs {
1181		let Some(rhs_val) = eval_number_expr_ref(expr, r, ctx).map(Value::Number) else {
1182			return AtomOutcome::NotApplicable;
1183		};
1184		return apply_op_values(&value, atom.op, &rhs_val);
1185	}
1186	apply_op(&value, atom)
1187}
1188
1189fn eval_ref_lhs_expr_value(
1190	lhs: &LhsExpr,
1191	r: &code_moniker_core::core::code_graph::RefRecord,
1192	ctx: &EvalCtx<'_, '_>,
1193) -> Option<Value> {
1194	match lhs {
1195		LhsExpr::Attr(lhs) => resolve_ref_lhs(*lhs, r, ctx),
1196		LhsExpr::SegmentOf { scope, kind } => match scope {
1197			SegmentScope::Def => None,
1198			SegmentScope::Source => {
1199				let source_def = ctx.graph.def_at(r.source);
1200				Some(Value::Str(first_segment_name(
1201					&source_def.moniker,
1202					kind.as_bytes(),
1203				)))
1204			}
1205			SegmentScope::Target => {
1206				Some(Value::Str(first_segment_name(&r.target, kind.as_bytes())))
1207			}
1208		},
1209		LhsExpr::Number(expr) => eval_number_expr_ref(expr, r, ctx).map(Value::Number),
1210		LhsExpr::Collection(_) | LhsExpr::Mode(_) | LhsExpr::PairProjection(_) => None,
1211	}
1212}
1213
1214fn eval_quantifier_ref(
1215	kind: QuantKind,
1216	domain: &Domain,
1217	filter: &Node,
1218	r: &code_moniker_core::core::code_graph::RefRecord,
1219	ctx: &EvalCtx<'_, '_>,
1220) -> NodeOutcome {
1221	let items = ref_domain_items(domain, r, ctx);
1222	if items.is_empty() {
1223		return match kind {
1224			QuantKind::All | QuantKind::None => NodeOutcome::Pass,
1225			QuantKind::Any => NodeOutcome::Fail(Failure {
1226				atom_raw: format!("any({})", domain_debug_label(domain)),
1227				lhs_label: "any".to_string(),
1228				actual: "0 matches".to_string(),
1229				expected: "at least one".to_string(),
1230				def_idx: None,
1231				details: None,
1232			}),
1233		};
1234	}
1235	let mut matched = 0usize;
1236	for item in items {
1237		let outcome = match item {
1238			DomainItem::Ref { record } => eval_ref_node_with_current(filter, record, r, ctx),
1239			DomainItem::Def {
1240				idx: Some(idx),
1241				def,
1242			} => eval_node_with_self(filter, def, idx, r.source, ctx),
1243			DomainItem::Def { idx: None, def } => eval_external_def_node(filter, def, ctx),
1244			DomainItem::Segment { kind, name } => eval_node_segment(filter, kind, name),
1245		};
1246		if matches!(outcome, NodeOutcome::Pass) {
1247			matched += 1;
1248			if kind == QuantKind::Any {
1249				return NodeOutcome::Pass;
1250			}
1251		} else if matches!(outcome, NodeOutcome::Fail(_)) && kind == QuantKind::All {
1252			return outcome;
1253		}
1254	}
1255	match kind {
1256		QuantKind::Any => NodeOutcome::Fail(Failure {
1257			atom_raw: format!("any({})", domain_debug_label(domain)),
1258			lhs_label: "any".to_string(),
1259			actual: format!("{matched} matches"),
1260			expected: "at least one".to_string(),
1261			def_idx: None,
1262			details: None,
1263		}),
1264		QuantKind::All => NodeOutcome::Pass,
1265		QuantKind::None if matched == 0 => NodeOutcome::Pass,
1266		QuantKind::None => NodeOutcome::Fail(Failure {
1267			atom_raw: format!("none({})", domain_debug_label(domain)),
1268			lhs_label: "none".to_string(),
1269			actual: format!("{matched} matches"),
1270			expected: "0 matches".to_string(),
1271			def_idx: None,
1272			details: None,
1273		}),
1274	}
1275}
1276
1277fn ref_domain_items<'a>(
1278	domain: &Domain,
1279	r: &code_moniker_core::core::code_graph::RefRecord,
1280	ctx: &'a EvalCtx<'_, '_>,
1281) -> Vec<DomainItem<'a>> {
1282	match domain {
1283		Domain::SourceOutRefs | Domain::OutRefs => ctx
1284			.out_refs_by_source
1285			.get(&r.source)
1286			.into_iter()
1287			.flatten()
1288			.map(|idx| DomainItem::Ref {
1289				record: ctx.graph.ref_at(*idx),
1290			})
1291			.collect(),
1292		Domain::SourceInRefs | Domain::InRefs => {
1293			let source = ctx.graph.def_at(r.source);
1294			let key = source.moniker.as_encoded();
1295			ctx.in_refs_by_target
1296				.get(key)
1297				.into_iter()
1298				.flatten()
1299				.map(|idx| DomainItem::Ref {
1300					record: ctx.graph.ref_at(*idx),
1301				})
1302				.collect()
1303		}
1304		Domain::TargetOutRefs => {
1305			let Some(target_idx) = local_def_index(&r.target, ctx) else {
1306				return Vec::new();
1307			};
1308			ctx.out_refs_by_source
1309				.get(&target_idx)
1310				.into_iter()
1311				.flatten()
1312				.map(|idx| DomainItem::Ref {
1313					record: ctx.graph.ref_at(*idx),
1314				})
1315				.collect()
1316		}
1317		Domain::TargetInRefs => {
1318			if local_def_index(&r.target, ctx).is_none() {
1319				return Vec::new();
1320			}
1321			ctx.in_refs_by_target
1322				.get(r.target.as_encoded())
1323				.into_iter()
1324				.flatten()
1325				.map(|idx| DomainItem::Ref {
1326					record: ctx.graph.ref_at(*idx),
1327				})
1328				.collect()
1329		}
1330		Domain::SourceAncestorOutRefs => ancestor_ref_items(r.source, ctx, true),
1331		Domain::SourceAncestorInRefs => ancestor_ref_items(r.source, ctx, false),
1332		Domain::Segments => ctx
1333			.graph
1334			.def_at(r.source)
1335			.moniker
1336			.as_view()
1337			.segments()
1338			.map(|seg| DomainItem::Segment {
1339				kind: seg.kind,
1340				name: seg.name,
1341			})
1342			.collect(),
1343		Domain::Children(_) | Domain::ChildrenByShape(_) | Domain::Descendants(_) => {
1344			domain_items(domain, r.source, ctx)
1345		}
1346		Domain::Pairs(_) => Vec::new(),
1347	}
1348}
1349
1350fn domain_debug_label(domain: &Domain) -> &'static str {
1351	match domain {
1352		Domain::Children(_) => "children",
1353		Domain::ChildrenByShape(_) => "shape",
1354		Domain::Descendants(_) => "descendants",
1355		Domain::Pairs(_) => "pairs",
1356		Domain::Segments => "segment",
1357		Domain::OutRefs => "out_refs",
1358		Domain::InRefs => "in_refs",
1359		Domain::SourceOutRefs => "source.out_refs",
1360		Domain::SourceInRefs => "source.in_refs",
1361		Domain::TargetOutRefs => "target.out_refs",
1362		Domain::TargetInRefs => "target.in_refs",
1363		Domain::SourceAncestorOutRefs => "source.ancestors.out_refs",
1364		Domain::SourceAncestorInRefs => "source.ancestors.in_refs",
1365	}
1366}
1367
1368fn ancestor_ref_items<'a>(
1369	def_idx: usize,
1370	ctx: &'a EvalCtx<'_, '_>,
1371	outgoing: bool,
1372) -> Vec<DomainItem<'a>> {
1373	ancestor_ref_indexes(def_idx, ctx, outgoing)
1374		.into_iter()
1375		.map(|ref_idx| DomainItem::Ref {
1376			record: ctx.graph.ref_at(ref_idx),
1377		})
1378		.collect()
1379}
1380
1381fn ancestor_ref_indexes(def_idx: usize, ctx: &EvalCtx<'_, '_>, outgoing: bool) -> Vec<usize> {
1382	let mut items = Vec::new();
1383	let mut parent = ctx.graph.def_at(def_idx).parent;
1384	while let Some(idx) = parent {
1385		if outgoing {
1386			if let Some(refs) = ctx.out_refs_by_source.get(&idx) {
1387				items.extend(refs.iter().copied());
1388			}
1389		} else {
1390			let key = ctx.graph.def_at(idx).moniker.as_encoded();
1391			if let Some(refs) = ctx.in_refs_by_target.get(key) {
1392				items.extend(refs.iter().copied());
1393			}
1394		}
1395		parent = ctx.graph.def_at(idx).parent;
1396	}
1397	items
1398}
1399
1400fn resolve_ref_lhs(
1401	lhs: Lhs,
1402	r: &code_moniker_core::core::code_graph::RefRecord,
1403	ctx: &EvalCtx<'_, '_>,
1404) -> Option<Value> {
1405	let graph = ctx.graph;
1406	let source_def = graph.def_at(r.source);
1407	Some(match lhs {
1408		Lhs::Kind => Value::Str(std::str::from_utf8(&r.kind).ok()?.to_string()),
1409		Lhs::Confidence => Value::Str(std::str::from_utf8(&r.confidence).ok()?.to_string()),
1410		Lhs::StartLine => {
1411			let (s, e) = r.position?;
1412			let (sl, _) = line_range(ctx.source, s, e);
1413			Value::Number(sl as f64)
1414		}
1415		Lhs::EndLine => {
1416			let (s, e) = r.position?;
1417			let (_, el) = line_range(ctx.source, s, e);
1418			Value::Number(el as f64)
1419		}
1420		Lhs::StartByte => {
1421			let (s, _) = r.position?;
1422			Value::Number(s as f64)
1423		}
1424		Lhs::EndByte => {
1425			let (_, e) = r.position?;
1426			Value::Number(e as f64)
1427		}
1428		Lhs::Text => Value::Str(ref_text(r, ctx)?),
1429		Lhs::Moniker | Lhs::SourceMoniker => Value::Moniker(source_def.moniker.clone()),
1430		Lhs::ParentMoniker => Value::Moniker(source_def.moniker.parent()?),
1431		Lhs::SourceParentMoniker => Value::Moniker(source_def.moniker.parent()?),
1432		Lhs::ParentName => Value::Str(name_of(&source_def.moniker.parent()?)?),
1433		Lhs::ParentKind => Value::Str(last_segment_kind(&source_def.moniker.parent()?)?),
1434		Lhs::TargetMoniker => Value::Moniker(r.target.clone()),
1435		Lhs::TargetParentMoniker => Value::Moniker(r.target.parent()?),
1436		Lhs::SourceName => Value::Str(name_of(&source_def.moniker)?),
1437		Lhs::TargetName => Value::Str(name_of(&r.target)?),
1438		Lhs::SourceKind => Value::Str(last_segment_kind(&source_def.moniker)?),
1439		Lhs::TargetKind => Value::Str(last_segment_kind(&r.target)?),
1440		Lhs::Shape | Lhs::SourceShape => Value::Str(
1441			shape_of_last_segment(&source_def.moniker)?
1442				.as_str()
1443				.to_string(),
1444		),
1445		Lhs::TargetShape => Value::Str(shape_of_last_segment(&r.target)?.as_str().to_string()),
1446		Lhs::Srcset | Lhs::SourceSrcset => {
1447			Value::Str(first_segment_name(&source_def.moniker, b"srcset"))
1448		}
1449		Lhs::TargetSrcset => Value::Str(first_segment_name(&r.target, b"srcset")),
1450		Lhs::ParentShape => {
1451			let segs: Vec<_> = source_def.moniker.as_view().segments().collect();
1452			if segs.len() < 2 {
1453				return None;
1454			}
1455			let parent_kind = segs[segs.len() - 2].kind;
1456			Value::Str(
1457				code_moniker_core::core::shape::shape_of(parent_kind)?
1458					.as_str()
1459					.to_string(),
1460			)
1461		}
1462		Lhs::SourceVisibility => Value::Str(
1463			std::str::from_utf8(&source_def.visibility)
1464				.ok()?
1465				.to_string(),
1466		),
1467		Lhs::TargetVisibility => {
1468			let def = resolve_local_def(graph, &r.target)?;
1469			Value::Str(std::str::from_utf8(&def.visibility).ok()?.to_string())
1470		}
1471		Lhs::Name
1472		| Lhs::Visibility
1473		| Lhs::Lines
1474		| Lhs::Depth
1475		| Lhs::SegmentName
1476		| Lhs::SegmentKind => return None,
1477	})
1478}
1479
1480fn ref_text(
1481	r: &code_moniker_core::core::code_graph::RefRecord,
1482	ctx: &EvalCtx<'_, '_>,
1483) -> Option<String> {
1484	let (start, end) = r.position?;
1485	ctx.source
1486		.get(start as usize..end as usize)
1487		.map(ToString::to_string)
1488}
1489
1490fn name_of(m: &code_moniker_core::core::moniker::Moniker) -> Option<String> {
1491	let last = m.as_view().segments().last()?;
1492	let bare = code_moniker_core::core::moniker::query::bare_callable_name(last.name);
1493	std::str::from_utf8(bare).ok().map(|s| s.to_string())
1494}
1495
1496fn first_segment_name(m: &code_moniker_core::core::moniker::Moniker, kind: &[u8]) -> String {
1497	for seg in m.as_view().segments() {
1498		if seg.kind == kind {
1499			return std::str::from_utf8(seg.name)
1500				.unwrap_or_default()
1501				.to_string();
1502		}
1503	}
1504	String::new()
1505}
1506
1507fn non_empty(value: String) -> Option<String> {
1508	(!value.is_empty()).then_some(value)
1509}
1510
1511fn last_segment_kind(m: &code_moniker_core::core::moniker::Moniker) -> Option<String> {
1512	let last = m.as_view().segments().last()?;
1513	std::str::from_utf8(last.kind).ok().map(|s| s.to_string())
1514}
1515
1516fn shape_of_last_segment(
1517	m: &code_moniker_core::core::moniker::Moniker,
1518) -> Option<code_moniker_core::core::shape::Shape> {
1519	let last = m.as_view().segments().last()?;
1520	code_moniker_core::core::shape::shape_of(last.kind)
1521}
1522
1523fn shape_name_of_last_segment(m: &code_moniker_core::core::moniker::Moniker) -> String {
1524	shape_of_last_segment(m)
1525		.map(|shape| shape.as_str().to_string())
1526		.unwrap_or_default()
1527}
1528
1529fn resolve_local_def<'g>(
1530	graph: &'g CodeGraph,
1531	m: &code_moniker_core::core::moniker::Moniker,
1532) -> Option<&'g DefRecord> {
1533	graph.defs().find(|d| d.moniker == *m)
1534}
1535
1536fn local_def_index(
1537	m: &code_moniker_core::core::moniker::Moniker,
1538	ctx: &EvalCtx<'_, '_>,
1539) -> Option<usize> {
1540	ctx.def_index
1541		.get_or_init(|| {
1542			ctx.graph
1543				.defs()
1544				.enumerate()
1545				.map(|(idx, def)| (def.moniker.as_encoded().to_vec(), idx))
1546				.collect()
1547		})
1548		.get(m.as_encoded())
1549		.copied()
1550}
1551
1552fn describe_lhs(lhs: &LhsExpr) -> &str {
1553	match lhs {
1554		LhsExpr::Attr(a) => a.as_str(),
1555		LhsExpr::Number(n) => number_expr_label(n),
1556		LhsExpr::Collection(_) => "collection",
1557		LhsExpr::Mode(_) => "mode",
1558		LhsExpr::PairProjection(_) => "pair",
1559		LhsExpr::SegmentOf { .. } => "segment",
1560	}
1561}
1562
1563#[derive(Debug)]
1564struct Failure {
1565	atom_raw: String,
1566	lhs_label: String,
1567	actual: String,
1568	expected: String,
1569	def_idx: Option<usize>,
1570	details: Option<String>,
1571}
1572
1573#[derive(Debug)]
1574enum NodeOutcome {
1575	Pass,
1576	Fail(Failure),
1577	NotApplicable,
1578}
1579
1580enum AtomOutcome {
1581	Pass,
1582	Fail { actual: String, expected: String },
1583	NotApplicable,
1584}
1585
1586/// Trivalent-logic walker shared by def/ref/segment evaluators. `atom_eval`
1587/// produces the atom leaf outcome; `quant_eval` handles `Node::Quantifier`
1588/// (scopes that can't iterate, like ref and segment, return NotApplicable).
1589fn walk_node<A, Q, R, L>(
1590	node: &Node,
1591	atom_eval: &A,
1592	quant_eval: &Q,
1593	require_eval: &R,
1594	layout_eval: &L,
1595) -> NodeOutcome
1596where
1597	A: Fn(&Atom) -> AtomOutcome,
1598	Q: Fn(QuantKind, &Domain, &Node) -> NodeOutcome,
1599	R: Fn(&str) -> NodeOutcome,
1600	L: Fn(&VerticalLayout) -> NodeOutcome,
1601{
1602	match node {
1603		Node::Atom(atom) => match atom_eval(atom) {
1604			AtomOutcome::Pass => NodeOutcome::Pass,
1605			AtomOutcome::Fail { actual, expected } => NodeOutcome::Fail(Failure {
1606				atom_raw: atom.raw.clone(),
1607				lhs_label: describe_lhs(&atom.lhs).to_string(),
1608				actual,
1609				expected,
1610				def_idx: None,
1611				details: None,
1612			}),
1613			AtomOutcome::NotApplicable => NodeOutcome::NotApplicable,
1614		},
1615		Node::And(children) => {
1616			let mut na = false;
1617			for c in children {
1618				match walk_node(c, atom_eval, quant_eval, require_eval, layout_eval) {
1619					NodeOutcome::Pass => {}
1620					NodeOutcome::Fail(f) => return NodeOutcome::Fail(f),
1621					NodeOutcome::NotApplicable => na = true,
1622				}
1623			}
1624			if na {
1625				NodeOutcome::NotApplicable
1626			} else {
1627				NodeOutcome::Pass
1628			}
1629		}
1630		Node::Or(children) => {
1631			let mut last_fail: Option<Failure> = None;
1632			let mut na = false;
1633			for c in children {
1634				match walk_node(c, atom_eval, quant_eval, require_eval, layout_eval) {
1635					NodeOutcome::Pass => return NodeOutcome::Pass,
1636					NodeOutcome::Fail(f) => last_fail = Some(f),
1637					NodeOutcome::NotApplicable => na = true,
1638				}
1639			}
1640			if na {
1641				NodeOutcome::NotApplicable
1642			} else if let Some(f) = last_fail {
1643				NodeOutcome::Fail(f)
1644			} else {
1645				NodeOutcome::NotApplicable
1646			}
1647		}
1648		Node::Not(inner) => {
1649			match walk_node(inner, atom_eval, quant_eval, require_eval, layout_eval) {
1650				NodeOutcome::Pass => NodeOutcome::Fail(Failure {
1651					atom_raw: "NOT (...)".to_string(),
1652					lhs_label: "NOT".to_string(),
1653					actual: "true".to_string(),
1654					expected: "false".to_string(),
1655					def_idx: None,
1656					details: None,
1657				}),
1658				NodeOutcome::Fail(_) => NodeOutcome::Pass,
1659				NodeOutcome::NotApplicable => NodeOutcome::NotApplicable,
1660			}
1661		}
1662		Node::Implies(prem, cons) => {
1663			match walk_node(prem, atom_eval, quant_eval, require_eval, layout_eval) {
1664				NodeOutcome::Pass => {
1665					walk_node(cons, atom_eval, quant_eval, require_eval, layout_eval)
1666				}
1667				NodeOutcome::Fail(_) => NodeOutcome::Pass,
1668				NodeOutcome::NotApplicable => NodeOutcome::NotApplicable,
1669			}
1670		}
1671		Node::Require(pattern) => require_eval(pattern),
1672		Node::VerticalLayout(layout) => layout_eval(layout),
1673		Node::Quantifier {
1674			kind,
1675			domain,
1676			filter,
1677		} => quant_eval(*kind, domain, filter),
1678	}
1679}
1680
1681fn eval_node(node: &Node, d: &DefRecord, def_idx: usize, ctx: &EvalCtx<'_, '_>) -> NodeOutcome {
1682	eval_node_with_self(node, d, def_idx, def_idx, ctx)
1683}
1684
1685fn eval_node_with_self(
1686	node: &Node,
1687	d: &DefRecord,
1688	def_idx: usize,
1689	self_idx: usize,
1690	ctx: &EvalCtx<'_, '_>,
1691) -> NodeOutcome {
1692	walk_node(
1693		node,
1694		&|a| eval_atom(a, d, def_idx, self_idx, ctx),
1695		&|kind, domain, filter| {
1696			eval_quantifier_def(
1697				kind,
1698				domain,
1699				filter,
1700				DefScope {
1701					record: d,
1702					idx: def_idx,
1703				},
1704				self_idx,
1705				ctx,
1706			)
1707		},
1708		&|pattern| eval_require(pattern, d, ctx),
1709		&|layout| eval_vertical_layout(layout, d, def_idx, ctx),
1710	)
1711}
1712
1713fn eval_require(pattern: &str, d: &DefRecord, ctx: &EvalCtx<'_, '_>) -> NodeOutcome {
1714	let Some(rendered) = render_requirement_pattern(pattern, d) else {
1715		return NodeOutcome::NotApplicable;
1716	};
1717	if local_requirement_exists(&rendered, ctx)
1718		|| ctx
1719			.requirements
1720			.is_some_and(|resolver| resolver.exists(&rendered, d, ctx.uri_cfg.scheme))
1721	{
1722		return NodeOutcome::Pass;
1723	}
1724	NodeOutcome::Fail(Failure {
1725		atom_raw: format!("require(\"{pattern}\")"),
1726		lhs_label: "require".to_string(),
1727		actual: "missing".to_string(),
1728		expected: rendered,
1729		def_idx: None,
1730		details: None,
1731	})
1732}
1733
1734fn local_requirement_exists(pattern: &str, ctx: &EvalCtx<'_, '_>) -> bool {
1735	let Ok(pattern) = crate::check::path::parse(pattern) else {
1736		return false;
1737	};
1738	ctx.graph
1739		.defs()
1740		.any(|def| crate::check::path::matches(&pattern, &def.moniker))
1741}
1742
1743fn render_requirement_pattern(pattern: &str, d: &DefRecord) -> Option<String> {
1744	let name = def_name(d)?;
1745	Some(
1746		pattern
1747			.replace("{name}", &name)
1748			.replace("{name.snake}", &to_snake_case(&name)),
1749	)
1750}
1751
1752fn to_snake_case(name: &str) -> String {
1753	let mut out = String::new();
1754	for (idx, ch) in name.chars().enumerate() {
1755		if ch.is_ascii_uppercase() {
1756			if idx > 0 {
1757				out.push('_');
1758			}
1759			out.push(ch.to_ascii_lowercase());
1760		} else {
1761			out.push(ch);
1762		}
1763	}
1764	out
1765}
1766
1767fn resolve_def_lhs(lhs: Lhs, d: &DefRecord, ctx: &EvalCtx<'_, '_>) -> Option<Value> {
1768	let source = ctx.source;
1769	let value = match lhs {
1770		Lhs::Name => Value::Str(def_name(d)?),
1771		Lhs::Kind => Value::Str(std::str::from_utf8(&d.kind).ok()?.to_string()),
1772		Lhs::Visibility => Value::Str(std::str::from_utf8(&d.visibility).ok()?.to_string()),
1773		Lhs::Lines => {
1774			let (s, e) = d.position?;
1775			let (sl, el) = line_range(source, s, e);
1776			Value::Number((el - sl + 1) as f64)
1777		}
1778		Lhs::StartLine => {
1779			let (s, e) = d.position?;
1780			let (sl, _) = line_range(source, s, e);
1781			Value::Number(sl as f64)
1782		}
1783		Lhs::EndLine => {
1784			let (s, e) = d.position?;
1785			let (_, el) = line_range(source, s, e);
1786			Value::Number(el as f64)
1787		}
1788		Lhs::StartByte => {
1789			let (s, _) = d.position?;
1790			Value::Number(s as f64)
1791		}
1792		Lhs::EndByte => {
1793			let (_, e) = d.position?;
1794			Value::Number(e as f64)
1795		}
1796		Lhs::Text => {
1797			let (s, e) = d.position?;
1798			Value::Str(source.get(s as usize..e as usize).unwrap_or("").to_string())
1799		}
1800		Lhs::Moniker => Value::Moniker(d.moniker.clone()),
1801		Lhs::ParentMoniker => Value::Moniker(d.moniker.parent()?),
1802		Lhs::Depth => Value::Number(d.moniker.as_view().segments().count() as f64),
1803		Lhs::ParentName => {
1804			let segs: Vec<_> = d.moniker.as_view().segments().collect();
1805			if segs.len() < 2 {
1806				return None;
1807			}
1808			let p = &segs[segs.len() - 2];
1809			let bare = bare_callable_name(p.name);
1810			Value::Str(std::str::from_utf8(bare).ok()?.to_string())
1811		}
1812		Lhs::ParentKind => {
1813			let segs: Vec<_> = d.moniker.as_view().segments().collect();
1814			if segs.len() < 2 {
1815				return None;
1816			}
1817			let p = &segs[segs.len() - 2];
1818			Value::Str(std::str::from_utf8(p.kind).ok()?.to_string())
1819		}
1820		Lhs::Shape => Value::Str(d.shape()?.as_str().to_string()),
1821		Lhs::Srcset => Value::Str(first_segment_name(&d.moniker, b"srcset")),
1822		Lhs::ParentShape => {
1823			let segs: Vec<_> = d.moniker.as_view().segments().collect();
1824			if segs.len() < 2 {
1825				return None;
1826			}
1827			let parent_kind = segs[segs.len() - 2].kind;
1828			Value::Str(
1829				code_moniker_core::core::shape::shape_of(parent_kind)?
1830					.as_str()
1831					.to_string(),
1832			)
1833		}
1834		Lhs::SourceName => Value::Str(def_name(d)?),
1835		Lhs::SourceKind => Value::Str(std::str::from_utf8(&d.kind).ok()?.to_string()),
1836		Lhs::SourceShape => Value::Str(d.shape()?.as_str().to_string()),
1837		Lhs::SourceVisibility => Value::Str(std::str::from_utf8(&d.visibility).ok()?.to_string()),
1838		Lhs::SourceSrcset => Value::Str(first_segment_name(&d.moniker, b"srcset")),
1839		Lhs::SourceMoniker => Value::Moniker(d.moniker.clone()),
1840		Lhs::SourceParentMoniker => Value::Moniker(d.moniker.parent()?),
1841		Lhs::Confidence
1842		| Lhs::TargetName
1843		| Lhs::TargetKind
1844		| Lhs::TargetShape
1845		| Lhs::TargetVisibility
1846		| Lhs::TargetSrcset
1847		| Lhs::TargetMoniker
1848		| Lhs::TargetParentMoniker
1849		| Lhs::SegmentName
1850		| Lhs::SegmentKind => return None,
1851	};
1852	Some(value)
1853}
1854
1855/// `count(<domain>, <filter>?)` evaluated in def scope. Counts items in
1856/// the domain for which `filter` evaluates to Pass.
1857fn eval_count(
1858	domain: &Domain,
1859	filter: Option<&Node>,
1860	d: &DefRecord,
1861	def_idx: usize,
1862	self_idx: usize,
1863	ctx: &EvalCtx<'_, '_>,
1864) -> u32 {
1865	match domain {
1866		Domain::Children(kind) => match filter {
1867			None => ctx
1868				.parent_counts
1869				.get(&(def_idx, kind.as_bytes()))
1870				.copied()
1871				.unwrap_or(0),
1872			Some(node) => count_children_filtered(d, def_idx, self_idx, kind, node, ctx),
1873		},
1874		Domain::ChildrenByShape(shape) => {
1875			count_children_by_shape(def_idx, self_idx, shape, filter, ctx)
1876		}
1877		Domain::Descendants(_) => count_domain_items(domain, filter, def_idx, self_idx, ctx),
1878		Domain::Pairs(inner) => eval_pair_count(inner, filter, def_idx, self_idx, ctx),
1879		Domain::Segments => count_segments(d, filter),
1880		Domain::OutRefs | Domain::SourceOutRefs => count_out_refs(d, def_idx, filter, ctx),
1881		Domain::InRefs | Domain::SourceInRefs => count_in_refs(d, filter, ctx),
1882		Domain::TargetOutRefs | Domain::TargetInRefs => 0,
1883		Domain::SourceAncestorOutRefs | Domain::SourceAncestorInRefs => {
1884			count_domain_items(domain, filter, def_idx, self_idx, ctx)
1885		}
1886	}
1887}
1888
1889fn count_domain_items(
1890	domain: &Domain,
1891	filter: Option<&Node>,
1892	def_idx: usize,
1893	self_idx: usize,
1894	ctx: &EvalCtx<'_, '_>,
1895) -> u32 {
1896	let items = domain_items(domain, def_idx, ctx);
1897	let Some(node) = filter else {
1898		return items.len() as u32;
1899	};
1900	items
1901		.into_iter()
1902		.filter(|item| match item {
1903			DomainItem::Def {
1904				idx: Some(idx),
1905				def,
1906			} => {
1907				matches!(
1908					eval_node_with_self(node, def, *idx, self_idx, ctx),
1909					NodeOutcome::Pass
1910				)
1911			}
1912			DomainItem::Def { idx: None, def } => {
1913				matches!(eval_external_def_node(node, def, ctx), NodeOutcome::Pass)
1914			}
1915			DomainItem::Ref { record } => {
1916				matches!(eval_ref_node(node, record, ctx), NodeOutcome::Pass)
1917			}
1918			DomainItem::Segment { kind, name } => {
1919				matches!(eval_node_segment(node, kind, name), NodeOutcome::Pass)
1920			}
1921		})
1922		.count() as u32
1923}
1924
1925fn eval_number_expr_def(
1926	expr: &NumberExpr,
1927	d: &DefRecord,
1928	def_idx: usize,
1929	self_idx: usize,
1930	ctx: &EvalCtx<'_, '_>,
1931) -> Option<f64> {
1932	match expr {
1933		NumberExpr::Literal(n) => Some(*n),
1934		NumberExpr::Projection(lhs) => match resolve_def_lhs(*lhs, d, ctx)? {
1935			Value::Number(n) => Some(n),
1936			_ => None,
1937		},
1938		NumberExpr::Count { domain, filter } => {
1939			Some(eval_count(domain, filter.as_deref(), d, def_idx, self_idx, ctx) as f64)
1940		}
1941		NumberExpr::Aggregate {
1942			kind,
1943			domain,
1944			expr,
1945			percentile,
1946		} => eval_aggregate(
1947			AggregateEval {
1948				kind: *kind,
1949				domain,
1950				expr,
1951				percentile: *percentile,
1952				def_idx,
1953				self_idx,
1954			},
1955			ctx,
1956		),
1957		NumberExpr::Metric { kind, binding } => {
1958			eval_metric(*kind, *binding, def_idx, self_idx, ctx)
1959		}
1960		NumberExpr::Entropy(collection) => eval_entropy(collection, def_idx, self_idx, ctx),
1961		NumberExpr::Size(collection) => {
1962			if collection_has_pair_binding(collection) {
1963				return None;
1964			}
1965			Some(eval_collection_size(collection, def_idx, self_idx, ctx) as f64)
1966		}
1967	}
1968}
1969
1970fn eval_number_expr_ref(
1971	expr: &NumberExpr,
1972	r: &code_moniker_core::core::code_graph::RefRecord,
1973	ctx: &EvalCtx<'_, '_>,
1974) -> Option<f64> {
1975	match expr {
1976		NumberExpr::Literal(n) => Some(*n),
1977		NumberExpr::Projection(lhs) => match resolve_ref_lhs(*lhs, r, ctx)? {
1978			Value::Number(n) => Some(n),
1979			_ => None,
1980		},
1981		NumberExpr::Count { domain, filter } => {
1982			Some(eval_count_ref(domain, filter.as_deref(), r, ctx) as f64)
1983		}
1984		NumberExpr::Aggregate { .. }
1985		| NumberExpr::Metric { .. }
1986		| NumberExpr::Entropy(_)
1987		| NumberExpr::Size(_) => None,
1988	}
1989}
1990
1991fn eval_count_ref(
1992	domain: &Domain,
1993	filter: Option<&Node>,
1994	r: &code_moniker_core::core::code_graph::RefRecord,
1995	ctx: &EvalCtx<'_, '_>,
1996) -> u32 {
1997	ref_domain_items(domain, r, ctx)
1998		.into_iter()
1999		.filter(|item| {
2000			let Some(filter) = filter else {
2001				return true;
2002			};
2003			match item {
2004				DomainItem::Ref { record } => {
2005					matches!(
2006						eval_ref_node_with_current(filter, record, r, ctx),
2007						NodeOutcome::Pass
2008					)
2009				}
2010				DomainItem::Def {
2011					idx: Some(idx),
2012					def,
2013				} => matches!(
2014					eval_node_with_self(filter, def, *idx, r.source, ctx),
2015					NodeOutcome::Pass
2016				),
2017				DomainItem::Def { idx: None, def } => {
2018					matches!(eval_external_def_node(filter, def, ctx), NodeOutcome::Pass)
2019				}
2020				DomainItem::Segment { kind, name } => {
2021					matches!(eval_node_segment(filter, kind, name), NodeOutcome::Pass)
2022				}
2023			}
2024		})
2025		.count() as u32
2026}
2027
2028fn eval_number_expr_segment(expr: &NumberExpr) -> Option<f64> {
2029	match expr {
2030		NumberExpr::Literal(n) => Some(*n),
2031		NumberExpr::Projection(_)
2032		| NumberExpr::Count { .. }
2033		| NumberExpr::Aggregate { .. }
2034		| NumberExpr::Metric { .. }
2035		| NumberExpr::Entropy(_)
2036		| NumberExpr::Size(_) => None,
2037	}
2038}
2039
2040fn count_children_filtered(
2041	_d: &DefRecord,
2042	def_idx: usize,
2043	self_idx: usize,
2044	kind: &str,
2045	filter: &Node,
2046	ctx: &EvalCtx<'_, '_>,
2047) -> u32 {
2048	let Some(child_idxs) = ctx.children_by_parent.get(&def_idx) else {
2049		return 0;
2050	};
2051	let mut n = 0;
2052	for &ci in child_idxs {
2053		let cd = ctx.graph.def_at(ci);
2054		if cd.kind.as_ref() != kind.as_bytes() {
2055			continue;
2056		}
2057		if let NodeOutcome::Pass = eval_node_with_self(filter, cd, ci, self_idx, ctx) {
2058			n += 1;
2059		}
2060	}
2061	n
2062}
2063
2064fn count_children_by_shape(
2065	def_idx: usize,
2066	self_idx: usize,
2067	shape: &str,
2068	filter: Option<&Node>,
2069	ctx: &EvalCtx<'_, '_>,
2070) -> u32 {
2071	let Some(child_idxs) = ctx.children_by_parent.get(&def_idx) else {
2072		return 0;
2073	};
2074	let mut n = 0;
2075	for &ci in child_idxs {
2076		let cd = ctx.graph.def_at(ci);
2077		if !def_has_shape(cd, shape) {
2078			continue;
2079		}
2080		match filter {
2081			None => n += 1,
2082			Some(node) => {
2083				if let NodeOutcome::Pass = eval_node_with_self(node, cd, ci, self_idx, ctx) {
2084					n += 1;
2085				}
2086			}
2087		}
2088	}
2089	n
2090}
2091
2092fn def_has_shape(d: &DefRecord, shape: &str) -> bool {
2093	d.shape().is_some_and(|actual| actual.as_str() == shape)
2094}
2095
2096fn count_segments(d: &DefRecord, filter: Option<&Node>) -> u32 {
2097	let mut n = 0;
2098	for seg in d.moniker.as_view().segments() {
2099		match filter {
2100			None => n += 1,
2101			Some(node) => {
2102				if let NodeOutcome::Pass = eval_node_segment(node, seg.kind, seg.name) {
2103					n += 1;
2104				}
2105			}
2106		}
2107	}
2108	n
2109}
2110
2111fn count_out_refs(
2112	_d: &DefRecord,
2113	def_idx: usize,
2114	filter: Option<&Node>,
2115	ctx: &EvalCtx<'_, '_>,
2116) -> u32 {
2117	let Some(ref_idxs) = ctx.out_refs_by_source.get(&def_idx) else {
2118		return 0;
2119	};
2120	let mut n = 0;
2121	for &ri in ref_idxs {
2122		let r = ctx.graph.ref_at(ri);
2123		match filter {
2124			None => n += 1,
2125			Some(node) => {
2126				if let NodeOutcome::Pass = eval_ref_node(node, r, ctx) {
2127					n += 1;
2128				}
2129			}
2130		}
2131	}
2132	n
2133}
2134
2135fn count_in_refs(d: &DefRecord, filter: Option<&Node>, ctx: &EvalCtx<'_, '_>) -> u32 {
2136	let key = d.moniker.as_encoded();
2137	let Some(ref_idxs) = ctx.in_refs_by_target.get(key) else {
2138		return 0;
2139	};
2140	let mut n = 0;
2141	for &ri in ref_idxs {
2142		let r = ctx.graph.ref_at(ri);
2143		match filter {
2144			None => n += 1,
2145			Some(node) => {
2146				if let NodeOutcome::Pass = eval_ref_node(node, r, ctx) {
2147					n += 1;
2148				}
2149			}
2150		}
2151	}
2152	n
2153}
2154
2155/// Quantifier evaluation in def scope.
2156fn eval_quantifier_def(
2157	kind: QuantKind,
2158	domain: &Domain,
2159	filter: &Node,
2160	scope: DefScope<'_>,
2161	self_idx: usize,
2162	ctx: &EvalCtx<'_, '_>,
2163) -> NodeOutcome {
2164	let mut total = 0u32;
2165	let mut passes = 0u32;
2166	match domain {
2167		Domain::Children(_) | Domain::ChildrenByShape(_) | Domain::Descendants(_) => {
2168			match eval_def_domain_quantifier(kind, domain, filter, scope.idx, self_idx, ctx) {
2169				Ok((domain_total, domain_passes)) => {
2170					total = domain_total;
2171					passes = domain_passes;
2172				}
2173				Err(outcome) => return *outcome,
2174			}
2175		}
2176		Domain::Pairs(inner) => {
2177			return eval_pair_quantifier(kind, inner, filter, scope.idx, self_idx, ctx);
2178		}
2179		Domain::Segments => {
2180			for seg in scope.record.moniker.as_view().segments() {
2181				total += 1;
2182				if matches!(
2183					eval_node_segment(filter, seg.kind, seg.name),
2184					NodeOutcome::Pass
2185				) {
2186					passes += 1;
2187				}
2188			}
2189		}
2190		Domain::OutRefs | Domain::SourceOutRefs | Domain::SourceAncestorOutRefs => {
2191			let empty = Vec::new();
2192			let ancestor_refs;
2193			let ref_idxs: &[usize] = if matches!(domain, Domain::SourceAncestorOutRefs) {
2194				ancestor_refs = ancestor_ref_indexes(scope.idx, ctx, true);
2195				&ancestor_refs
2196			} else {
2197				ctx.out_refs_by_source.get(&scope.idx).unwrap_or(&empty)
2198			};
2199			for &ri in ref_idxs {
2200				let r = ctx.graph.ref_at(ri);
2201				total += 1;
2202				if matches!(eval_ref_node(filter, r, ctx), NodeOutcome::Pass) {
2203					passes += 1;
2204				}
2205			}
2206		}
2207		Domain::InRefs | Domain::SourceInRefs | Domain::SourceAncestorInRefs => {
2208			let empty = Vec::new();
2209			let ancestor_refs;
2210			let ref_idxs: &[usize] = if matches!(domain, Domain::SourceAncestorInRefs) {
2211				ancestor_refs = ancestor_ref_indexes(scope.idx, ctx, false);
2212				&ancestor_refs
2213			} else {
2214				let key = scope.record.moniker.as_encoded();
2215				ctx.in_refs_by_target.get(key).unwrap_or(&empty)
2216			};
2217			for &ri in ref_idxs {
2218				let r = ctx.graph.ref_at(ri);
2219				total += 1;
2220				if matches!(eval_ref_node(filter, r, ctx), NodeOutcome::Pass) {
2221					passes += 1;
2222				}
2223			}
2224		}
2225		Domain::TargetOutRefs | Domain::TargetInRefs => {}
2226	}
2227	let label = match kind {
2228		QuantKind::Any => "any",
2229		QuantKind::All => "all",
2230		QuantKind::None => "none",
2231	};
2232	let ok = match kind {
2233		QuantKind::Any => passes > 0,
2234		QuantKind::All => total == 0 || passes == total,
2235		QuantKind::None => passes == 0,
2236	};
2237	if ok {
2238		NodeOutcome::Pass
2239	} else {
2240		NodeOutcome::Fail(Failure {
2241			atom_raw: format!("{label}(...)"),
2242			lhs_label: label.to_string(),
2243			actual: format!("{passes}/{total}"),
2244			expected: match kind {
2245				QuantKind::Any => "≥ 1 match".to_string(),
2246				QuantKind::All => "all match".to_string(),
2247				QuantKind::None => "zero matches".to_string(),
2248			},
2249			def_idx: None,
2250			details: None,
2251		})
2252	}
2253}
2254
2255fn eval_def_domain_quantifier(
2256	kind: QuantKind,
2257	domain: &Domain,
2258	filter: &Node,
2259	def_idx: usize,
2260	self_idx: usize,
2261	ctx: &EvalCtx<'_, '_>,
2262) -> Result<(u32, u32), Box<NodeOutcome>> {
2263	let mut total = 0u32;
2264	let mut passes = 0u32;
2265	for item in domain_items(domain, def_idx, ctx) {
2266		let DomainItem::Def { idx, def } = item else {
2267			continue;
2268		};
2269		total += 1;
2270		let outcome = match idx {
2271			Some(idx) => eval_node_with_self(filter, def, idx, self_idx, ctx),
2272			None => eval_external_def_node(filter, def, ctx),
2273		};
2274		match outcome {
2275			NodeOutcome::Pass => passes += 1,
2276			NodeOutcome::Fail(mut failure) if kind == QuantKind::All => {
2277				if let Some(idx) = idx {
2278					failure.def_idx.get_or_insert(idx);
2279				}
2280				return Err(Box::new(NodeOutcome::Fail(failure)));
2281			}
2282			NodeOutcome::Fail(_) | NodeOutcome::NotApplicable => {}
2283		}
2284	}
2285	Ok((total, passes))
2286}
2287
2288fn eval_external_def_node(node: &Node, def: &DefRecord, ctx: &EvalCtx<'_, '_>) -> NodeOutcome {
2289	walk_node(
2290		node,
2291		&|atom| eval_external_def_atom(atom, def, ctx),
2292		&|_, _, _| NodeOutcome::NotApplicable,
2293		&|_| NodeOutcome::NotApplicable,
2294		&|_| NodeOutcome::NotApplicable,
2295	)
2296}
2297
2298fn eval_external_def_atom(atom: &Atom, def: &DefRecord, ctx: &EvalCtx<'_, '_>) -> AtomOutcome {
2299	let LhsExpr::Attr(lhs) = &atom.lhs else {
2300		return AtomOutcome::NotApplicable;
2301	};
2302	let Some(value) = project_def_lhs_value(None, def, *lhs, ctx) else {
2303		return AtomOutcome::NotApplicable;
2304	};
2305	if let Rhs::Projection(rhs) = &atom.rhs
2306		&& let Some(rhs_value) = project_def_lhs_value(None, def, *rhs, ctx)
2307	{
2308		return apply_op_values(&value, atom.op, &rhs_value);
2309	}
2310	apply_op(&value, atom)
2311}
2312
2313fn eval_node_segment(node: &Node, seg_kind: &[u8], seg_name: &[u8]) -> NodeOutcome {
2314	walk_node(
2315		node,
2316		&|a| eval_atom_segment(a, seg_kind, seg_name),
2317		&|_, _, _| NodeOutcome::NotApplicable,
2318		&|_| NodeOutcome::NotApplicable,
2319		&|_| NodeOutcome::NotApplicable,
2320	)
2321}
2322
2323fn eval_atom_segment(atom: &Atom, seg_kind: &[u8], seg_name: &[u8]) -> AtomOutcome {
2324	let value: Value = match &atom.lhs {
2325		LhsExpr::Attr(Lhs::SegmentKind) => Value::Str(
2326			std::str::from_utf8(seg_kind)
2327				.unwrap_or_default()
2328				.to_string(),
2329		),
2330		LhsExpr::Attr(Lhs::SegmentName) => Value::Str(
2331			std::str::from_utf8(seg_name)
2332				.unwrap_or_default()
2333				.to_string(),
2334		),
2335		_ => return AtomOutcome::NotApplicable,
2336	};
2337	if let Rhs::Projection(other) = &atom.rhs {
2338		let rhs_val = match other {
2339			Lhs::SegmentKind => Value::Str(
2340				std::str::from_utf8(seg_kind)
2341					.unwrap_or_default()
2342					.to_string(),
2343			),
2344			Lhs::SegmentName => Value::Str(
2345				std::str::from_utf8(seg_name)
2346					.unwrap_or_default()
2347					.to_string(),
2348			),
2349			_ => return AtomOutcome::NotApplicable,
2350		};
2351		return apply_op_values(&value, atom.op, &rhs_val);
2352	}
2353	if let Rhs::Number(expr) = &atom.rhs {
2354		let Some(rhs_val) = eval_number_expr_segment(expr).map(Value::Number) else {
2355			return AtomOutcome::NotApplicable;
2356		};
2357		return apply_op_values(&value, atom.op, &rhs_val);
2358	}
2359	apply_op(&value, atom)
2360}
2361
2362fn eval_atom(
2363	atom: &Atom,
2364	d: &DefRecord,
2365	def_idx: usize,
2366	self_idx: usize,
2367	ctx: &EvalCtx<'_, '_>,
2368) -> AtomOutcome {
2369	if let (LhsExpr::Collection(left), Op::Subset, Rhs::Collection(right)) =
2370		(&atom.lhs, atom.op, &atom.rhs)
2371	{
2372		if collection_has_pair_binding(left) || collection_has_pair_binding(right) {
2373			return AtomOutcome::NotApplicable;
2374		}
2375		return if eval_collection_subset(left, right, def_idx, self_idx, ctx) {
2376			AtomOutcome::Pass
2377		} else {
2378			AtomOutcome::Fail {
2379				actual: "not subset".to_string(),
2380				expected: "subset".to_string(),
2381			}
2382		};
2383	}
2384	let value: Value = match &atom.lhs {
2385		LhsExpr::Attr(lhs) => {
2386			let Some(value) = resolve_def_lhs(*lhs, d, ctx) else {
2387				return AtomOutcome::NotApplicable;
2388			};
2389			value
2390		}
2391		LhsExpr::Number(expr) => {
2392			let Some(n) = eval_number_expr_def(expr, d, def_idx, self_idx, ctx) else {
2393				return AtomOutcome::NotApplicable;
2394			};
2395			Value::Number(n)
2396		}
2397		LhsExpr::Mode(collection) => {
2398			let Some(value) = eval_mode(collection, def_idx, self_idx, ctx) else {
2399				return AtomOutcome::NotApplicable;
2400			};
2401			value
2402		}
2403		LhsExpr::PairProjection(_) => return AtomOutcome::NotApplicable,
2404		LhsExpr::SegmentOf { scope, kind } => match scope {
2405			SegmentScope::Def => Value::Str(first_segment_name(&d.moniker, kind.as_bytes())),
2406			SegmentScope::Source | SegmentScope::Target => {
2407				return AtomOutcome::NotApplicable;
2408			}
2409		},
2410		LhsExpr::Collection(_) => return AtomOutcome::NotApplicable,
2411	};
2412	if let Rhs::Projection(other) = &atom.rhs {
2413		let Some(rhs_val) = resolve_def_lhs(*other, d, ctx) else {
2414			return AtomOutcome::NotApplicable;
2415		};
2416		return apply_op_values(&value, atom.op, &rhs_val);
2417	}
2418	if let Rhs::CurrentProjection(other) = &atom.rhs {
2419		let Some(rhs_val) = resolve_def_lhs(*other, ctx.graph.def_at(self_idx), ctx) else {
2420			return AtomOutcome::NotApplicable;
2421		};
2422		return apply_op_values(&value, atom.op, &rhs_val);
2423	}
2424	if let Rhs::Number(expr) = &atom.rhs {
2425		let Some(rhs_val) =
2426			eval_number_expr_def(expr, d, def_idx, self_idx, ctx).map(Value::Number)
2427		else {
2428			return AtomOutcome::NotApplicable;
2429		};
2430		return apply_op_values(&value, atom.op, &rhs_val);
2431	}
2432	apply_op(&value, atom)
2433}
2434
2435fn children_by_parent(graph: &CodeGraph) -> HashMap<usize, Vec<usize>> {
2436	let mut m: HashMap<usize, Vec<usize>> = HashMap::new();
2437	for (idx, d) in graph.defs().enumerate() {
2438		if let Some(p) = d.parent {
2439			m.entry(p).or_default().push(idx);
2440		}
2441	}
2442	m
2443}
2444
2445fn out_refs_by_source(graph: &CodeGraph) -> HashMap<usize, Vec<usize>> {
2446	let mut m: HashMap<usize, Vec<usize>> = HashMap::new();
2447	for (idx, r) in graph.refs().enumerate() {
2448		m.entry(r.source).or_default().push(idx);
2449	}
2450	m
2451}
2452
2453fn in_refs_by_target(graph: &CodeGraph) -> HashMap<Vec<u8>, Vec<usize>> {
2454	let mut m: HashMap<Vec<u8>, Vec<usize>> = HashMap::new();
2455	for (idx, r) in graph.refs().enumerate() {
2456		m.entry(r.target.as_encoded().to_vec())
2457			.or_default()
2458			.push(idx);
2459	}
2460	m
2461}
2462
2463fn parent_counts_by_kind(graph: &CodeGraph) -> HashMap<(usize, &[u8]), u32> {
2464	let mut m: HashMap<(usize, &[u8]), u32> = HashMap::new();
2465	for d in graph.defs() {
2466		if let Some(p) = d.parent {
2467			*m.entry((p, d.kind.as_ref())).or_insert(0) += 1;
2468		}
2469	}
2470	m
2471}
2472
2473fn comment_end_bytes(graph: &CodeGraph) -> Vec<u32> {
2474	let mut v: Vec<u32> = graph
2475		.defs()
2476		.filter(|d| d.kind.as_ref() == KIND_COMMENT)
2477		.filter_map(|d| d.position.map(|(_, e)| e))
2478		.collect();
2479	v.sort_unstable();
2480	v
2481}
2482
2483/// One pass over `graph.refs()` to bucket the earliest `annotates`-ref start
2484/// per annotated def. Saves the O(D × R) per-def filter that the old
2485/// `doc_anchor_byte` performed.
2486fn doc_anchors_by_def(graph: &CodeGraph) -> HashMap<usize, u32> {
2487	let mut m: HashMap<usize, u32> = HashMap::new();
2488	for r in graph.refs() {
2489		if r.kind != b"annotates" {
2490			continue;
2491		}
2492		let Some((start, _)) = r.position else {
2493			continue;
2494		};
2495		m.entry(r.source)
2496			.and_modify(|cur| {
2497				if start < *cur {
2498					*cur = start;
2499				}
2500			})
2501			.or_insert(start);
2502	}
2503	m
2504}
2505
2506fn comment_attaches_to(source: &str, comment_end: u32, header_start: u32) -> bool {
2507	if comment_end > header_start {
2508		return false;
2509	}
2510	let last_comment_byte = comment_end.saturating_sub(1);
2511	let (cl, _) = line_range(source, last_comment_byte, last_comment_byte + 1);
2512	let (hl, _) = line_range(source, header_start, header_start + 1);
2513	hl == cl || hl == cl + 1
2514}
2515
2516fn check_require_doc_comment(
2517	target: RuleTarget<'_>,
2518	rules: &CompiledKindRules,
2519	ctx: &EvalCtx<'_, '_>,
2520	out: &mut Vec<Violation>,
2521) {
2522	check_require_doc_comment_with_id(
2523		target,
2524		rules,
2525		rule_id(ctx.lang, target.kind, "require_doc_comment"),
2526		ctx,
2527		out,
2528	);
2529}
2530
2531fn check_require_doc_comment_with_id(
2532	target: RuleTarget<'_>,
2533	rules: &CompiledKindRules,
2534	rule_id: String,
2535	ctx: &EvalCtx<'_, '_>,
2536	out: &mut Vec<Violation>,
2537) {
2538	if eval_require_doc_comment(target.scope.record, target.scope.idx, rules, ctx) != Some(false) {
2539		return;
2540	}
2541
2542	let moniker = to_uri(&target.scope.record.moniker, &ctx.uri_cfg);
2543	let name = def_name(target.scope.record).unwrap_or_default();
2544	let (start_line, end_line) = lines_of(target.scope.record, ctx.source);
2545	out.push(Violation {
2546		rule_id,
2547		severity: RuleSeverity::Error,
2548		moniker,
2549		srcset: non_empty(first_segment_name(&target.scope.record.moniker, b"srcset")),
2550		kind: target.kind.to_string(),
2551		lines: (start_line, end_line),
2552		message: format!(
2553			"{} `{name}` is missing a doc comment immediately before it",
2554			target.kind
2555		),
2556		explanation: None,
2557	});
2558}
2559
2560fn eval_require_doc_comment(
2561	d: &DefRecord,
2562	def_idx: usize,
2563	rules: &CompiledKindRules,
2564	ctx: &EvalCtx<'_, '_>,
2565) -> Option<bool> {
2566	let filter = rules.require_doc_for_vis.as_ref()?;
2567	let vis = std::str::from_utf8(&d.visibility).unwrap_or("");
2568	if filter != "any" && filter != vis {
2569		return None;
2570	}
2571	let (def_start, _) = d.position?;
2572	let header_start = ctx
2573		.doc_anchors
2574		.get(&def_idx)
2575		.copied()
2576		.map(|anc| anc.min(def_start))
2577		.unwrap_or(def_start);
2578
2579	let idx = ctx.comment_ends.partition_point(|&end| end <= header_start);
2580	let has_doc =
2581		idx > 0 && comment_attaches_to(ctx.source, ctx.comment_ends[idx - 1], header_start);
2582	Some(has_doc)
2583}
2584
2585#[cfg(test)]
2586mod tests;