Skip to main content

code_moniker_cli/check/
eval.rs

1use std::collections::HashMap;
2
3use crate::check::config::{Config, ConfigError, KindRules, config_section};
4use crate::check::expr::{
5	self, Atom, Domain, Lhs, LhsExpr, Node, Op, QuantKind, Rhs, SegmentScope,
6};
7use crate::lines::line_range;
8use crate::render_uri;
9use code_moniker_core::core::code_graph::{CodeGraph, DefRecord};
10use code_moniker_core::core::kinds::KIND_COMMENT;
11use code_moniker_core::core::moniker::query::bare_callable_name;
12use code_moniker_core::core::uri::UriConfig;
13use code_moniker_core::lang::Lang;
14
15#[derive(Debug, Clone, serde::Serialize)]
16pub struct Violation {
17	pub rule_id: String,
18	pub moniker: String,
19	pub kind: String,
20	#[serde(serialize_with = "serialize_lines")]
21	pub lines: (u32, u32),
22	pub message: String,
23	#[serde(skip_serializing_if = "Option::is_none")]
24	pub explanation: Option<String>,
25}
26
27fn serialize_lines<S: serde::Serializer>(v: &(u32, u32), s: S) -> Result<S::Ok, S::Error> {
28	use serde::ser::SerializeTuple;
29	let mut t = s.serialize_tuple(2)?;
30	t.serialize_element(&v.0)?;
31	t.serialize_element(&v.1)?;
32	t.end()
33}
34
35pub fn evaluate(
36	graph: &CodeGraph,
37	source: &str,
38	lang: Lang,
39	cfg: &Config,
40	scheme: &str,
41) -> Result<Vec<Violation>, ConfigError> {
42	let compiled = compile_rules(cfg, lang, scheme)?;
43	Ok(evaluate_compiled(graph, source, lang, scheme, &compiled))
44}
45
46/// Build the compiled rule set for a single `lang`. Parses every rule
47/// expression, resolves aliases. Call once per language and reuse across
48/// many files of that language — the eval pipeline is shaped so the heavy
49/// work happens here, not per-file.
50pub fn compile_rules(cfg: &Config, lang: Lang, scheme: &str) -> Result<CompiledRules, ConfigError> {
51	CompiledRules::for_lang(cfg, lang, scheme)
52}
53
54pub fn evaluate_compiled(
55	graph: &CodeGraph,
56	source: &str,
57	lang: Lang,
58	scheme: &str,
59	compiled: &CompiledRules,
60) -> Vec<Violation> {
61	let need_doc_anchors = compiled
62		.by_kind
63		.values()
64		.any(|r| r.require_doc_for_vis.is_some());
65	let ctx = EvalCtx {
66		graph,
67		source,
68		lang,
69		uri_cfg: UriConfig { scheme },
70		parent_counts: parent_counts_by_kind(graph),
71		children_by_parent: children_by_parent(graph),
72		out_refs_by_source: out_refs_by_source(graph),
73		in_refs_by_target: in_refs_by_target(graph),
74		comment_ends: if need_doc_anchors {
75			comment_end_bytes(graph)
76		} else {
77			Vec::new()
78		},
79		doc_anchors: if need_doc_anchors {
80			doc_anchors_by_def(graph)
81		} else {
82			HashMap::new()
83		},
84	};
85	let mut out = Vec::new();
86
87	for (idx, d) in graph.defs().enumerate() {
88		let Ok(kind_str) = std::str::from_utf8(&d.kind) else {
89			continue;
90		};
91		let Some(rules) = compiled.for_kind(kind_str) else {
92			continue;
93		};
94		for rule in &rules.rules {
95			eval_rule(rule, d, idx, kind_str, &ctx, &mut out);
96		}
97		check_require_doc_comment(d, kind_str, idx, rules, &ctx, &mut out);
98	}
99
100	for r in graph.refs() {
101		for rule in &compiled.refs {
102			eval_ref_rule(rule, r, graph, &ctx, &mut out);
103		}
104	}
105
106	out
107}
108
109#[derive(Debug, Clone, serde::Serialize)]
110pub struct RuleReport {
111	pub rule_id: String,
112	pub domain: String,
113	pub evaluated: usize,
114	pub matches: usize,
115	pub violations: usize,
116	#[serde(skip_serializing_if = "Option::is_none")]
117	pub antecedent_matches: Option<usize>,
118	#[serde(skip_serializing_if = "Option::is_none")]
119	pub warning: Option<String>,
120}
121
122pub fn rule_report_compiled(
123	graph: &CodeGraph,
124	source: &str,
125	lang: Lang,
126	scheme: &str,
127	compiled: &CompiledRules,
128) -> Vec<RuleReport> {
129	let need_doc_anchors = compiled
130		.by_kind
131		.values()
132		.any(|r| r.require_doc_for_vis.is_some());
133	let ctx = EvalCtx {
134		graph,
135		source,
136		lang,
137		uri_cfg: UriConfig { scheme },
138		parent_counts: parent_counts_by_kind(graph),
139		children_by_parent: children_by_parent(graph),
140		out_refs_by_source: out_refs_by_source(graph),
141		in_refs_by_target: in_refs_by_target(graph),
142		comment_ends: if need_doc_anchors {
143			comment_end_bytes(graph)
144		} else {
145			Vec::new()
146		},
147		doc_anchors: if need_doc_anchors {
148			doc_anchors_by_def(graph)
149		} else {
150			HashMap::new()
151		},
152	};
153	let mut out = Vec::new();
154	for (kind, rules) in &compiled.by_kind {
155		for rule in &rules.rules {
156			let mut report = RuleReport::new(rule_id(lang, kind, &rule.id), kind.clone(), rule);
157			for (idx, d) in graph.defs().enumerate() {
158				if d.kind.as_slice() != kind.as_bytes() {
159					continue;
160				}
161				report.evaluated += 1;
162				let premise =
163					implication_premise(rule).map(|premise| eval_node(premise, d, idx, &ctx));
164				report.record(eval_node(&rule.root, d, idx, &ctx), premise);
165			}
166			report.finalize_warning();
167			out.push(report);
168		}
169		if rules.require_doc_for_vis.is_some() {
170			let mut report = RuleReport::new_require_doc(
171				rule_id(lang, kind, "require_doc_comment"),
172				kind.clone(),
173			);
174			for (idx, d) in graph.defs().enumerate() {
175				if d.kind.as_slice() != kind.as_bytes() {
176					continue;
177				}
178				report.evaluated += 1;
179				report.record(
180					eval_require_doc_comment(d, idx, rules, &ctx).map_or(
181						NodeOutcome::NotApplicable,
182						|has_doc| {
183							if has_doc {
184								NodeOutcome::Pass
185							} else {
186								NodeOutcome::Fail(Failure {
187									atom_raw: "require_doc_comment".to_string(),
188									lhs_label: "doc_comment".to_string(),
189									actual: "missing".to_string(),
190									expected: "present".to_string(),
191								})
192							}
193						},
194					),
195					None,
196				);
197			}
198			out.push(report);
199		}
200	}
201	for rule in &compiled.refs {
202		let mut report = RuleReport::new(format!("refs.{}", rule.id), "refs".to_string(), rule);
203		for r in graph.refs() {
204			report.evaluated += 1;
205			let premise = implication_premise(rule).map(|premise| eval_ref_node(premise, r, &ctx));
206			report.record(eval_ref_node(&rule.root, r, &ctx), premise);
207		}
208		report.finalize_warning();
209		out.push(report);
210	}
211	out.sort_by(|a, b| a.rule_id.cmp(&b.rule_id));
212	out
213}
214
215impl RuleReport {
216	fn new(rule_id: String, domain: String, rule: &CompiledRule) -> Self {
217		Self {
218			rule_id,
219			domain,
220			evaluated: 0,
221			matches: 0,
222			violations: 0,
223			antecedent_matches: implication_premise(rule).map(|_| 0),
224			warning: None,
225		}
226	}
227
228	fn new_require_doc(rule_id: String, domain: String) -> Self {
229		Self {
230			rule_id,
231			domain,
232			evaluated: 0,
233			matches: 0,
234			violations: 0,
235			antecedent_matches: None,
236			warning: None,
237		}
238	}
239
240	fn record(&mut self, outcome: NodeOutcome, premise: Option<NodeOutcome>) {
241		if matches!(premise, Some(NodeOutcome::Pass)) {
242			self.antecedent_matches = Some(self.antecedent_matches.unwrap_or(0) + 1);
243		}
244		match outcome {
245			NodeOutcome::Pass => {
246				if premise.is_none() || matches!(premise, Some(NodeOutcome::Pass)) {
247					self.matches += 1;
248				}
249			}
250			NodeOutcome::Fail(_) => self.violations += 1,
251			NodeOutcome::NotApplicable => {}
252		}
253	}
254
255	fn finalize_warning(&mut self) {
256		if self.evaluated > 0 && self.antecedent_matches == Some(0) {
257			self.warning = Some("antecedent never matched".to_string());
258		}
259	}
260}
261
262fn implication_premise(rule: &CompiledRule) -> Option<&Node> {
263	match &rule.root {
264		Node::Implies(premise, _) => Some(premise),
265		_ => None,
266	}
267}
268
269struct EvalCtx<'g, 'src> {
270	graph: &'g CodeGraph,
271	source: &'src str,
272	lang: Lang,
273	uri_cfg: UriConfig<'src>,
274	parent_counts: HashMap<(usize, &'g [u8]), u32>,
275	children_by_parent: HashMap<usize, Vec<usize>>,
276	out_refs_by_source: HashMap<usize, Vec<usize>>,
277	in_refs_by_target: HashMap<Vec<u8>, Vec<usize>>,
278	comment_ends: Vec<u32>,
279	doc_anchors: HashMap<usize, u32>,
280}
281
282#[derive(Debug)]
283struct CompiledRule {
284	id: String,
285	raw_expr: String,
286	root: Node,
287	message: Option<String>,
288}
289
290#[derive(Default)]
291struct CompiledKindRules {
292	rules: Vec<CompiledRule>,
293	require_doc_for_vis: Option<String>,
294}
295
296pub struct CompiledRules {
297	by_kind: HashMap<String, CompiledKindRules>,
298	refs: Vec<CompiledRule>,
299}
300
301impl CompiledRules {
302	fn for_lang(cfg: &Config, lang: Lang, scheme: &str) -> Result<Self, ConfigError> {
303		let section = config_section(lang);
304		let allowed = crate::check::config::allowed_kinds_for(lang);
305		let aliases = crate::check::config::resolve_aliases(&cfg.aliases)?;
306		let mut by_kind: HashMap<String, CompiledKindRules> = HashMap::new();
307		let mut per_lang_refs: Vec<&crate::check::config::RuleEntry> = Vec::new();
308		for (kind, rules) in cfg.for_lang(lang).kinds.iter() {
309			if kind == "refs" {
310				per_lang_refs.extend(rules.rules.iter());
311				continue;
312			}
313			by_kind.insert(
314				kind.clone(),
315				compile(rules, section, kind, scheme, &allowed, &aliases)?,
316			);
317		}
318		for (kind, rules) in cfg.default.kinds.iter() {
319			if kind == "refs" {
320				continue;
321			}
322			if !by_kind.contains_key(kind.as_str()) {
323				by_kind.insert(
324					kind.clone(),
325					compile(rules, "default", kind, scheme, &allowed, &aliases)?,
326				);
327			}
328		}
329		let mut refs = Vec::with_capacity(cfg.refs.rules.len() + per_lang_refs.len());
330		for (idx, entry) in cfg.refs.rules.iter().enumerate() {
331			let id = entry.fallback_id(idx);
332			let at = format!("refs.{id}");
333			let expanded = crate::check::config::substitute_aliases(&entry.expr, &aliases, &at)?;
334			let parsed = expr::parse(&expanded, scheme, &allowed).map_err(|error| {
335				ConfigError::InvalidExpr {
336					at: at.clone(),
337					error,
338				}
339			})?;
340			refs.push(CompiledRule {
341				id,
342				raw_expr: entry.expr.clone(),
343				root: parsed.root,
344				message: entry.message.clone(),
345			});
346		}
347		for (idx, entry) in per_lang_refs.iter().enumerate() {
348			let id = entry.fallback_id(idx);
349			let at = format!("{section}.refs.{id}");
350			let expanded = crate::check::config::substitute_aliases(&entry.expr, &aliases, &at)?;
351			let parsed = expr::parse(&expanded, scheme, &allowed).map_err(|error| {
352				ConfigError::InvalidExpr {
353					at: at.clone(),
354					error,
355				}
356			})?;
357			refs.push(CompiledRule {
358				id,
359				raw_expr: entry.expr.clone(),
360				root: parsed.root,
361				message: entry.message.clone(),
362			});
363		}
364		Ok(Self { by_kind, refs })
365	}
366
367	fn for_kind(&self, kind: &str) -> Option<&CompiledKindRules> {
368		self.by_kind.get(kind)
369	}
370}
371
372fn compile(
373	rules: &KindRules,
374	section: &str,
375	kind: &str,
376	scheme: &str,
377	allowed_kinds: &[&str],
378	aliases: &HashMap<String, String>,
379) -> Result<CompiledKindRules, ConfigError> {
380	let mut compiled = Vec::with_capacity(rules.rules.len());
381	for (idx, entry) in rules.rules.iter().enumerate() {
382		let id = entry.fallback_id(idx);
383		let at = format!("{section}.{kind}.{id}");
384		let expanded = crate::check::config::substitute_aliases(&entry.expr, aliases, &at)?;
385		let parsed = expr::parse(&expanded, scheme, allowed_kinds).map_err(|error| {
386			ConfigError::InvalidExpr {
387				at: at.clone(),
388				error,
389			}
390		})?;
391		compiled.push(CompiledRule {
392			id,
393			raw_expr: entry.expr.clone(),
394			root: parsed.root,
395			message: entry.message.clone(),
396		});
397	}
398	Ok(CompiledKindRules {
399		rules: compiled,
400		require_doc_for_vis: rules.require_doc_comment.clone(),
401	})
402}
403
404fn rule_id(lang: Lang, kind: &str, rule: &str) -> String {
405	format!("{}.{}.{}", config_section(lang), kind, rule)
406}
407
408fn lines_of(d: &DefRecord, source: &str) -> (u32, u32) {
409	match d.position {
410		Some((s, e)) => line_range(source, s, e),
411		None => (0, 0),
412	}
413}
414
415fn def_name(d: &DefRecord) -> Option<String> {
416	let last = d.moniker.as_view().segments().last()?;
417	let bare = bare_callable_name(last.name);
418	std::str::from_utf8(bare).ok().map(|s| s.to_string())
419}
420
421fn render_template(tpl: &str, vars: &[(&str, &str)]) -> String {
422	let mut out = tpl.to_string();
423	for (k, v) in vars {
424		let placeholder = format!("{{{k}}}");
425		if out.contains(&placeholder) {
426			out = out.replace(&placeholder, v);
427		}
428	}
429	out
430}
431
432fn eval_rule(
433	rule: &CompiledRule,
434	d: &DefRecord,
435	def_idx: usize,
436	kind: &str,
437	ctx: &EvalCtx<'_, '_>,
438	out: &mut Vec<Violation>,
439) {
440	let Failure {
441		atom_raw,
442		lhs_label,
443		actual,
444		expected,
445	} = match eval_node(&rule.root, d, def_idx, ctx) {
446		NodeOutcome::Pass | NodeOutcome::NotApplicable => return,
447		NodeOutcome::Fail(f) => f,
448	};
449	let name = def_name(d).unwrap_or_default();
450	let moniker = render_uri(&d.moniker, &ctx.uri_cfg);
451	let (start_line, end_line) = lines_of(d, ctx.source);
452	let message = format!(
453		"{kind} `{name}` fails `{atom_raw}` ({lhs_label} = {actual}, expected {expected})",
454	);
455	let explanation = rule.message.as_ref().map(|tpl| {
456		render_template(
457			tpl,
458			&[
459				("name", &name),
460				("kind", kind),
461				("moniker", &moniker),
462				("expr", &rule.raw_expr),
463				("value", &actual),
464				("expected", &expected),
465				("pattern", &expected),
466				("lines", &actual),
467				("limit", &expected),
468				("count", &actual),
469			],
470		)
471	});
472	out.push(Violation {
473		rule_id: rule_id(ctx.lang, kind, &rule.id),
474		moniker,
475		kind: kind.to_string(),
476		lines: (start_line, end_line),
477		message,
478		explanation,
479	});
480}
481
482fn eval_ref_rule(
483	rule: &CompiledRule,
484	r: &code_moniker_core::core::code_graph::RefRecord,
485	graph: &CodeGraph,
486	ctx: &EvalCtx<'_, '_>,
487	out: &mut Vec<Violation>,
488) {
489	let Failure {
490		atom_raw,
491		lhs_label,
492		actual,
493		expected,
494	} = match eval_ref_node(&rule.root, r, ctx) {
495		NodeOutcome::Pass | NodeOutcome::NotApplicable => return,
496		NodeOutcome::Fail(f) => f,
497	};
498	let source_def = graph.def_at(r.source);
499	let source_uri = render_uri(&source_def.moniker, &ctx.uri_cfg);
500	let target_uri = render_uri(&r.target, &ctx.uri_cfg);
501	let ref_kind = std::str::from_utf8(&r.kind).unwrap_or_default();
502	let (start_line, end_line) = match r.position {
503		Some((s, e)) => crate::lines::line_range(ctx.source, s, e),
504		None => (0, 0),
505	};
506	let message = format!(
507		"ref {ref_kind} {source_uri} → {target_uri} fails `{atom_raw}` ({lhs_label} = {actual}, expected {expected})"
508	);
509	out.push(Violation {
510		rule_id: format!("refs.{}", rule.id),
511		moniker: target_uri,
512		kind: ref_kind.to_string(),
513		lines: (start_line, end_line),
514		message,
515		explanation: rule.message.clone(),
516	});
517}
518
519fn eval_ref_node(
520	node: &Node,
521	r: &code_moniker_core::core::code_graph::RefRecord,
522	ctx: &EvalCtx<'_, '_>,
523) -> NodeOutcome {
524	walk_node(node, &|a| eval_ref_atom(a, r, ctx), &|_, _, _| {
525		NodeOutcome::NotApplicable
526	})
527}
528
529fn eval_ref_atom(
530	atom: &Atom,
531	r: &code_moniker_core::core::code_graph::RefRecord,
532	ctx: &EvalCtx<'_, '_>,
533) -> AtomOutcome {
534	let graph = ctx.graph;
535	let source_def = graph.def_at(r.source);
536	let value: Value = match &atom.lhs {
537		LhsExpr::Attr(Lhs::Kind) => {
538			Value::Str(std::str::from_utf8(&r.kind).unwrap_or_default().to_string())
539		}
540		LhsExpr::Attr(Lhs::Confidence) => Value::Str(
541			std::str::from_utf8(&r.confidence)
542				.unwrap_or_default()
543				.to_string(),
544		),
545		LhsExpr::Attr(Lhs::Moniker) | LhsExpr::Attr(Lhs::SourceMoniker) => {
546			Value::Moniker(source_def.moniker.clone())
547		}
548		LhsExpr::Attr(Lhs::TargetMoniker) => Value::Moniker(r.target.clone()),
549		LhsExpr::Attr(Lhs::SourceName) => match name_of(&source_def.moniker) {
550			Some(n) => Value::Str(n),
551			None => return AtomOutcome::NotApplicable,
552		},
553		LhsExpr::Attr(Lhs::TargetName) => match name_of(&r.target) {
554			Some(n) => Value::Str(n),
555			None => return AtomOutcome::NotApplicable,
556		},
557		LhsExpr::Attr(Lhs::SourceKind) => match last_segment_kind(&source_def.moniker) {
558			Some(k) => Value::Str(k),
559			None => return AtomOutcome::NotApplicable,
560		},
561		LhsExpr::Attr(Lhs::TargetKind) => match last_segment_kind(&r.target) {
562			Some(k) => Value::Str(k),
563			None => return AtomOutcome::NotApplicable,
564		},
565		LhsExpr::Attr(Lhs::Shape) | LhsExpr::Attr(Lhs::SourceShape) => {
566			match shape_of_last_segment(&source_def.moniker) {
567				Some(s) => Value::Str(s.as_str().to_string()),
568				None => return AtomOutcome::NotApplicable,
569			}
570		}
571		LhsExpr::Attr(Lhs::TargetShape) => match shape_of_last_segment(&r.target) {
572			Some(s) => Value::Str(s.as_str().to_string()),
573			None => return AtomOutcome::NotApplicable,
574		},
575		LhsExpr::Attr(Lhs::ParentShape) => {
576			let segs: Vec<_> = source_def.moniker.as_view().segments().collect();
577			if segs.len() < 2 {
578				return AtomOutcome::NotApplicable;
579			}
580			let parent_kind = segs[segs.len() - 2].kind;
581			match code_moniker_core::core::shape::shape_of(parent_kind) {
582				Some(s) => Value::Str(s.as_str().to_string()),
583				None => return AtomOutcome::NotApplicable,
584			}
585		}
586		LhsExpr::Attr(Lhs::SourceVisibility) => Value::Str(
587			std::str::from_utf8(&source_def.visibility)
588				.unwrap_or_default()
589				.to_string(),
590		),
591		LhsExpr::Attr(Lhs::TargetVisibility) => match resolve_local_def(graph, &r.target) {
592			Some(def) => Value::Str(
593				std::str::from_utf8(&def.visibility)
594					.unwrap_or_default()
595					.to_string(),
596			),
597			None => return AtomOutcome::NotApplicable,
598		},
599		LhsExpr::SegmentOf { scope, kind } => match scope {
600			SegmentScope::Def => {
601				return AtomOutcome::NotApplicable;
602			}
603			SegmentScope::Source => {
604				Value::Str(first_segment_name(&source_def.moniker, kind.as_bytes()))
605			}
606			SegmentScope::Target => Value::Str(first_segment_name(&r.target, kind.as_bytes())),
607		},
608		_ => return AtomOutcome::NotApplicable,
609	};
610	if let Rhs::Projection(other) = &atom.rhs {
611		let Some(rhs_val) = resolve_ref_lhs(*other, r, ctx) else {
612			return AtomOutcome::NotApplicable;
613		};
614		return apply_op_values(&value, atom.op, &rhs_val);
615	}
616	apply_op(&value, atom)
617}
618
619fn resolve_ref_lhs(
620	lhs: Lhs,
621	r: &code_moniker_core::core::code_graph::RefRecord,
622	ctx: &EvalCtx<'_, '_>,
623) -> Option<Value> {
624	let graph = ctx.graph;
625	let source_def = graph.def_at(r.source);
626	Some(match lhs {
627		Lhs::Kind => Value::Str(std::str::from_utf8(&r.kind).ok()?.to_string()),
628		Lhs::Confidence => Value::Str(std::str::from_utf8(&r.confidence).ok()?.to_string()),
629		Lhs::Moniker | Lhs::SourceMoniker => Value::Moniker(source_def.moniker.clone()),
630		Lhs::TargetMoniker => Value::Moniker(r.target.clone()),
631		Lhs::SourceName => Value::Str(name_of(&source_def.moniker)?),
632		Lhs::TargetName => Value::Str(name_of(&r.target)?),
633		Lhs::SourceKind => Value::Str(last_segment_kind(&source_def.moniker)?),
634		Lhs::TargetKind => Value::Str(last_segment_kind(&r.target)?),
635		Lhs::SourceVisibility => Value::Str(
636			std::str::from_utf8(&source_def.visibility)
637				.ok()?
638				.to_string(),
639		),
640		Lhs::TargetVisibility => {
641			let def = resolve_local_def(graph, &r.target)?;
642			Value::Str(std::str::from_utf8(&def.visibility).ok()?.to_string())
643		}
644		_ => return None,
645	})
646}
647
648fn name_of(m: &code_moniker_core::core::moniker::Moniker) -> Option<String> {
649	let last = m.as_view().segments().last()?;
650	let bare = code_moniker_core::core::moniker::query::bare_callable_name(last.name);
651	std::str::from_utf8(bare).ok().map(|s| s.to_string())
652}
653
654fn first_segment_name(m: &code_moniker_core::core::moniker::Moniker, kind: &[u8]) -> String {
655	for seg in m.as_view().segments() {
656		if seg.kind == kind {
657			return std::str::from_utf8(seg.name)
658				.unwrap_or_default()
659				.to_string();
660		}
661	}
662	String::new()
663}
664
665fn last_segment_kind(m: &code_moniker_core::core::moniker::Moniker) -> Option<String> {
666	let last = m.as_view().segments().last()?;
667	std::str::from_utf8(last.kind).ok().map(|s| s.to_string())
668}
669
670fn shape_of_last_segment(
671	m: &code_moniker_core::core::moniker::Moniker,
672) -> Option<code_moniker_core::core::shape::Shape> {
673	let last = m.as_view().segments().last()?;
674	code_moniker_core::core::shape::shape_of(last.kind)
675}
676
677fn resolve_local_def<'g>(
678	graph: &'g CodeGraph,
679	m: &code_moniker_core::core::moniker::Moniker,
680) -> Option<&'g DefRecord> {
681	graph.defs().find(|d| d.moniker == *m)
682}
683
684fn describe_lhs(lhs: &LhsExpr) -> &str {
685	match lhs {
686		LhsExpr::Attr(a) => a.as_str(),
687		LhsExpr::Count { .. } => "count",
688		LhsExpr::SegmentOf { .. } => "segment",
689	}
690}
691
692#[derive(Debug)]
693struct Failure {
694	atom_raw: String,
695	lhs_label: String,
696	actual: String,
697	expected: String,
698}
699
700#[derive(Debug)]
701enum NodeOutcome {
702	Pass,
703	Fail(Failure),
704	NotApplicable,
705}
706
707enum AtomOutcome {
708	Pass,
709	Fail { actual: String, expected: String },
710	NotApplicable,
711}
712
713/// Trivalent-logic walker shared by def/ref/segment evaluators. `atom_eval`
714/// produces the atom leaf outcome; `quant_eval` handles `Node::Quantifier`
715/// (scopes that can't iterate, like ref and segment, return NotApplicable).
716fn walk_node<A, Q>(node: &Node, atom_eval: &A, quant_eval: &Q) -> NodeOutcome
717where
718	A: Fn(&Atom) -> AtomOutcome,
719	Q: Fn(QuantKind, &Domain, &Node) -> NodeOutcome,
720{
721	match node {
722		Node::Atom(atom) => match atom_eval(atom) {
723			AtomOutcome::Pass => NodeOutcome::Pass,
724			AtomOutcome::Fail { actual, expected } => NodeOutcome::Fail(Failure {
725				atom_raw: atom.raw.clone(),
726				lhs_label: describe_lhs(&atom.lhs).to_string(),
727				actual,
728				expected,
729			}),
730			AtomOutcome::NotApplicable => NodeOutcome::NotApplicable,
731		},
732		Node::And(children) => {
733			let mut na = false;
734			for c in children {
735				match walk_node(c, atom_eval, quant_eval) {
736					NodeOutcome::Pass => {}
737					NodeOutcome::Fail(f) => return NodeOutcome::Fail(f),
738					NodeOutcome::NotApplicable => na = true,
739				}
740			}
741			if na {
742				NodeOutcome::NotApplicable
743			} else {
744				NodeOutcome::Pass
745			}
746		}
747		Node::Or(children) => {
748			let mut last_fail: Option<Failure> = None;
749			let mut na = false;
750			for c in children {
751				match walk_node(c, atom_eval, quant_eval) {
752					NodeOutcome::Pass => return NodeOutcome::Pass,
753					NodeOutcome::Fail(f) => last_fail = Some(f),
754					NodeOutcome::NotApplicable => na = true,
755				}
756			}
757			if na {
758				NodeOutcome::NotApplicable
759			} else if let Some(f) = last_fail {
760				NodeOutcome::Fail(f)
761			} else {
762				NodeOutcome::NotApplicable
763			}
764		}
765		Node::Not(inner) => match walk_node(inner, atom_eval, quant_eval) {
766			NodeOutcome::Pass => NodeOutcome::Fail(Failure {
767				atom_raw: "NOT (...)".to_string(),
768				lhs_label: "NOT".to_string(),
769				actual: "true".to_string(),
770				expected: "false".to_string(),
771			}),
772			NodeOutcome::Fail(_) => NodeOutcome::Pass,
773			NodeOutcome::NotApplicable => NodeOutcome::NotApplicable,
774		},
775		Node::Implies(prem, cons) => match walk_node(prem, atom_eval, quant_eval) {
776			NodeOutcome::Pass => walk_node(cons, atom_eval, quant_eval),
777			NodeOutcome::Fail(_) => NodeOutcome::Pass,
778			NodeOutcome::NotApplicable => NodeOutcome::NotApplicable,
779		},
780		Node::Quantifier {
781			kind,
782			domain,
783			filter,
784		} => quant_eval(*kind, domain, filter),
785	}
786}
787
788fn eval_node(node: &Node, d: &DefRecord, def_idx: usize, ctx: &EvalCtx<'_, '_>) -> NodeOutcome {
789	walk_node(
790		node,
791		&|a| eval_atom(a, d, def_idx, ctx),
792		&|kind, domain, filter| eval_quantifier_def(kind, domain, filter, d, def_idx, ctx),
793	)
794}
795
796fn resolve_def_lhs(lhs: Lhs, d: &DefRecord, ctx: &EvalCtx<'_, '_>) -> Option<Value> {
797	let source = ctx.source;
798	let value = match lhs {
799		Lhs::Name => Value::Str(def_name(d)?),
800		Lhs::Kind => Value::Str(std::str::from_utf8(&d.kind).ok()?.to_string()),
801		Lhs::Visibility => Value::Str(std::str::from_utf8(&d.visibility).ok()?.to_string()),
802		Lhs::Lines => {
803			let (s, e) = d.position?;
804			let (sl, el) = line_range(source, s, e);
805			Value::Number(el - sl + 1)
806		}
807		Lhs::Text => {
808			let (s, e) = d.position?;
809			Value::Str(source.get(s as usize..e as usize).unwrap_or("").to_string())
810		}
811		Lhs::Moniker => Value::Moniker(d.moniker.clone()),
812		Lhs::Depth => Value::Number(d.moniker.as_view().segments().count() as u32),
813		Lhs::ParentName => {
814			let segs: Vec<_> = d.moniker.as_view().segments().collect();
815			if segs.len() < 2 {
816				return None;
817			}
818			let p = &segs[segs.len() - 2];
819			let bare = bare_callable_name(p.name);
820			Value::Str(std::str::from_utf8(bare).ok()?.to_string())
821		}
822		Lhs::ParentKind => {
823			let segs: Vec<_> = d.moniker.as_view().segments().collect();
824			if segs.len() < 2 {
825				return None;
826			}
827			let p = &segs[segs.len() - 2];
828			Value::Str(std::str::from_utf8(p.kind).ok()?.to_string())
829		}
830		Lhs::Shape => Value::Str(d.shape()?.as_str().to_string()),
831		Lhs::ParentShape => {
832			let segs: Vec<_> = d.moniker.as_view().segments().collect();
833			if segs.len() < 2 {
834				return None;
835			}
836			let parent_kind = segs[segs.len() - 2].kind;
837			Value::Str(
838				code_moniker_core::core::shape::shape_of(parent_kind)?
839					.as_str()
840					.to_string(),
841			)
842		}
843		Lhs::Confidence
844		| Lhs::SourceName
845		| Lhs::SourceKind
846		| Lhs::SourceShape
847		| Lhs::SourceVisibility
848		| Lhs::SourceMoniker
849		| Lhs::TargetName
850		| Lhs::TargetKind
851		| Lhs::TargetShape
852		| Lhs::TargetVisibility
853		| Lhs::TargetMoniker
854		| Lhs::SegmentName
855		| Lhs::SegmentKind => return None,
856	};
857	Some(value)
858}
859
860/// `count(<domain>, <filter>?)` evaluated in def scope. Counts items in
861/// the domain for which `filter` evaluates to Pass.
862fn eval_count(
863	domain: &Domain,
864	filter: Option<&Node>,
865	d: &DefRecord,
866	def_idx: usize,
867	ctx: &EvalCtx<'_, '_>,
868) -> u32 {
869	match domain {
870		Domain::Children(kind) => match filter {
871			None => ctx
872				.parent_counts
873				.get(&(def_idx, kind.as_bytes()))
874				.copied()
875				.unwrap_or(0),
876			Some(node) => count_children_filtered(d, def_idx, kind, node, ctx),
877		},
878		Domain::Segments => count_segments(d, filter),
879		Domain::OutRefs => count_out_refs(d, def_idx, filter, ctx),
880		Domain::InRefs => count_in_refs(d, filter, ctx),
881	}
882}
883
884fn count_children_filtered(
885	_d: &DefRecord,
886	def_idx: usize,
887	kind: &str,
888	filter: &Node,
889	ctx: &EvalCtx<'_, '_>,
890) -> u32 {
891	let Some(child_idxs) = ctx.children_by_parent.get(&def_idx) else {
892		return 0;
893	};
894	let mut n = 0;
895	for &ci in child_idxs {
896		let cd = ctx.graph.def_at(ci);
897		if cd.kind.as_slice() != kind.as_bytes() {
898			continue;
899		}
900		if let NodeOutcome::Pass = eval_node(filter, cd, ci, ctx) {
901			n += 1;
902		}
903	}
904	n
905}
906
907fn count_segments(d: &DefRecord, filter: Option<&Node>) -> u32 {
908	let mut n = 0;
909	for seg in d.moniker.as_view().segments() {
910		match filter {
911			None => n += 1,
912			Some(node) => {
913				if let NodeOutcome::Pass = eval_node_segment(node, seg.kind, seg.name) {
914					n += 1;
915				}
916			}
917		}
918	}
919	n
920}
921
922fn count_out_refs(
923	_d: &DefRecord,
924	def_idx: usize,
925	filter: Option<&Node>,
926	ctx: &EvalCtx<'_, '_>,
927) -> u32 {
928	let Some(ref_idxs) = ctx.out_refs_by_source.get(&def_idx) else {
929		return 0;
930	};
931	let mut n = 0;
932	for &ri in ref_idxs {
933		let r = ctx.graph.ref_at(ri);
934		match filter {
935			None => n += 1,
936			Some(node) => {
937				if let NodeOutcome::Pass = eval_ref_node(node, r, ctx) {
938					n += 1;
939				}
940			}
941		}
942	}
943	n
944}
945
946fn count_in_refs(d: &DefRecord, filter: Option<&Node>, ctx: &EvalCtx<'_, '_>) -> u32 {
947	let key = d.moniker.as_bytes();
948	let Some(ref_idxs) = ctx.in_refs_by_target.get(key) else {
949		return 0;
950	};
951	let mut n = 0;
952	for &ri in ref_idxs {
953		let r = ctx.graph.ref_at(ri);
954		match filter {
955			None => n += 1,
956			Some(node) => {
957				if let NodeOutcome::Pass = eval_ref_node(node, r, ctx) {
958					n += 1;
959				}
960			}
961		}
962	}
963	n
964}
965
966/// Quantifier evaluation in def scope.
967fn eval_quantifier_def(
968	kind: QuantKind,
969	domain: &Domain,
970	filter: &Node,
971	d: &DefRecord,
972	def_idx: usize,
973	ctx: &EvalCtx<'_, '_>,
974) -> NodeOutcome {
975	let mut total = 0u32;
976	let mut passes = 0u32;
977	match domain {
978		Domain::Children(child_kind) => {
979			let empty = Vec::new();
980			let child_idxs = ctx.children_by_parent.get(&def_idx).unwrap_or(&empty);
981			for &ci in child_idxs {
982				let cd = ctx.graph.def_at(ci);
983				if cd.kind.as_slice() != child_kind.as_bytes() {
984					continue;
985				}
986				total += 1;
987				if matches!(eval_node(filter, cd, ci, ctx), NodeOutcome::Pass) {
988					passes += 1;
989				}
990			}
991		}
992		Domain::Segments => {
993			for seg in d.moniker.as_view().segments() {
994				total += 1;
995				if matches!(
996					eval_node_segment(filter, seg.kind, seg.name),
997					NodeOutcome::Pass
998				) {
999					passes += 1;
1000				}
1001			}
1002		}
1003		Domain::OutRefs => {
1004			let empty = Vec::new();
1005			let ref_idxs = ctx.out_refs_by_source.get(&def_idx).unwrap_or(&empty);
1006			for &ri in ref_idxs {
1007				let r = ctx.graph.ref_at(ri);
1008				total += 1;
1009				if matches!(eval_ref_node(filter, r, ctx), NodeOutcome::Pass) {
1010					passes += 1;
1011				}
1012			}
1013		}
1014		Domain::InRefs => {
1015			let key = d.moniker.as_bytes();
1016			let empty = Vec::new();
1017			let ref_idxs = ctx.in_refs_by_target.get(key).unwrap_or(&empty);
1018			for &ri in ref_idxs {
1019				let r = ctx.graph.ref_at(ri);
1020				total += 1;
1021				if matches!(eval_ref_node(filter, r, ctx), NodeOutcome::Pass) {
1022					passes += 1;
1023				}
1024			}
1025		}
1026	}
1027	let label = match kind {
1028		QuantKind::Any => "any",
1029		QuantKind::All => "all",
1030		QuantKind::None => "none",
1031	};
1032	let ok = match kind {
1033		QuantKind::Any => passes > 0,
1034		QuantKind::All => total == 0 || passes == total,
1035		QuantKind::None => passes == 0,
1036	};
1037	if ok {
1038		NodeOutcome::Pass
1039	} else {
1040		NodeOutcome::Fail(Failure {
1041			atom_raw: format!("{label}(...)"),
1042			lhs_label: label.to_string(),
1043			actual: format!("{passes}/{total}"),
1044			expected: match kind {
1045				QuantKind::Any => "≥ 1 match".to_string(),
1046				QuantKind::All => "all match".to_string(),
1047				QuantKind::None => "zero matches".to_string(),
1048			},
1049		})
1050	}
1051}
1052
1053fn eval_node_segment(node: &Node, seg_kind: &[u8], seg_name: &[u8]) -> NodeOutcome {
1054	walk_node(
1055		node,
1056		&|a| eval_atom_segment(a, seg_kind, seg_name),
1057		&|_, _, _| NodeOutcome::NotApplicable,
1058	)
1059}
1060
1061fn eval_atom_segment(atom: &Atom, seg_kind: &[u8], seg_name: &[u8]) -> AtomOutcome {
1062	let value: Value = match &atom.lhs {
1063		LhsExpr::Attr(Lhs::SegmentKind) => Value::Str(
1064			std::str::from_utf8(seg_kind)
1065				.unwrap_or_default()
1066				.to_string(),
1067		),
1068		LhsExpr::Attr(Lhs::SegmentName) => Value::Str(
1069			std::str::from_utf8(seg_name)
1070				.unwrap_or_default()
1071				.to_string(),
1072		),
1073		_ => return AtomOutcome::NotApplicable,
1074	};
1075	if let Rhs::Projection(other) = &atom.rhs {
1076		let rhs_val = match other {
1077			Lhs::SegmentKind => Value::Str(
1078				std::str::from_utf8(seg_kind)
1079					.unwrap_or_default()
1080					.to_string(),
1081			),
1082			Lhs::SegmentName => Value::Str(
1083				std::str::from_utf8(seg_name)
1084					.unwrap_or_default()
1085					.to_string(),
1086			),
1087			_ => return AtomOutcome::NotApplicable,
1088		};
1089		return apply_op_values(&value, atom.op, &rhs_val);
1090	}
1091	apply_op(&value, atom)
1092}
1093
1094fn eval_atom(atom: &Atom, d: &DefRecord, def_idx: usize, ctx: &EvalCtx<'_, '_>) -> AtomOutcome {
1095	let source = ctx.source;
1096	let value: Value = match &atom.lhs {
1097		LhsExpr::Attr(Lhs::Name) => match def_name(d) {
1098			Some(n) => Value::Str(n),
1099			None => return AtomOutcome::NotApplicable,
1100		},
1101		LhsExpr::Attr(Lhs::Kind) => {
1102			Value::Str(std::str::from_utf8(&d.kind).unwrap_or_default().to_string())
1103		}
1104		LhsExpr::Attr(Lhs::Visibility) => Value::Str(
1105			std::str::from_utf8(&d.visibility)
1106				.unwrap_or_default()
1107				.to_string(),
1108		),
1109		LhsExpr::Attr(Lhs::Lines) => {
1110			let Some((s, e)) = d.position else {
1111				return AtomOutcome::NotApplicable;
1112			};
1113			let (sl, el) = line_range(source, s, e);
1114			Value::Number(el - sl + 1)
1115		}
1116		LhsExpr::Attr(Lhs::Text) => {
1117			let Some((s, e)) = d.position else {
1118				return AtomOutcome::NotApplicable;
1119			};
1120			Value::Str(source.get(s as usize..e as usize).unwrap_or("").to_string())
1121		}
1122		LhsExpr::Attr(Lhs::Moniker) => Value::Moniker(d.moniker.clone()),
1123		LhsExpr::Attr(Lhs::Depth) => Value::Number(d.moniker.as_view().segments().count() as u32),
1124		LhsExpr::Attr(Lhs::ParentName) => {
1125			let segs: Vec<_> = d.moniker.as_view().segments().collect();
1126			let Some(p) = segs.get(segs.len().saturating_sub(2)) else {
1127				return AtomOutcome::NotApplicable;
1128			};
1129			if segs.len() < 2 {
1130				return AtomOutcome::NotApplicable;
1131			}
1132			let bare = bare_callable_name(p.name);
1133			match std::str::from_utf8(bare) {
1134				Ok(s) => Value::Str(s.to_string()),
1135				Err(_) => return AtomOutcome::NotApplicable,
1136			}
1137		}
1138		LhsExpr::Attr(Lhs::ParentKind) => {
1139			let segs: Vec<_> = d.moniker.as_view().segments().collect();
1140			if segs.len() < 2 {
1141				return AtomOutcome::NotApplicable;
1142			}
1143			let p = &segs[segs.len() - 2];
1144			match std::str::from_utf8(p.kind) {
1145				Ok(s) => Value::Str(s.to_string()),
1146				Err(_) => return AtomOutcome::NotApplicable,
1147			}
1148		}
1149		LhsExpr::Attr(Lhs::Shape) => match d.shape() {
1150			Some(s) => Value::Str(s.as_str().to_string()),
1151			None => return AtomOutcome::NotApplicable,
1152		},
1153		LhsExpr::Attr(Lhs::ParentShape) => {
1154			let segs: Vec<_> = d.moniker.as_view().segments().collect();
1155			if segs.len() < 2 {
1156				return AtomOutcome::NotApplicable;
1157			}
1158			let parent_kind = segs[segs.len() - 2].kind;
1159			match code_moniker_core::core::shape::shape_of(parent_kind) {
1160				Some(s) => Value::Str(s.as_str().to_string()),
1161				None => return AtomOutcome::NotApplicable,
1162			}
1163		}
1164		LhsExpr::Attr(
1165			Lhs::Confidence
1166			| Lhs::SourceName
1167			| Lhs::SourceKind
1168			| Lhs::SourceShape
1169			| Lhs::SourceVisibility
1170			| Lhs::SourceMoniker
1171			| Lhs::TargetName
1172			| Lhs::TargetKind
1173			| Lhs::TargetShape
1174			| Lhs::TargetVisibility
1175			| Lhs::TargetMoniker
1176			| Lhs::SegmentName
1177			| Lhs::SegmentKind,
1178		) => return AtomOutcome::NotApplicable,
1179		LhsExpr::Count { domain, filter } => {
1180			let c = eval_count(domain, filter.as_deref(), d, def_idx, ctx);
1181			Value::Number(c)
1182		}
1183		LhsExpr::SegmentOf { scope, kind } => match scope {
1184			SegmentScope::Def => Value::Str(first_segment_name(&d.moniker, kind.as_bytes())),
1185			SegmentScope::Source | SegmentScope::Target => {
1186				return AtomOutcome::NotApplicable;
1187			}
1188		},
1189	};
1190	if let Rhs::Projection(other) = &atom.rhs {
1191		let Some(rhs_val) = resolve_def_lhs(*other, d, ctx) else {
1192			return AtomOutcome::NotApplicable;
1193		};
1194		return apply_op_values(&value, atom.op, &rhs_val);
1195	}
1196	apply_op(&value, atom)
1197}
1198
1199/// Value-vs-Value comparison for the cases where the RHS is itself a
1200/// projection. Restricted to the ops that pair naturally (equality and
1201/// numeric ordering); a structural moniker op against a string projection
1202/// stays `NotApplicable`.
1203fn apply_op_values(lhs: &Value, op: Op, rhs: &Value) -> AtomOutcome {
1204	use Op::*;
1205	let ok = match (lhs, op, rhs) {
1206		(Value::Str(a), Eq, Value::Str(b)) => a == b,
1207		(Value::Str(a), Ne, Value::Str(b)) => a != b,
1208		(Value::Number(a), Eq, Value::Number(b)) => a == b,
1209		(Value::Number(a), Ne, Value::Number(b)) => a != b,
1210		(Value::Number(a), Lt, Value::Number(b)) => a < b,
1211		(Value::Number(a), Le, Value::Number(b)) => a <= b,
1212		(Value::Number(a), Gt, Value::Number(b)) => a > b,
1213		(Value::Number(a), Ge, Value::Number(b)) => a >= b,
1214		(Value::Moniker(a), Eq, Value::Moniker(b)) => a == b,
1215		(Value::Moniker(a), Ne, Value::Moniker(b)) => a != b,
1216		(Value::Moniker(a), AncestorOf, Value::Moniker(b)) => a.is_ancestor_of(b),
1217		(Value::Moniker(a), DescendantOf, Value::Moniker(b)) => b.is_ancestor_of(a),
1218		(Value::Moniker(a), BindMatch, Value::Moniker(b)) => a.bind_match(b),
1219		_ => return AtomOutcome::NotApplicable,
1220	};
1221	if ok {
1222		AtomOutcome::Pass
1223	} else {
1224		AtomOutcome::Fail {
1225			actual: render_value(lhs),
1226			expected: render_value(rhs),
1227		}
1228	}
1229}
1230
1231enum Value {
1232	Str(String),
1233	Number(u32),
1234	Moniker(code_moniker_core::core::moniker::Moniker),
1235}
1236
1237fn apply_op(value: &Value, atom: &Atom) -> AtomOutcome {
1238	use Op::*;
1239	let ok = match (value, atom.op, &atom.rhs) {
1240		(Value::Str(s), RegexMatch, Rhs::RegexStr(_)) => {
1241			atom.regex.as_ref().is_some_and(|re| re.is_match(s))
1242		}
1243		(Value::Str(s), RegexNoMatch, Rhs::RegexStr(_)) => {
1244			atom.regex.as_ref().is_some_and(|re| !re.is_match(s))
1245		}
1246		(Value::Str(s), Eq, Rhs::Str(t)) => s == t,
1247		(Value::Str(s), Ne, Rhs::Str(t)) => s != t,
1248		(Value::Number(a), Eq, Rhs::Number(b)) => a == b,
1249		(Value::Number(a), Ne, Rhs::Number(b)) => a != b,
1250		(Value::Number(a), Lt, Rhs::Number(b)) => a < b,
1251		(Value::Number(a), Le, Rhs::Number(b)) => a <= b,
1252		(Value::Number(a), Gt, Rhs::Number(b)) => a > b,
1253		(Value::Number(a), Ge, Rhs::Number(b)) => a >= b,
1254		(Value::Moniker(m), Eq, Rhs::Moniker(t)) => m == t,
1255		(Value::Moniker(m), Ne, Rhs::Moniker(t)) => m != t,
1256		(Value::Moniker(m), AncestorOf, Rhs::Moniker(t)) => m.is_ancestor_of(t),
1257		(Value::Moniker(m), DescendantOf, Rhs::Moniker(t)) => t.is_ancestor_of(m),
1258		(Value::Moniker(m), BindMatch, Rhs::Moniker(t)) => m.bind_match(t),
1259		(Value::Moniker(m), PathMatch, Rhs::PathPattern(p)) => crate::check::path::matches(p, m),
1260		_ => return AtomOutcome::NotApplicable,
1261	};
1262	if ok {
1263		AtomOutcome::Pass
1264	} else {
1265		let actual = render_value(value);
1266		let expected = render_rhs(&atom.rhs);
1267		AtomOutcome::Fail { actual, expected }
1268	}
1269}
1270
1271fn render_value(v: &Value) -> String {
1272	match v {
1273		Value::Str(s) => s.clone(),
1274		Value::Number(n) => n.to_string(),
1275		Value::Moniker(m) => format!("{}b moniker", m.as_bytes().len()),
1276	}
1277}
1278
1279fn render_rhs(r: &Rhs) -> String {
1280	match r {
1281		Rhs::Str(s) => s.clone(),
1282		Rhs::Number(n) => n.to_string(),
1283		Rhs::RegexStr(s) => s.clone(),
1284		Rhs::Moniker(m) => format!("{}b moniker", m.as_bytes().len()),
1285		Rhs::PathPattern(p) => format!("path `{}`", p.raw),
1286		Rhs::Projection(l) => l.as_str().to_string(),
1287	}
1288}
1289
1290fn children_by_parent(graph: &CodeGraph) -> HashMap<usize, Vec<usize>> {
1291	let mut m: HashMap<usize, Vec<usize>> = HashMap::new();
1292	for (idx, d) in graph.defs().enumerate() {
1293		if let Some(p) = d.parent {
1294			m.entry(p).or_default().push(idx);
1295		}
1296	}
1297	m
1298}
1299
1300fn out_refs_by_source(graph: &CodeGraph) -> HashMap<usize, Vec<usize>> {
1301	let mut m: HashMap<usize, Vec<usize>> = HashMap::new();
1302	for (idx, r) in graph.refs().enumerate() {
1303		m.entry(r.source).or_default().push(idx);
1304	}
1305	m
1306}
1307
1308fn in_refs_by_target(graph: &CodeGraph) -> HashMap<Vec<u8>, Vec<usize>> {
1309	let mut m: HashMap<Vec<u8>, Vec<usize>> = HashMap::new();
1310	for (idx, r) in graph.refs().enumerate() {
1311		m.entry(r.target.as_bytes().to_vec()).or_default().push(idx);
1312	}
1313	m
1314}
1315
1316fn parent_counts_by_kind(graph: &CodeGraph) -> HashMap<(usize, &[u8]), u32> {
1317	let mut m: HashMap<(usize, &[u8]), u32> = HashMap::new();
1318	for d in graph.defs() {
1319		if let Some(p) = d.parent {
1320			*m.entry((p, d.kind.as_slice())).or_insert(0) += 1;
1321		}
1322	}
1323	m
1324}
1325
1326fn comment_end_bytes(graph: &CodeGraph) -> Vec<u32> {
1327	let mut v: Vec<u32> = graph
1328		.defs()
1329		.filter(|d| d.kind.as_slice() == KIND_COMMENT)
1330		.filter_map(|d| d.position.map(|(_, e)| e))
1331		.collect();
1332	v.sort_unstable();
1333	v
1334}
1335
1336/// One pass over `graph.refs()` to bucket the earliest `annotates`-ref start
1337/// per annotated def. Saves the O(D × R) per-def filter that the old
1338/// `doc_anchor_byte` performed.
1339fn doc_anchors_by_def(graph: &CodeGraph) -> HashMap<usize, u32> {
1340	let mut m: HashMap<usize, u32> = HashMap::new();
1341	for r in graph.refs() {
1342		if r.kind != b"annotates" {
1343			continue;
1344		}
1345		let Some((start, _)) = r.position else {
1346			continue;
1347		};
1348		m.entry(r.source)
1349			.and_modify(|cur| {
1350				if start < *cur {
1351					*cur = start;
1352				}
1353			})
1354			.or_insert(start);
1355	}
1356	m
1357}
1358
1359fn comment_attaches_to(source: &str, comment_end: u32, header_start: u32) -> bool {
1360	if comment_end > header_start {
1361		return false;
1362	}
1363	let last_comment_byte = comment_end.saturating_sub(1);
1364	let (cl, _) = line_range(source, last_comment_byte, last_comment_byte + 1);
1365	let (hl, _) = line_range(source, header_start, header_start + 1);
1366	hl == cl || hl == cl + 1
1367}
1368
1369fn check_require_doc_comment(
1370	d: &DefRecord,
1371	kind: &str,
1372	def_idx: usize,
1373	rules: &CompiledKindRules,
1374	ctx: &EvalCtx<'_, '_>,
1375	out: &mut Vec<Violation>,
1376) {
1377	if eval_require_doc_comment(d, def_idx, rules, ctx) != Some(false) {
1378		return;
1379	}
1380
1381	let moniker = render_uri(&d.moniker, &ctx.uri_cfg);
1382	let name = def_name(d).unwrap_or_default();
1383	let (start_line, end_line) = lines_of(d, ctx.source);
1384	out.push(Violation {
1385		rule_id: rule_id(ctx.lang, kind, "require_doc_comment"),
1386		moniker,
1387		kind: kind.to_string(),
1388		lines: (start_line, end_line),
1389		message: format!("{kind} `{name}` is missing a doc comment immediately before it"),
1390		explanation: None,
1391	});
1392}
1393
1394fn eval_require_doc_comment(
1395	d: &DefRecord,
1396	def_idx: usize,
1397	rules: &CompiledKindRules,
1398	ctx: &EvalCtx<'_, '_>,
1399) -> Option<bool> {
1400	let filter = rules.require_doc_for_vis.as_ref()?;
1401	let vis = std::str::from_utf8(&d.visibility).unwrap_or("");
1402	if filter != "any" && filter != vis {
1403		return None;
1404	}
1405	let (def_start, _) = d.position?;
1406	let header_start = ctx
1407		.doc_anchors
1408		.get(&def_idx)
1409		.copied()
1410		.map(|anc| anc.min(def_start))
1411		.unwrap_or(def_start);
1412
1413	let idx = ctx.comment_ends.partition_point(|&end| end <= header_start);
1414	let has_doc =
1415		idx > 0 && comment_attaches_to(ctx.source, ctx.comment_ends[idx - 1], header_start);
1416	Some(has_doc)
1417}
1418
1419#[cfg(test)]
1420mod tests {
1421	use super::*;
1422	use code_moniker_core::core::code_graph::DefAttrs;
1423	use code_moniker_core::core::moniker::{Moniker, MonikerBuilder};
1424
1425	const SCHEME: &str = "code+moniker://";
1426
1427	fn cfg_from(s: &str) -> Config {
1428		toml::from_str(s).expect("test config must parse")
1429	}
1430
1431	fn build_module(name: &[u8]) -> Moniker {
1432		let mut b = MonikerBuilder::new();
1433		b.project(b".");
1434		b.segment(b"lang", b"ts");
1435		b.segment(b"module", name);
1436		b.build()
1437	}
1438
1439	fn child(parent: &Moniker, kind: &[u8], name: &[u8]) -> Moniker {
1440		let mut b = MonikerBuilder::from_view(parent.as_view());
1441		b.segment(kind, name);
1442		b.build()
1443	}
1444
1445	#[test]
1446	fn no_rules_means_no_violations() {
1447		let cfg: Config = Config::default();
1448		let module = build_module(b"a");
1449		let g = CodeGraph::new(module, b"module");
1450		let v = evaluate(&g, "", Lang::Ts, &cfg, SCHEME).unwrap();
1451		assert!(v.is_empty());
1452	}
1453
1454	#[test]
1455	fn name_regex_violation() {
1456		let cfg = cfg_from(
1457			r#"
1458			[[ts.class.where]]
1459			id   = "name-pascal"
1460			expr = "name =~ ^[A-Z][A-Za-z0-9]*$"
1461			"#,
1462		);
1463		let module = build_module(b"a");
1464		let mut g = CodeGraph::new(module.clone(), b"module");
1465		let bad = child(&module, b"class", b"lower_case_bad");
1466		g.add_def(bad, b"class", &module, Some((0, 10))).unwrap();
1467		let v = evaluate(&g, "anything\n", Lang::Ts, &cfg, SCHEME).unwrap();
1468		assert_eq!(v.len(), 1);
1469		assert_eq!(v[0].rule_id, "ts.class.name-pascal");
1470	}
1471
1472	#[test]
1473	fn auto_id_when_user_omits_one() {
1474		let cfg = cfg_from(
1475			r#"
1476			[[ts.class.where]]
1477			expr = "name =~ ^[A-Z]"
1478			"#,
1479		);
1480		let module = build_module(b"a");
1481		let mut g = CodeGraph::new(module.clone(), b"module");
1482		g.add_def(
1483			child(&module, b"class", b"lower"),
1484			b"class",
1485			&module,
1486			Some((0, 10)),
1487		)
1488		.unwrap();
1489		let v = evaluate(&g, "x", Lang::Ts, &cfg, SCHEME).unwrap();
1490		assert_eq!(v[0].rule_id, "ts.class.where_0");
1491	}
1492
1493	#[test]
1494	fn lines_le_violation_uses_actual_count() {
1495		let cfg = cfg_from(
1496			r#"
1497			[[ts.function.where]]
1498			id   = "max-lines"
1499			expr = "lines <= 2"
1500			"#,
1501		);
1502		let module = build_module(b"a");
1503		let mut g = CodeGraph::new(module.clone(), b"module");
1504		let f = child(&module, b"function", b"foo");
1505		g.add_def(f, b"function", &module, Some((0, 14))).unwrap();
1506		let v = evaluate(&g, "a\nb\nc\n", Lang::Ts, &cfg, SCHEME).unwrap();
1507		assert_eq!(v.len(), 1);
1508		assert!(v[0].message.contains("3"));
1509		assert!(v[0].message.contains("expected 2"));
1510	}
1511
1512	#[test]
1513	fn forbid_name_via_regex_no_match() {
1514		let cfg = cfg_from(
1515			r#"
1516			[[ts.function.where]]
1517			id   = "no-helper-names"
1518			expr = "name !~ ^(helper|utils|manager)$"
1519			"#,
1520		);
1521		let module = build_module(b"a");
1522		let mut g = CodeGraph::new(module.clone(), b"module");
1523		g.add_def(
1524			child(&module, b"function", b"helper"),
1525			b"function",
1526			&module,
1527			Some((0, 5)),
1528		)
1529		.unwrap();
1530		let v = evaluate(&g, "x", Lang::Ts, &cfg, SCHEME).unwrap();
1531		assert_eq!(v.len(), 1);
1532		assert_eq!(v[0].rule_id, "ts.function.no-helper-names");
1533	}
1534
1535	#[test]
1536	fn count_children_groups_by_parent() {
1537		let cfg = cfg_from(
1538			r#"
1539			[[ts.class.where]]
1540			id   = "max-methods"
1541			expr = "count(method) <= 2"
1542			"#,
1543		);
1544		let module = build_module(b"a");
1545		let mut g = CodeGraph::new(module.clone(), b"module");
1546		let foo = child(&module, b"class", b"Foo");
1547		g.add_def(foo.clone(), b"class", &module, Some((0, 100)))
1548			.unwrap();
1549		g.add_def(child(&foo, b"method", b"a"), b"method", &foo, Some((1, 5)))
1550			.unwrap();
1551		g.add_def(child(&foo, b"method", b"b"), b"method", &foo, Some((6, 10)))
1552			.unwrap();
1553		g.add_def(
1554			child(&foo, b"method", b"c"),
1555			b"method",
1556			&foo,
1557			Some((11, 15)),
1558		)
1559		.unwrap();
1560		let bar = child(&module, b"class", b"Bar");
1561		g.add_def(bar.clone(), b"class", &module, Some((20, 50)))
1562			.unwrap();
1563		g.add_def(
1564			child(&bar, b"method", b"x"),
1565			b"method",
1566			&bar,
1567			Some((21, 25)),
1568		)
1569		.unwrap();
1570		let v = evaluate(&g, "", Lang::Ts, &cfg, SCHEME).unwrap();
1571		assert_eq!(v.len(), 1, "Foo violates, Bar passes: {v:?}");
1572		assert!(v[0].moniker.contains("class:Foo"));
1573	}
1574
1575	#[test]
1576	fn text_regex_on_comment() {
1577		let cfg = cfg_from(
1578			r#"
1579			[[ts.comment.where]]
1580			id   = "no-prose"
1581			expr = '''text =~ ^\s*//\s*TODO'''
1582			"#,
1583		);
1584		let module = build_module(b"a");
1585		let mut g = CodeGraph::new(module.clone(), b"module");
1586		let cmt = child(&module, b"comment", b"0");
1587		let source = "// random prose\n";
1588		g.add_def(cmt, b"comment", &module, Some((0, source.len() as u32 - 1)))
1589			.unwrap();
1590		let v = evaluate(&g, source, Lang::Ts, &cfg, SCHEME).unwrap();
1591		assert_eq!(v.len(), 1);
1592	}
1593
1594	#[test]
1595	fn moniker_descendant_of() {
1596		let cfg = cfg_from(
1597			r#"
1598			[[ts.method.where]]
1599			id   = "stay-in-foo"
1600			expr = "moniker <@ code+moniker://./lang:ts/module:a/class:Foo"
1601			"#,
1602		);
1603		let module = build_module(b"a");
1604		let mut g = CodeGraph::new(module.clone(), b"module");
1605		let foo = child(&module, b"class", b"Foo");
1606		g.add_def(foo.clone(), b"class", &module, Some((0, 50)))
1607			.unwrap();
1608		g.add_def(child(&foo, b"method", b"a"), b"method", &foo, Some((1, 5)))
1609			.unwrap();
1610		let bar = child(&module, b"class", b"Bar");
1611		g.add_def(bar.clone(), b"class", &module, Some((10, 30)))
1612			.unwrap();
1613		g.add_def(
1614			child(&bar, b"method", b"b"),
1615			b"method",
1616			&bar,
1617			Some((11, 15)),
1618		)
1619		.unwrap();
1620		let v = evaluate(&g, "", Lang::Ts, &cfg, SCHEME).unwrap();
1621		assert_eq!(v.len(), 1, "Bar.b violates, Foo.a passes");
1622		assert!(v[0].moniker.contains("class:Bar/method:b"));
1623	}
1624
1625	#[test]
1626	fn invalid_expression_surfaces_at_evaluate() {
1627		let cfg = cfg_from(
1628			r#"
1629			[[ts.class.where]]
1630			expr = "name =~ [unclosed"
1631			"#,
1632		);
1633		let module = build_module(b"a");
1634		let g = CodeGraph::new(module, b"module");
1635		match evaluate(&g, "", Lang::Ts, &cfg, SCHEME) {
1636			Err(ConfigError::InvalidExpr { at, .. }) => {
1637				assert!(at.contains("ts.class"), "{at}");
1638			}
1639			other => panic!("expected InvalidExpr, got {other:?}"),
1640		}
1641	}
1642
1643	#[test]
1644	fn unknown_kind_section_still_rejected() {
1645		let r = toml::from_str::<Config>(
1646			r#"
1647			[[ts.classs.where]]
1648			expr = "name =~ ^X"
1649			"#,
1650		);
1651		// parses fine — kind validation happens in config::validate during load
1652		assert!(r.is_ok());
1653	}
1654
1655	#[test]
1656	fn require_doc_comment_skips_when_annotations_precede_def() {
1657		let cfg = cfg_from(
1658			r#"
1659			[ts.class]
1660			require_doc_comment = "public"
1661			"#,
1662		);
1663		let module = build_module(b"a");
1664		let mut g = CodeGraph::new(module.clone(), b"module");
1665
1666		// Doc comment at lines 1
1667		let mut b = MonikerBuilder::from_view(module.as_view());
1668		b.segment(b"comment", b"0");
1669		let cmt = b.build();
1670		g.add_def(cmt, b"comment", &module, Some((0, 10))).unwrap();
1671
1672		// Class def header starts at line 3 (after `@Decorator` on line 2)
1673		let source = "/** doc */\n@Decorator\nclass Foo {}\n";
1674		let mut b = MonikerBuilder::from_view(module.as_view());
1675		b.segment(b"class", b"Foo");
1676		let foo = b.build();
1677		let attrs = DefAttrs {
1678			visibility: b"public",
1679			..DefAttrs::default()
1680		};
1681		// def starts at `class Foo` byte 22, class def is index 2 in graph
1682		g.add_def_attrs(foo.clone(), b"class", &module, Some((22, 35)), &attrs)
1683			.unwrap();
1684		let class_idx = g.defs().position(|d| d.moniker == foo).unwrap();
1685
1686		// Emit @Decorator as an annotates ref starting at byte 11 (line 2)
1687		g.add_ref(
1688			&g.def_at(class_idx).moniker.clone(),
1689			module.clone(),
1690			b"annotates",
1691			Some((11, 21)),
1692		)
1693		.unwrap();
1694
1695		let v = evaluate(&g, source, Lang::Ts, &cfg, SCHEME).unwrap();
1696		assert!(
1697			v.is_empty(),
1698			"comment line 1 + annotation line 2 + class line 3: doc must attach via annotation anchor: {v:?}"
1699		);
1700	}
1701
1702	// ─── booleans + implication semantics ───────────────────────────────
1703
1704	#[test]
1705	fn or_passes_if_one_arm_passes() {
1706		let cfg = cfg_from(
1707			r#"
1708			[[ts.class.where]]
1709			id   = "any-of"
1710			expr = "name = 'Foo' OR name = 'Bar'"
1711			"#,
1712		);
1713		let module = build_module(b"a");
1714		let mut g = CodeGraph::new(module.clone(), b"module");
1715		g.add_def(
1716			child(&module, b"class", b"Foo"),
1717			b"class",
1718			&module,
1719			Some((0, 10)),
1720		)
1721		.unwrap();
1722		let v = evaluate(&g, "x", Lang::Ts, &cfg, SCHEME).unwrap();
1723		assert!(v.is_empty(), "Foo matches first arm: {v:?}");
1724	}
1725
1726	#[test]
1727	fn or_fails_when_all_arms_fail() {
1728		let cfg = cfg_from(
1729			r#"
1730			[[ts.class.where]]
1731			id   = "any-of"
1732			expr = "name = 'Foo' OR name = 'Bar'"
1733			"#,
1734		);
1735		let module = build_module(b"a");
1736		let mut g = CodeGraph::new(module.clone(), b"module");
1737		g.add_def(
1738			child(&module, b"class", b"Baz"),
1739			b"class",
1740			&module,
1741			Some((0, 10)),
1742		)
1743		.unwrap();
1744		let v = evaluate(&g, "x", Lang::Ts, &cfg, SCHEME).unwrap();
1745		assert_eq!(v.len(), 1, "Baz matches no arm: {v:?}");
1746	}
1747
1748	#[test]
1749	fn not_inverts_pass_and_fail() {
1750		let cfg = cfg_from(
1751			r#"
1752			[[ts.class.where]]
1753			id   = "not-internal"
1754			expr = "NOT name = 'Internal'"
1755			"#,
1756		);
1757		let module = build_module(b"a");
1758		let mut g = CodeGraph::new(module.clone(), b"module");
1759		g.add_def(
1760			child(&module, b"class", b"Internal"),
1761			b"class",
1762			&module,
1763			Some((0, 5)),
1764		)
1765		.unwrap();
1766		g.add_def(
1767			child(&module, b"class", b"Public"),
1768			b"class",
1769			&module,
1770			Some((6, 10)),
1771		)
1772		.unwrap();
1773		let v = evaluate(&g, "x", Lang::Ts, &cfg, SCHEME).unwrap();
1774		assert_eq!(v.len(), 1, "only `Internal` violates: {v:?}");
1775		assert!(v[0].moniker.contains("class:Internal"));
1776	}
1777
1778	#[test]
1779	fn implies_false_premise_is_pass() {
1780		// `name = 'Entity' => any(...)` should NOT flag classes that aren't Entities.
1781		// This is the bug that fix-by-implication addresses.
1782		let cfg = cfg_from(
1783			r#"
1784			[[ts.class.where]]
1785			id   = "entity-implies-x"
1786			expr = "name =~ Entity$ => kind = 'class'"
1787			"#,
1788		);
1789		let module = build_module(b"a");
1790		let mut g = CodeGraph::new(module.clone(), b"module");
1791		g.add_def(
1792			child(&module, b"class", b"NotAnEntity"),
1793			b"class",
1794			&module,
1795			Some((0, 10)),
1796		)
1797		.unwrap();
1798		let v = evaluate(&g, "x", Lang::Ts, &cfg, SCHEME).unwrap();
1799		assert!(
1800			v.is_empty(),
1801			"premise false (no `Entity` suffix) ⇒ implication trivially true: {v:?}"
1802		);
1803	}
1804
1805	#[test]
1806	fn implies_true_premise_evaluates_consequent() {
1807		let cfg = cfg_from(
1808			r#"
1809			[[ts.class.where]]
1810			id   = "entity-must-be-class"
1811			expr = "name =~ Entity$ => kind = 'class'"
1812			"#,
1813		);
1814		let module = build_module(b"a");
1815		let mut g = CodeGraph::new(module.clone(), b"module");
1816		// kind is 'class', so this should pass
1817		g.add_def(
1818			child(&module, b"class", b"UserEntity"),
1819			b"class",
1820			&module,
1821			Some((0, 10)),
1822		)
1823		.unwrap();
1824		let v = evaluate(&g, "x", Lang::Ts, &cfg, SCHEME).unwrap();
1825		assert!(v.is_empty(), "premise true + consequent true: {v:?}");
1826	}
1827
1828	// ─── segment(K) projection ──────────────────────────────────────────
1829
1830	#[test]
1831	fn segment_of_def_returns_first_match() {
1832		let cfg = cfg_from(
1833			r#"
1834			[[ts.class.where]]
1835			id   = "must-be-in-domain-module"
1836			expr = "segment('module') = 'domain'"
1837			"#,
1838		);
1839		let module = build_module(b"app");
1840		let mut g = CodeGraph::new(module.clone(), b"module");
1841		g.add_def(
1842			child(&module, b"class", b"Foo"),
1843			b"class",
1844			&module,
1845			Some((0, 5)),
1846		)
1847		.unwrap();
1848		let v = evaluate(&g, "x", Lang::Ts, &cfg, SCHEME).unwrap();
1849		assert_eq!(
1850			v.len(),
1851			1,
1852			"class lives in module:app, not module:domain: {v:?}"
1853		);
1854	}
1855
1856	#[test]
1857	fn source_and_target_segment_in_refs() {
1858		let cfg = cfg_from(
1859			r#"
1860			[[refs.where]]
1861			id   = "same-module-only"
1862			expr = "source.segment('module') != target.segment('module') => target.segment('module') = 'std'"
1863			"#,
1864		);
1865		let root = build_root();
1866		let mut g = CodeGraph::new(root.clone(), b"module");
1867		let billing = submodule(&root, b"billing");
1868		g.add_def(billing.clone(), b"module", &root, Some((0, 1)))
1869			.unwrap();
1870		let shipping = submodule(&root, b"shipping");
1871		g.add_def(shipping.clone(), b"module", &root, Some((2, 3)))
1872			.unwrap();
1873		let o = child(&billing, b"class", b"Order");
1874		g.add_def(o.clone(), b"class", &billing, Some((4, 5)))
1875			.unwrap();
1876		let p = child(&shipping, b"class", b"Pkg");
1877		g.add_def(p.clone(), b"class", &shipping, Some((6, 10)))
1878			.unwrap();
1879		g.add_ref(&o, p, b"uses_type", Some((4, 5))).unwrap();
1880		let v = evaluate(&g, "x", Lang::Ts, &cfg, SCHEME).unwrap();
1881		assert_eq!(v.len(), 1, "billing→shipping violation: {v:?}");
1882	}
1883
1884	#[test]
1885	fn per_lang_refs_section_is_evaluated() {
1886		let cfg = cfg_from(
1887			r#"
1888			[[ts.refs.where]]
1889			id   = "no-domain-import"
1890			expr = "source.segment('module') = 'domain' => NOT kind = 'imports'"
1891			"#,
1892		);
1893		let root = build_root();
1894		let mut g = CodeGraph::new(root.clone(), b"module");
1895		let domain = submodule(&root, b"domain");
1896		g.add_def(domain.clone(), b"module", &root, Some((0, 1)))
1897			.unwrap();
1898		let other = submodule(&root, b"infra");
1899		g.add_def(other.clone(), b"module", &root, Some((2, 3)))
1900			.unwrap();
1901		let order = child(&domain, b"class", b"Order");
1902		g.add_def(order.clone(), b"class", &domain, Some((4, 5)))
1903			.unwrap();
1904		let infra_cls = child(&other, b"class", b"X");
1905		g.add_def(infra_cls.clone(), b"class", &other, Some((6, 10)))
1906			.unwrap();
1907		g.add_ref(&order, infra_cls, b"imports", Some((4, 5)))
1908			.unwrap();
1909		let v = evaluate(&g, "x", Lang::Ts, &cfg, SCHEME).unwrap();
1910		assert_eq!(v.len(), 1, "per-lang refs rule fires: {v:?}");
1911		assert_eq!(v[0].rule_id, "refs.no-domain-import");
1912	}
1913
1914	// ─── quantifiers ────────────────────────────────────────────────────
1915
1916	#[test]
1917	fn count_method_with_filter() {
1918		let cfg = cfg_from(
1919			r#"
1920			[[ts.class.where]]
1921			id   = "few-getters"
1922			expr = "count(method, name =~ ^get) <= 1"
1923			"#,
1924		);
1925		let module = build_module(b"a");
1926		let mut g = CodeGraph::new(module.clone(), b"module");
1927		let cls = child(&module, b"class", b"Foo");
1928		g.add_def(cls.clone(), b"class", &module, Some((0, 50)))
1929			.unwrap();
1930		for name in [
1931			b"getFoo".as_slice(),
1932			b"getBar".as_slice(),
1933			b"setBaz".as_slice(),
1934		] {
1935			let m = child(&cls, b"method", name);
1936			g.add_def(m, b"method", &cls, Some((1, 5))).unwrap();
1937		}
1938		let v = evaluate(&g, "x", Lang::Ts, &cfg, SCHEME).unwrap();
1939		assert_eq!(v.len(), 1, "2 getters > 1 limit: {v:?}");
1940	}
1941
1942	#[test]
1943	fn any_quantifier_children() {
1944		let cfg = cfg_from(
1945			r#"
1946			[[ts.class.where]]
1947			id   = "must-have-execute"
1948			expr = "name =~ UseCase$ => any(method, name = 'execute')"
1949			"#,
1950		);
1951		let module = build_module(b"a");
1952		let mut g = CodeGraph::new(module.clone(), b"module");
1953		// MissingUC has no execute → violation
1954		let uc = child(&module, b"class", b"PayUseCase");
1955		g.add_def(uc.clone(), b"class", &module, Some((0, 50)))
1956			.unwrap();
1957		g.add_def(
1958			child(&uc, b"method", b"prepare"),
1959			b"method",
1960			&uc,
1961			Some((1, 5)),
1962		)
1963		.unwrap();
1964		// GoodUC has execute → no violation
1965		let good = child(&module, b"class", b"GoodUseCase");
1966		g.add_def(good.clone(), b"class", &module, Some((51, 100)))
1967			.unwrap();
1968		g.add_def(
1969			child(&good, b"method", b"execute"),
1970			b"method",
1971			&good,
1972			Some((52, 60)),
1973		)
1974		.unwrap();
1975		let v = evaluate(&g, "x", Lang::Ts, &cfg, SCHEME).unwrap();
1976		assert_eq!(v.len(), 1, "PayUseCase lacks execute: {v:?}");
1977		assert!(v[0].moniker.contains("PayUseCase"));
1978	}
1979
1980	#[test]
1981	fn all_quantifier_children() {
1982		let cfg = cfg_from(
1983			r#"
1984			[[ts.class.where]]
1985			id   = "methods-short"
1986			expr = "all(method, lines <= 5)"
1987			"#,
1988		);
1989		let module = build_module(b"a");
1990		let mut g = CodeGraph::new(module.clone(), b"module");
1991		let cls = child(&module, b"class", b"Foo");
1992		g.add_def(cls.clone(), b"class", &module, Some((0, 100)))
1993			.unwrap();
1994		g.add_def(child(&cls, b"method", b"ok"), b"method", &cls, Some((0, 4)))
1995			.unwrap();
1996		g.add_def(
1997			child(&cls, b"method", b"long"),
1998			b"method",
1999			&cls,
2000			Some((0, 200)),
2001		)
2002		.unwrap();
2003		let source: String = (0..40).map(|_| "a\n").collect();
2004		let v = evaluate(&g, &source, Lang::Ts, &cfg, SCHEME).unwrap();
2005		assert_eq!(v.len(), 1, "long method violates: {v:?}");
2006	}
2007
2008	#[test]
2009	fn none_quantifier_segments() {
2010		// "this def's moniker has no segment whose kind is 'class'"
2011		let cfg = cfg_from(
2012			r#"
2013			[[ts.function.where]]
2014			id   = "function-not-in-class"
2015			expr = "none(segment, segment.kind = 'class')"
2016			"#,
2017		);
2018		let module = build_module(b"a");
2019		let mut g = CodeGraph::new(module.clone(), b"module");
2020		let cls = child(&module, b"class", b"Foo");
2021		g.add_def(cls.clone(), b"class", &module, Some((0, 50)))
2022			.unwrap();
2023		// function nested inside class → has a class segment → violates
2024		let f = child(&cls, b"function", b"inner");
2025		g.add_def(f, b"function", &cls, Some((1, 5))).unwrap();
2026		let v = evaluate(&g, "x", Lang::Ts, &cfg, SCHEME).unwrap();
2027		assert_eq!(v.len(), 1, "function inside class violates: {v:?}");
2028	}
2029
2030	#[test]
2031	fn any_out_refs_must_implement_port() {
2032		let cfg = cfg_from(
2033			r#"
2034			[[ts.class.where]]
2035			id   = "adapter-implements-port"
2036			expr = "name =~ Adapter$ => any(out_refs, kind = 'implements' AND target.name =~ Port$)"
2037			"#,
2038		);
2039		let root = build_root();
2040		let mut g = CodeGraph::new(root.clone(), b"module");
2041		let m = submodule(&root, b"adapters");
2042		g.add_def(m.clone(), b"module", &root, Some((0, 1)))
2043			.unwrap();
2044		let bad = child(&m, b"class", b"OrderAdapter");
2045		g.add_def(bad.clone(), b"class", &m, Some((2, 10))).unwrap();
2046		// No implements ref → adapter without port → violation
2047		let v = evaluate(&g, "x", Lang::Ts, &cfg, SCHEME).unwrap();
2048		assert_eq!(v.len(), 1, "adapter with no implements: {v:?}");
2049	}
2050
2051	// ─── projection extensions ──────────────────────────────────────────
2052
2053	#[test]
2054	fn depth_projection() {
2055		let cfg = cfg_from(
2056			r#"
2057			[[ts.class.where]]
2058			id   = "shallow"
2059			expr = "depth <= 3"
2060			"#,
2061		);
2062		let module = build_module(b"a");
2063		let mut g = CodeGraph::new(module.clone(), b"module");
2064		let cls = child(&module, b"class", b"DeepClass");
2065		g.add_def(cls.clone(), b"class", &module, Some((0, 5)))
2066			.unwrap();
2067		// depth = 3 (project segment doesn't count, segments: lang, module, class)
2068		let v = evaluate(&g, "x", Lang::Ts, &cfg, SCHEME).unwrap();
2069		assert!(v.is_empty(), "depth = 3 is within limit: {v:?}");
2070	}
2071
2072	#[test]
2073	fn parent_name_projection() {
2074		let cfg = cfg_from(
2075			r#"
2076			[[ts.method.where]]
2077			id   = "no-name-clash"
2078			expr = "name != parent.name"
2079			"#,
2080		);
2081		let module = build_module(b"a");
2082		let mut g = CodeGraph::new(module.clone(), b"module");
2083		let cls = child(&module, b"class", b"Foo");
2084		g.add_def(cls.clone(), b"class", &module, Some((0, 50)))
2085			.unwrap();
2086		let m_ok = child(&cls, b"method", b"bar");
2087		g.add_def(m_ok, b"method", &cls, Some((1, 10))).unwrap();
2088		let m_bad = child(&cls, b"method", b"Foo");
2089		g.add_def(m_bad, b"method", &cls, Some((11, 20))).unwrap();
2090		let v = evaluate(&g, "x", Lang::Ts, &cfg, SCHEME).unwrap();
2091		assert_eq!(v.len(), 1, "method `Foo` shares parent name: {v:?}");
2092	}
2093
2094	#[test]
2095	fn parent_kind_projection() {
2096		let cfg = cfg_from(
2097			r#"
2098			[[ts.method.where]]
2099			id   = "method-in-class"
2100			expr = "parent.kind = 'class'"
2101			"#,
2102		);
2103		let module = build_module(b"a");
2104		let mut g = CodeGraph::new(module.clone(), b"module");
2105		// method directly under module (no class parent) — violates
2106		let m = child(&module, b"method", b"loose");
2107		g.add_def(m, b"method", &module, Some((0, 5))).unwrap();
2108		let v = evaluate(&g, "x", Lang::Ts, &cfg, SCHEME).unwrap();
2109		assert_eq!(v.len(), 1, "parent is module, not class: {v:?}");
2110	}
2111
2112	#[test]
2113	fn source_and_target_kind_projection() {
2114		let cfg = cfg_from(
2115			r#"
2116			[[refs.where]]
2117			id   = "no-class-to-function-edge"
2118			expr = "source.kind = 'class' => NOT target.kind = 'function'"
2119			"#,
2120		);
2121		let root = build_root();
2122		let mut g = CodeGraph::new(root.clone(), b"module");
2123		let cls = child(&root, b"class", b"Foo");
2124		g.add_def(cls.clone(), b"class", &root, Some((0, 5)))
2125			.unwrap();
2126		let func = child(&root, b"function", b"bar");
2127		g.add_def(func.clone(), b"function", &root, Some((6, 10)))
2128			.unwrap();
2129		g.add_ref(&cls, func, b"calls", Some((0, 5))).unwrap();
2130		let v = evaluate(&g, "x", Lang::Ts, &cfg, SCHEME).unwrap();
2131		assert_eq!(v.len(), 1, "class→function edge flagged: {v:?}");
2132	}
2133
2134	// ─── refs pipeline ──────────────────────────────────────────────────
2135
2136	fn build_root() -> Moniker {
2137		let mut b = MonikerBuilder::new();
2138		b.project(b".");
2139		b.segment(b"lang", b"ts");
2140		b.build()
2141	}
2142
2143	fn submodule(root: &Moniker, name: &[u8]) -> Moniker {
2144		let mut b = MonikerBuilder::from_view(root.as_view());
2145		b.segment(b"module", name);
2146		b.build()
2147	}
2148
2149	#[test]
2150	fn refs_top_level_flags_cross_layer_dep() {
2151		let cfg = cfg_from(
2152			r#"
2153			[[refs.where]]
2154			id   = "domain-no-infra"
2155			expr = "source ~ '**/module:domain/**' => NOT target ~ '**/module:infrastructure/**'"
2156			"#,
2157		);
2158		let root = build_root();
2159		let mut g = CodeGraph::new(root.clone(), b"module");
2160		let domain = submodule(&root, b"domain");
2161		g.add_def(domain.clone(), b"module", &root, Some((0, 1)))
2162			.unwrap();
2163		let infra = submodule(&root, b"infrastructure");
2164		g.add_def(infra.clone(), b"module", &root, Some((2, 3)))
2165			.unwrap();
2166		let order = child(&domain, b"class", b"Order");
2167		g.add_def(order.clone(), b"class", &domain, Some((4, 5)))
2168			.unwrap();
2169		let repo = child(&infra, b"class", b"OrderRepoImpl");
2170		g.add_def(repo.clone(), b"class", &infra, Some((6, 10)))
2171			.unwrap();
2172		g.add_ref(&order, repo, b"uses_type", Some((4, 5))).unwrap();
2173		let v = evaluate(&g, "x", Lang::Ts, &cfg, SCHEME).unwrap();
2174		assert_eq!(v.len(), 1, "cross-layer ref must violate: {v:?}");
2175		assert_eq!(v[0].rule_id, "refs.domain-no-infra");
2176	}
2177
2178	#[test]
2179	fn refs_implication_skips_unrelated_refs() {
2180		let cfg = cfg_from(
2181			r#"
2182			[[refs.where]]
2183			id   = "domain-only-self-or-std"
2184			expr = "source ~ '**/module:domain/**' => target ~ '**/module:domain/**' OR target ~ '**/module:std/**'"
2185			"#,
2186		);
2187		let root = build_root();
2188		let mut g = CodeGraph::new(root.clone(), b"module");
2189		let domain = submodule(&root, b"domain");
2190		g.add_def(domain.clone(), b"module", &root, Some((0, 1)))
2191			.unwrap();
2192		let std_mod = submodule(&root, b"std");
2193		g.add_def(std_mod.clone(), b"module", &root, Some((2, 3)))
2194			.unwrap();
2195		let order = child(&domain, b"class", b"Order");
2196		g.add_def(order.clone(), b"class", &domain, Some((4, 5)))
2197			.unwrap();
2198		let vec_class = child(&std_mod, b"class", b"Vec");
2199		g.add_def(vec_class.clone(), b"class", &std_mod, Some((6, 10)))
2200			.unwrap();
2201		g.add_ref(&order, vec_class, b"uses_type", Some((4, 5)))
2202			.unwrap();
2203		let v = evaluate(&g, "x", Lang::Ts, &cfg, SCHEME).unwrap();
2204		assert!(v.is_empty(), "domain → std is allowed: {v:?}");
2205	}
2206
2207	#[test]
2208	fn refs_filtered_by_kind() {
2209		let cfg = cfg_from(
2210			r#"
2211			[[refs.where]]
2212			id   = "no-domain-imports-framework"
2213			expr = "source ~ '**/module:domain/**' AND kind = 'imports' => NOT target.name =~ ^(express|nestjs)$"
2214			"#,
2215		);
2216		let root = build_root();
2217		let mut g = CodeGraph::new(root.clone(), b"module");
2218		let domain = submodule(&root, b"domain");
2219		g.add_def(domain.clone(), b"module", &root, Some((0, 1)))
2220			.unwrap();
2221		let ext = submodule(&root, b"extern");
2222		g.add_def(ext.clone(), b"module", &root, Some((2, 3)))
2223			.unwrap();
2224		let order = child(&domain, b"class", b"Order");
2225		g.add_def(order.clone(), b"class", &domain, Some((4, 5)))
2226			.unwrap();
2227		let express = child(&ext, b"class", b"express");
2228		g.add_def(express.clone(), b"class", &ext, Some((6, 10)))
2229			.unwrap();
2230		g.add_ref(&order, express, b"imports", Some((4, 5)))
2231			.unwrap();
2232		let v = evaluate(&g, "x", Lang::Ts, &cfg, SCHEME).unwrap();
2233		assert_eq!(v.len(), 1, "domain import of express must violate: {v:?}");
2234	}
2235
2236	#[test]
2237	fn alias_expands_in_rule_expr() {
2238		let cfg = cfg_from(
2239			r#"
2240			[aliases]
2241			domain = "moniker ~ '**/module:domain/**'"
2242
2243			[[ts.class.where]]
2244			id   = "no-class-in-domain"
2245			expr = "NOT $domain"
2246			"#,
2247		);
2248		let module = build_module(b"domain");
2249		let mut g = CodeGraph::new(module.clone(), b"module");
2250		g.add_def(
2251			child(&module, b"class", b"Foo"),
2252			b"class",
2253			&module,
2254			Some((0, 5)),
2255		)
2256		.unwrap();
2257		let v = evaluate(&g, "x", Lang::Ts, &cfg, SCHEME).unwrap();
2258		assert_eq!(v.len(), 1, "class in module:domain violates: {v:?}");
2259	}
2260
2261	#[test]
2262	fn path_match_subtree_flags_domain_class() {
2263		let cfg = cfg_from(
2264			r#"
2265			[[ts.class.where]]
2266			id   = "no-class-in-domain"
2267			expr = "NOT moniker ~ '**/module:domain/**'"
2268			"#,
2269		);
2270		let module = build_module(b"domain");
2271		let mut g = CodeGraph::new(module.clone(), b"module");
2272		g.add_def(
2273			child(&module, b"class", b"User"),
2274			b"class",
2275			&module,
2276			Some((0, 10)),
2277		)
2278		.unwrap();
2279		let v = evaluate(&g, "x", Lang::Ts, &cfg, SCHEME).unwrap();
2280		assert_eq!(v.len(), 1, "class lives in module:domain: {v:?}");
2281	}
2282
2283	#[test]
2284	fn has_segment_finds_module() {
2285		let cfg = cfg_from(
2286			r#"
2287			[[ts.class.where]]
2288			id   = "must-be-in-app"
2289			expr = "has_segment('module', 'application')"
2290			"#,
2291		);
2292		let module = build_module(b"infrastructure");
2293		let mut g = CodeGraph::new(module.clone(), b"module");
2294		g.add_def(
2295			child(&module, b"class", b"Foo"),
2296			b"class",
2297			&module,
2298			Some((0, 5)),
2299		)
2300		.unwrap();
2301		let v = evaluate(&g, "x", Lang::Ts, &cfg, SCHEME).unwrap();
2302		assert_eq!(v.len(), 1, "Foo lives in infrastructure, not application");
2303	}
2304
2305	#[test]
2306	fn path_regex_step_on_class_name() {
2307		let cfg = cfg_from(
2308			r#"
2309			[[ts.class.where]]
2310			id   = "ports-only-in-app"
2311			expr = "moniker ~ '**/class:/Port$/' => has_segment('module', 'application')"
2312			"#,
2313		);
2314		let module = build_module(b"domain");
2315		let mut g = CodeGraph::new(module.clone(), b"module");
2316		// A `Port` class living in `domain` (wrong place) — should flag.
2317		g.add_def(
2318			child(&module, b"class", b"UserPort"),
2319			b"class",
2320			&module,
2321			Some((0, 5)),
2322		)
2323		.unwrap();
2324		// A non-Port class in domain — premise false, should NOT flag.
2325		g.add_def(
2326			child(&module, b"class", b"Order"),
2327			b"class",
2328			&module,
2329			Some((6, 10)),
2330		)
2331		.unwrap();
2332		let v = evaluate(&g, "x", Lang::Ts, &cfg, SCHEME).unwrap();
2333		assert_eq!(v.len(), 1, "only `UserPort` violates: {v:?}");
2334		assert!(v[0].moniker.contains("UserPort"));
2335	}
2336
2337	#[test]
2338	fn implies_true_premise_failed_consequent_violates() {
2339		let cfg = cfg_from(
2340			r#"
2341			[[ts.function.where]]
2342			id   = "use-case-has-one-method"
2343			expr = "name =~ UseCase$ => lines <= 5"
2344			"#,
2345		);
2346		let module = build_module(b"a");
2347		let mut g = CodeGraph::new(module.clone(), b"module");
2348		g.add_def(
2349			child(&module, b"function", b"CreateInvoiceUseCase"),
2350			b"function",
2351			&module,
2352			Some((0, 200)),
2353		)
2354		.unwrap();
2355		// 50 lines of source so lines > 5
2356		let source: String = (0..50).map(|_| "a\n").collect();
2357		let v = evaluate(&g, &source, Lang::Ts, &cfg, SCHEME).unwrap();
2358		assert_eq!(
2359			v.len(),
2360			1,
2361			"premise true, consequent false ⇒ violation: {v:?}"
2362		);
2363	}
2364}