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