Skip to main content

code_moniker_check/check/workspace_eval/
group.rs

1use std::collections::BTreeMap;
2use std::fmt;
3
4use code_moniker_workspace::snapshot::{
5	InventorySymbol, SymbolInventoryIndex, SymbolOrdinal, SymbolSet,
6};
7use rustc_hash::FxHashMap;
8
9use crate::check::config::{ConfigError, WorkspaceGroupRuleEntry};
10use crate::check::eval::{CompiledRuleSpec, RuleReport, Violation};
11use crate::check::expr::{self, Node};
12
13use super::{
14	CompiledWorkspaceRules, WorkspaceEvaluation, WorkspaceSymbolViolation, classify_t1, eval_node,
15	render_template,
16};
17
18const MEMBER_SAMPLE_LIMIT: usize = 5;
19
20mod predicate;
21
22use predicate::GroupPredicate;
23
24#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
25pub struct ScopeKey {
26	pub rule_id: String,
27	pub values: Vec<String>,
28}
29
30impl ScopeKey {
31	pub fn canonical(&self) -> String {
32		let values = self
33			.values
34			.iter()
35			.map(|value| format!("{}:{value}", value.len()))
36			.collect::<Vec<_>>()
37			.join("/");
38		format!("{}:{}/{}", self.rule_id.len(), self.rule_id, values)
39	}
40
41	fn label(&self) -> String {
42		self.values.join(" / ")
43	}
44}
45
46impl fmt::Display for ScopeKey {
47	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48		formatter.write_str(&self.canonical())
49	}
50}
51
52#[derive(Clone, Debug, Eq, PartialEq)]
53pub struct WorkspaceGroupResult {
54	pub key: ScopeKey,
55	pub members: SymbolSet,
56	pub passed: bool,
57	pub suppressed: bool,
58	pub observations: Vec<String>,
59	pub evaluation_error: Option<String>,
60}
61
62#[derive(Debug)]
63pub(super) struct CompiledWorkspaceGroupRule {
64	rule_id: String,
65	members_expr: String,
66	members: Node,
67	group_by: Vec<GroupProjection>,
68	group_by_exprs: Vec<String>,
69	expr: String,
70	predicate: GroupPredicate,
71	severity: crate::check::config::RuleSeverity,
72	message: Option<String>,
73	rationale: Option<String>,
74	suppress: Vec<Vec<String>>,
75	capabilities: Vec<String>,
76}
77
78#[derive(Debug)]
79enum GroupProjection {
80	Language,
81	Name,
82	Kind,
83	Shape,
84	Visibility,
85	SourcePath,
86	SourceRoot,
87	Srcset,
88	Segment(String),
89}
90
91pub(super) fn compile_groups(
92	cfg: &crate::check::config::Config,
93	scheme: &str,
94	allowed_kinds: &[&str],
95	aliases: &std::collections::HashMap<String, String>,
96) -> Result<Vec<CompiledWorkspaceGroupRule>, ConfigError> {
97	cfg.workspace
98		.group
99		.rules
100		.iter()
101		.enumerate()
102		.map(|(index, entry)| {
103			let id = entry.fallback_id(index);
104			compile_group_rule(
105				entry,
106				format!("workspace.group.{id}"),
107				scheme,
108				allowed_kinds,
109				aliases,
110			)
111		})
112		.collect()
113}
114
115// The compiled plan outlives configuration loading and therefore owns its
116// projections, messages, rationales and suppression keys.
117// code-moniker: ignore[smell-clone-reflex]
118fn compile_group_rule(
119	entry: &WorkspaceGroupRuleEntry,
120	at: String,
121	scheme: &str,
122	allowed_kinds: &[&str],
123	aliases: &std::collections::HashMap<String, String>,
124) -> Result<CompiledWorkspaceGroupRule, ConfigError> {
125	if entry.group_by.is_empty() {
126		return invalid(&at, "`group_by` must contain at least one projection");
127	}
128	let members_expr = crate::check::config::substitute_aliases(&entry.members, aliases, &at)?;
129	let members = parse(&members_expr, scheme, allowed_kinds, &at)?;
130	let mut capabilities = classify_t1(&members.root, &at)?;
131	let group_by = entry
132		.group_by
133		.iter()
134		.map(|projection| parse_projection(projection, &at))
135		.collect::<Result<Vec<_>, _>>()?;
136	for projection in &group_by {
137		capabilities.push(format!("group_by.{}", projection.capability()));
138	}
139	let expr = crate::check::config::substitute_aliases(&entry.expr, aliases, &at)?;
140	let mut group_kinds = allowed_kinds.to_vec();
141	group_kinds.push("member");
142	let predicate_node = parse(&expr, scheme, &group_kinds, &at)?;
143	let predicate = predicate::compile(&predicate_node.root, &at)?;
144	predicate.append_capabilities(&mut capabilities);
145	capabilities.sort();
146	capabilities.dedup();
147	for suppression in &entry.suppress {
148		if suppression.values.len() != group_by.len() {
149			return invalid(
150				&at,
151				&format!(
152					"suppression key has {} values, expected {} from `group_by`",
153					suppression.values.len(),
154					group_by.len()
155				),
156			);
157		}
158	}
159	Ok(CompiledWorkspaceGroupRule {
160		rule_id: at,
161		members_expr,
162		members: members.root,
163		group_by,
164		group_by_exprs: entry.group_by.clone(),
165		expr,
166		predicate,
167		severity: entry.severity,
168		message: entry.message.clone(),
169		rationale: entry.rationale.clone(),
170		suppress: entry
171			.suppress
172			.iter()
173			.map(|suppression| suppression.values.clone())
174			.collect(),
175		capabilities,
176	})
177}
178
179fn parse(
180	expression: &str,
181	scheme: &str,
182	allowed_kinds: &[&str],
183	at: &str,
184) -> Result<expr::Expr, ConfigError> {
185	expr::parse(expression, scheme, allowed_kinds).map_err(|error| ConfigError::InvalidExpr {
186		at: at.to_string(),
187		error,
188	})
189}
190
191fn invalid<T>(at: &str, message: &str) -> Result<T, ConfigError> {
192	Err(ConfigError::InvalidWorkspaceGroup {
193		at: at.to_string(),
194		message: message.to_string(),
195	})
196}
197
198fn parse_projection(raw: &str, at: &str) -> Result<GroupProjection, ConfigError> {
199	let projection = match raw.trim() {
200		"lang" => GroupProjection::Language,
201		"name" => GroupProjection::Name,
202		"kind" => GroupProjection::Kind,
203		"shape" => GroupProjection::Shape,
204		"visibility" => GroupProjection::Visibility,
205		"source.path" => GroupProjection::SourcePath,
206		"source.root" => GroupProjection::SourceRoot,
207		"srcset" => GroupProjection::Srcset,
208		other => {
209			let Some(kind) = other
210				.strip_prefix("segment(")
211				.and_then(|value| value.strip_suffix(')'))
212				.map(str::trim)
213				.and_then(|value| {
214					value
215						.strip_prefix('\'')
216						.and_then(|value| value.strip_suffix('\''))
217						.or_else(|| {
218							value
219								.strip_prefix('"')
220								.and_then(|value| value.strip_suffix('"'))
221						})
222				})
223			else {
224				return invalid(at, &format!("unsupported `group_by` projection `{other}`"));
225			};
226			if kind.is_empty() {
227				return invalid(at, "segment projection requires a non-empty kind");
228			}
229			GroupProjection::Segment(kind.to_string())
230		}
231	};
232	Ok(projection)
233}
234
235impl GroupProjection {
236	fn capability(&self) -> String {
237		match self {
238			Self::Language => "lang".to_string(),
239			Self::Name => "name".to_string(),
240			Self::Kind => "kind".to_string(),
241			Self::Shape => "shape".to_string(),
242			Self::Visibility => "visibility".to_string(),
243			Self::SourcePath => "source.path".to_string(),
244			Self::SourceRoot => "source.root".to_string(),
245			Self::Srcset => "srcset".to_string(),
246			Self::Segment(kind) => format!("segment.{kind}"),
247		}
248	}
249
250	fn value(&self, record: &InventorySymbol) -> String {
251		match self {
252			Self::Language => record.language.to_string(),
253			Self::Name => record.name.to_string(),
254			Self::Kind => record.kind.to_string(),
255			Self::Shape => record.shape.to_string(),
256			Self::Visibility => record.visibility.to_string(),
257			Self::SourcePath => record.source_path.to_string(),
258			Self::SourceRoot => record.source_root.to_string(),
259			Self::Srcset => record.srcset.to_string(),
260			Self::Segment(kind) => record
261				.segments
262				.iter()
263				.filter(|segment| segment.kind.as_ref() == kind)
264				.map(|segment| escape_segment_component(segment.name.as_ref()))
265				.collect::<Vec<_>>()
266				.join("."),
267		}
268	}
269}
270
271fn escape_segment_component(value: &str) -> String {
272	value.replace('\\', "\\\\").replace('.', "\\.")
273}
274
275// CompiledRuleSpec is the owned public explanation DTO; projection necessarily
276// copies the plan strings out of the executable rule.
277// code-moniker: ignore[smell-clone-reflex]
278pub(super) fn append_group_specs(
279	compiled: &CompiledWorkspaceRules,
280	specs: &mut Vec<CompiledRuleSpec>,
281) {
282	specs.extend(compiled.group.iter().map(|rule| CompiledRuleSpec {
283		rule_id: rule.rule_id.clone(),
284		severity: rule.severity,
285		lang: "workspace".to_string(),
286		root: "workspace".to_string(),
287		subject: "group".to_string(),
288		plan: "t1_inventory".to_string(),
289		capabilities: rule.capabilities.clone(),
290		group_by: rule.group_by_exprs.clone(),
291		domain: "workspace groups".to_string(),
292		kind: None,
293		expr: rule.expr.clone(),
294		expanded_expr: format!("members: {}; assert: {}", rule.members_expr, rule.expr),
295		message: rule.message.clone(),
296		rationale: rule.rationale.clone(),
297		require_doc_comment: None,
298	}));
299}
300
301pub(super) fn evaluate_groups(
302	inventory: &SymbolInventoryIndex,
303	universe: &SymbolSet,
304	compiled: &CompiledWorkspaceRules,
305	report: bool,
306	atom_cache: &mut FxHashMap<String, SymbolSet>,
307	evaluation: &mut WorkspaceEvaluation,
308) {
309	for rule in &compiled.group {
310		evaluate_group_rule(inventory, universe, rule, report, atom_cache, evaluation);
311	}
312}
313
314fn evaluate_group_rule(
315	inventory: &SymbolInventoryIndex,
316	universe: &SymbolSet,
317	rule: &CompiledWorkspaceGroupRule,
318	report: bool,
319	atom_cache: &mut FxHashMap<String, SymbolSet>,
320	evaluation: &mut WorkspaceEvaluation,
321) {
322	let selected = eval_node(&rule.members, inventory, universe, atom_cache);
323	let mut buckets = BTreeMap::<ScopeKey, SymbolSet>::new();
324	for ordinal in selected.iter() {
325		let Some(record) = inventory.record(ordinal) else {
326			continue;
327		};
328		let key = ScopeKey {
329			rule_id: rule.rule_id.clone(),
330			values: rule
331				.group_by
332				.iter()
333				.map(|projection| projection.value(record))
334				.collect(),
335		};
336		buckets.entry(key).or_default().insert(ordinal);
337	}
338	let mut passing = 0;
339	let mut violations = 0;
340	for (key, members) in buckets {
341		let outcome = rule.predicate.evaluate(inventory, &members);
342		let passed = outcome.passed;
343		let suppressed = !passed && rule.suppress.contains(&key.values);
344		passing += usize::from(passed);
345		violations += usize::from(!passed && !suppressed);
346		let result = WorkspaceGroupResult {
347			key,
348			members,
349			passed,
350			suppressed,
351			observations: outcome.observations,
352			evaluation_error: outcome.error,
353		};
354		if !passed && !suppressed {
355			if let Some(violation) = group_violation(inventory, rule, &result) {
356				evaluation.violations.push(violation);
357			}
358		}
359		evaluation.groups.push(result);
360	}
361	if report {
362		evaluation.reports.push(RuleReport {
363			rule_id: rule.rule_id.clone(),
364			severity: rule.severity,
365			domain: "workspace groups".to_string(),
366			evaluated: evaluation
367				.groups
368				.iter()
369				.filter(|group| group.key.rule_id == rule.rule_id)
370				.count(),
371			matches: passing,
372			violations,
373			antecedent_matches: None,
374			warning: None,
375			inconclusive: None,
376			verdict: None,
377			coverage: None,
378			path: None,
379		});
380	}
381}
382
383fn group_violation(
384	inventory: &SymbolInventoryIndex,
385	rule: &CompiledWorkspaceGroupRule,
386	result: &WorkspaceGroupResult,
387) -> Option<WorkspaceSymbolViolation> {
388	let primary = primary_member(inventory, &result.members)?;
389	let member_summary = member_summary(inventory, &result.members);
390	let group = result.key.label();
391	let srcset = group_srcset(inventory, &result.members);
392	let observations = result.observations.join(", ");
393	let evaluation_error = result.evaluation_error.as_deref().unwrap_or_default();
394	let explanation = rule.message.as_deref().map(|message| {
395		render_template(
396			message,
397			&[
398				("group", group.as_str()),
399				("members", member_summary.as_str()),
400				("expr", rule.expr.as_str()),
401				("observations", observations.as_str()),
402				("evaluation_error", evaluation_error),
403			],
404		)
405	});
406	let detail = if observations.is_empty() {
407		String::new()
408	} else {
409		format!("; observed {observations}")
410	};
411	Some(WorkspaceSymbolViolation {
412		source: primary.1.source,
413		symbol: Some(primary.1.id),
414		source_suppression: false,
415		violation: Violation {
416			rule_id: rule.rule_id.clone(),
417			severity: rule.severity,
418			moniker: result.key.canonical(),
419			srcset,
420			kind: "group".to_string(),
421			lines: primary.1.line_range.unwrap_or((0, 0)),
422			message: format!(
423				"group `{group}` has {member_summary} and fails `{}`{detail}",
424				rule.expr,
425			),
426			explanation,
427		},
428	})
429}
430
431fn group_srcset(inventory: &SymbolInventoryIndex, members: &SymbolSet) -> Option<String> {
432	let mut srcset = None::<&str>;
433	let mut saw_unspecified = false;
434	for ordinal in members.iter() {
435		let Some(record) = inventory.record(ordinal) else {
436			continue;
437		};
438		if record.srcset.is_empty() {
439			saw_unspecified = true;
440			continue;
441		}
442		match srcset {
443			None => srcset = Some(record.srcset.as_ref()),
444			Some(value) if value == record.srcset.as_ref() => {}
445			Some(_) => return Some("mixed".to_string()),
446		}
447	}
448	if srcset.is_some() && saw_unspecified {
449		return Some("mixed".to_string());
450	}
451	srcset.map(str::to_string)
452}
453
454fn primary_member<'a>(
455	inventory: &'a SymbolInventoryIndex,
456	members: &SymbolSet,
457) -> Option<(SymbolOrdinal, &'a InventorySymbol)> {
458	members
459		.iter()
460		.filter_map(|ordinal| inventory.record(ordinal).map(|record| (ordinal, record)))
461		.min_by(|left, right| {
462			left.1
463				.source_path
464				.cmp(&right.1.source_path)
465				.then_with(|| left.1.identity.cmp(&right.1.identity))
466				.then_with(|| left.0.cmp(&right.0))
467		})
468}
469
470fn member_summary(inventory: &SymbolInventoryIndex, members: &SymbolSet) -> String {
471	let mut names = members
472		.iter()
473		.filter_map(|ordinal| inventory.record(ordinal))
474		.map(|record| format!("{} ({})", record.name, record.source_path))
475		.collect::<Vec<_>>();
476	names.sort();
477	names.truncate(MEMBER_SAMPLE_LIMIT);
478	let suffix =
479		(members.len() > names.len()).then(|| format!(", +{} more", members.len() - names.len()));
480	format!(
481		"{} members: {}{}",
482		members.len(),
483		names.join(", "),
484		suffix.as_deref().unwrap_or_default()
485	)
486}
487
488pub(super) struct GroupIncrementalInput<'a> {
489	pub previous_inventory: &'a SymbolInventoryIndex,
490	pub current_inventory: &'a SymbolInventoryIndex,
491	pub previous_universe: &'a SymbolSet,
492	pub current_universe: &'a SymbolSet,
493	pub previous_dirty: &'a SymbolSet,
494	pub current_dirty: &'a SymbolSet,
495	pub compiled: &'a CompiledWorkspaceRules,
496	pub previous: &'a WorkspaceEvaluation,
497}
498
499pub(super) fn evaluate_groups_incremental(
500	input: GroupIncrementalInput<'_>,
501) -> (
502	Vec<WorkspaceGroupResult>,
503	Vec<WorkspaceSymbolViolation>,
504	usize,
505) {
506	let GroupIncrementalInput {
507		previous_inventory,
508		current_inventory,
509		previous_universe,
510		current_universe,
511		previous_dirty,
512		current_dirty,
513		compiled,
514		previous,
515	} = input;
516	let mut next_by_key = index_group_results(previous);
517	let mut affected = std::collections::BTreeSet::new();
518	let mut previous_cache = FxHashMap::default();
519	let mut current_cache = FxHashMap::default();
520	for rule in &compiled.group {
521		let previous_selected = eval_node(
522			&rule.members,
523			previous_inventory,
524			&previous_dirty.intersection(previous_universe),
525			&mut previous_cache,
526		);
527		let current_selected = eval_node(
528			&rule.members,
529			current_inventory,
530			&current_dirty.intersection(current_universe),
531			&mut current_cache,
532		);
533		let previous_changed = bucket_members(previous_inventory, rule, &previous_selected);
534		let current_changed = bucket_members(current_inventory, rule, &current_selected);
535		let keys = previous_changed
536			.keys()
537			.chain(current_changed.keys())
538			.cloned()
539			.collect::<std::collections::BTreeSet<_>>();
540		for key in keys {
541			affected.insert(key.canonical());
542			let mut members = next_by_key
543				.get(&key)
544				.map(|group| group.members.clone())
545				.unwrap_or_default();
546			members.remove_all(previous_dirty);
547			members.intersect_with(current_universe);
548			if let Some(changed) = current_changed.get(&key) {
549				members.union_with(changed);
550			}
551			if members.is_empty() {
552				next_by_key.remove(&key);
553				continue;
554			}
555			let outcome = rule.predicate.evaluate(current_inventory, &members);
556			let passed = outcome.passed;
557			let suppressed = !passed && rule.suppress.contains(&key.values);
558			next_by_key.insert(
559				key.clone(),
560				WorkspaceGroupResult {
561					key,
562					members,
563					passed,
564					suppressed,
565					observations: outcome.observations,
566					evaluation_error: outcome.error,
567				},
568			);
569		}
570	}
571	let groups = next_by_key.into_values().collect::<Vec<_>>();
572	let violations = group_diagnostics(current_inventory, compiled, &groups);
573	(groups, violations, affected.len())
574}
575
576fn index_group_results(
577	evaluation: &WorkspaceEvaluation,
578) -> BTreeMap<ScopeKey, WorkspaceGroupResult> {
579	evaluation
580		.groups
581		.iter()
582		.cloned()
583		.map(|group| (group.key.clone(), group))
584		.collect()
585}
586
587fn bucket_members(
588	inventory: &SymbolInventoryIndex,
589	rule: &CompiledWorkspaceGroupRule,
590	selected: &SymbolSet,
591) -> BTreeMap<ScopeKey, SymbolSet> {
592	let mut buckets = BTreeMap::new();
593	for ordinal in selected.iter() {
594		let Some(record) = inventory.record(ordinal) else {
595			continue;
596		};
597		let key = ScopeKey {
598			rule_id: rule.rule_id.clone(),
599			values: rule
600				.group_by
601				.iter()
602				.map(|projection| projection.value(record))
603				.collect(),
604		};
605		buckets
606			.entry(key)
607			.or_insert_with(SymbolSet::new)
608			.insert(ordinal);
609	}
610	buckets
611}
612
613fn group_diagnostics(
614	inventory: &SymbolInventoryIndex,
615	compiled: &CompiledWorkspaceRules,
616	groups: &[WorkspaceGroupResult],
617) -> Vec<WorkspaceSymbolViolation> {
618	let rules = compiled
619		.group
620		.iter()
621		.map(|rule| (rule.rule_id.as_str(), rule))
622		.collect::<BTreeMap<_, _>>();
623	groups
624		.iter()
625		.filter(|group| !group.passed && !group.suppressed)
626		.filter_map(|group| {
627			group_violation(
628				inventory,
629				rules.get(group.key.rule_id.as_str()).copied()?,
630				group,
631			)
632		})
633		.collect()
634}
635
636#[cfg(test)]
637mod tests {
638	use std::collections::BTreeSet;
639	use std::sync::Arc;
640
641	use code_moniker_workspace::snapshot::{
642		RecordTable, ResourceGeneration, SourceFileRecord, SourceId, SymbolId, SymbolRecord,
643	};
644
645	use super::*;
646
647	fn source(file: usize, package: &str) -> SourceFileRecord {
648		let path = format!(
649			"src/main/java/{}/Type{file}.java",
650			package.replace('.', "/")
651		);
652		SourceFileRecord {
653			id: SourceId::at(file),
654			uri: path.clone(),
655			source_root: 0,
656			path: path.clone(),
657			rel_path: path.clone(),
658			anchor: path,
659			language: "java".to_string(),
660			text: String::new(),
661		}
662	}
663
664	fn invoice(file: usize, package: &str, container: &str) -> SymbolRecord {
665		invoice_with_lines(file, package, container, Some((4, 4)))
666	}
667
668	fn invoice_with_lines(
669		file: usize,
670		package: &str,
671		container: &str,
672		line_range: Option<(u32, u32)>,
673	) -> SymbolRecord {
674		let packages = package
675			.split('.')
676			.map(|name| format!("package:{name}"))
677			.collect::<Vec<_>>()
678			.join("/");
679		let mut symbol = SymbolRecord::new(
680			SymbolId::at(file, 0),
681			SourceId::at(file),
682			"Invoice",
683			"class",
684		);
685		symbol.identity = Arc::from(format!(
686			"code+moniker://./lang:java/srcset:main/{packages}/class:{container}/class:Invoice"
687		));
688		symbol.line_range = line_range;
689		symbol
690	}
691
692	fn group_config() -> crate::check::config::Config {
693		crate::check::config::load_from_str(
694			r#"
695			[[workspace.group.where]]
696			id = "unique"
697			members = "name = 'Invoice'"
698			group_by = ["lang", "segment('package')", "name"]
699			expr = "count(member) <= 1"
700			"#,
701			"<test>",
702			Some(false),
703		)
704		.expect("group config")
705	}
706
707	fn statistic_config(expr: &str) -> crate::check::config::Config {
708		crate::check::config::load_from_str(
709			&format!(
710				r#"
711				[[workspace.group.where]]
712				id = "balanced-size"
713				severity = "warn"
714				members = "name = 'Invoice'"
715				group_by = ["lang", "segment('package')"]
716				expr = "{expr}"
717				"#
718			),
719			"<test>",
720			Some(false),
721		)
722		.expect("statistic group config")
723	}
724
725	fn statistic_inventory(line_ranges: &[Option<(u32, u32)>]) -> SymbolInventoryIndex {
726		let sources = line_ranges
727			.iter()
728			.enumerate()
729			.map(|(file, _)| source(file, "com.acme.sales"))
730			.collect::<Vec<_>>();
731		let symbols = RecordTable::from_shards(
732			line_ranges
733				.iter()
734				.enumerate()
735				.map(|(file, line_range)| {
736					Arc::from(vec![invoice_with_lines(
737						file,
738						"com.acme.sales",
739						&format!("Sales{file}"),
740						*line_range,
741					)])
742				})
743				.collect(),
744		);
745		SymbolInventoryIndex::build(ResourceGeneration::new(1), &sources, &symbols)
746	}
747
748	#[test]
749	fn group_srcset_is_mixed_when_named_and_unspecified_members_coexist() {
750		let sources = vec![source(0, "com.acme.sales"), source(1, "com.acme.sales")];
751		let named = invoice(0, "com.acme.sales", "Named");
752		let mut unspecified = invoice(1, "com.acme.sales", "Unspecified");
753		unspecified.identity = Arc::from(unspecified.identity.replace("/srcset:main", ""));
754		let symbols =
755			RecordTable::from_shards(vec![Arc::from(vec![named]), Arc::from(vec![unspecified])]);
756		let inventory = SymbolInventoryIndex::build(ResourceGeneration::new(1), &sources, &symbols);
757
758		assert_eq!(
759			group_srcset(&inventory, inventory.all_symbols()),
760			Some("mixed".to_string())
761		);
762	}
763
764	#[test]
765	fn guarded_line_statistic_reports_the_value_and_coverage() {
766		let inventory = statistic_inventory(&[Some((4, 4)), Some((4, 4)), Some((4, 13))]);
767		let compiled = super::super::compile_workspace_rules(
768			&statistic_config("count(member) >= 3 => gini(member, lines) < 0.5"),
769			"code+moniker://",
770		)
771		.expect("statistic plan");
772		assert_eq!(
773			compiled.group[0].capabilities,
774			vec![
775				"group.count".to_string(),
776				"group.gini.lines".to_string(),
777				"group_by.lang".to_string(),
778				"group_by.segment.package".to_string(),
779				"name.exact".to_string(),
780			]
781		);
782
783		let result = super::super::evaluate_workspace_rules(&inventory, &compiled, false);
784
785		assert_eq!(result.groups.len(), 1);
786		assert!(!result.groups[0].passed);
787		assert_eq!(result.violations.len(), 1);
788		assert!(
789			result.violations[0]
790				.violation
791				.message
792				.contains("gini(member, lines)=0.5"),
793			"{:#?}",
794			result.violations
795		);
796		assert!(
797			result.violations[0]
798				.violation
799				.message
800				.contains("3/3 line ranges"),
801			"{:#?}",
802			result.violations
803		);
804	}
805
806	#[test]
807	fn line_statistic_is_fail_closed_when_a_member_has_no_line_range() {
808		let inventory = statistic_inventory(&[Some((4, 4)), None, Some((4, 13))]);
809		let compiled = super::super::compile_workspace_rules(
810			&statistic_config("count(member) >= 3 => avg(member, lines) <= 10"),
811			"code+moniker://",
812		)
813		.expect("statistic plan");
814
815		let result = super::super::evaluate_workspace_rules(&inventory, &compiled, false);
816
817		assert_eq!(result.groups.len(), 1);
818		assert!(!result.groups[0].passed);
819		assert_eq!(
820			result.groups[0].evaluation_error.as_deref(),
821			Some("avg(member, lines)=unavailable (2/3 line ranges)")
822		);
823		assert_eq!(result.violations.len(), 1);
824		assert!(
825			result.violations[0]
826				.violation
827				.message
828				.contains("avg(member, lines)=unavailable (2/3 line ranges)"),
829			"{:#?}",
830			result.violations
831		);
832	}
833
834	#[test]
835	fn unavailable_statistics_follow_order_independent_boolean_semantics() {
836		let inventory = statistic_inventory(&[Some((4, 4)), None]);
837
838		for expr in [
839			"count(member) >= 1 OR avg(member, lines) < 10",
840			"avg(member, lines) < 10 OR count(member) >= 1",
841		] {
842			let compiled =
843				super::super::compile_workspace_rules(&statistic_config(expr), "code+moniker://")
844					.expect("OR statistic plan");
845			let result = super::super::evaluate_workspace_rules(&inventory, &compiled, false);
846
847			assert!(result.groups[0].passed, "{expr}: {:#?}", result.groups[0]);
848			assert_eq!(result.groups[0].evaluation_error, None, "{expr}");
849		}
850
851		for expr in [
852			"count(member) < 1 AND avg(member, lines) < 10",
853			"avg(member, lines) < 10 AND count(member) < 1",
854		] {
855			let compiled =
856				super::super::compile_workspace_rules(&statistic_config(expr), "code+moniker://")
857					.expect("AND statistic plan");
858			let result = super::super::evaluate_workspace_rules(&inventory, &compiled, false);
859
860			assert!(!result.groups[0].passed, "{expr}: {:#?}", result.groups[0]);
861			assert_eq!(result.groups[0].evaluation_error, None, "{expr}");
862		}
863
864		let compiled = super::super::compile_workspace_rules(
865			&statistic_config("avg(member, lines) < 10 => count(member) >= 1"),
866			"code+moniker://",
867		)
868		.expect("implication statistic plan");
869		let result = super::super::evaluate_workspace_rules(&inventory, &compiled, false);
870		assert!(result.groups[0].passed, "{:#?}", result.groups[0]);
871		assert_eq!(result.groups[0].evaluation_error, None);
872
873		for expr in [
874			"count(member) >= 1 AND avg(member, lines) < 10",
875			"avg(member, lines) < 10 AND count(member) >= 1",
876			"count(member) < 1 OR avg(member, lines) < 10",
877			"avg(member, lines) < 10 OR count(member) < 1",
878		] {
879			let compiled =
880				super::super::compile_workspace_rules(&statistic_config(expr), "code+moniker://")
881					.expect("unresolved statistic plan");
882			let result = super::super::evaluate_workspace_rules(&inventory, &compiled, false);
883
884			assert!(!result.groups[0].passed, "{expr}: {:#?}", result.groups[0]);
885			assert!(
886				result.groups[0].evaluation_error.is_some(),
887				"{expr}: {:#?}",
888				result.groups[0]
889			);
890		}
891	}
892
893	#[test]
894	fn implication_guard_skips_the_statistic_for_small_groups() {
895		let inventory = statistic_inventory(&[Some((4, 4)), None]);
896		let compiled = super::super::compile_workspace_rules(
897			&statistic_config("count(member) >= 3 => gini(member, lines) <= 0.5"),
898			"code+moniker://",
899		)
900		.expect("statistic plan");
901
902		let result = super::super::evaluate_workspace_rules(&inventory, &compiled, false);
903
904		assert_eq!(result.groups.len(), 1);
905		assert!(result.groups[0].passed);
906		assert!(result.violations.is_empty());
907	}
908
909	#[test]
910	fn statistic_compilation_rejects_invalid_percentiles_and_projections() {
911		for (expr, expected) in [
912			(
913				"percentile(member, lines, 101) <= 10",
914				"requires P in 0..=100",
915			),
916			(
917				"gini(member, start_line) <= 0.5",
918				"descriptive aggregates over `(member, lines)`",
919			),
920		] {
921			let error =
922				super::super::compile_workspace_rules(&statistic_config(expr), "code+moniker://")
923					.expect_err("unsupported statistic must fail closed");
924			assert!(error.to_string().contains(expected), "{error}");
925		}
926	}
927
928	#[test]
929	fn all_descriptive_line_aggregates_use_the_group_member_domain() {
930		let inventory = statistic_inventory(&[Some((4, 4)), Some((4, 4)), Some((4, 13))]);
931		let compiled = super::super::compile_workspace_rules(
932			&statistic_config(
933				"sum(member, lines) = 12 \
934				AND max(member, lines) = 10 \
935				AND min(member, lines) = 1 \
936				AND avg(member, lines) = 4 \
937				AND median(member, lines) = 1 \
938				AND percentile(member, lines, 90) > 8 \
939				AND stddev(member, lines) > 4 \
940				AND var(member, lines) = 18 \
941				AND cv(member, lines) > 1 \
942				AND gini(member, lines) = 0.5",
943			),
944			"code+moniker://",
945		)
946		.expect("all descriptive statistics plan");
947
948		let result = super::super::evaluate_workspace_rules(&inventory, &compiled, false);
949
950		assert_eq!(result.groups.len(), 1);
951		assert!(result.groups[0].passed, "{:#?}", result.groups[0]);
952		assert_eq!(result.groups[0].observations.len(), 10);
953		assert!(result.violations.is_empty());
954	}
955
956	#[test]
957	fn incremental_line_statistic_matches_a_full_re_evaluation() {
958		let sources = (0..3)
959			.map(|file| source(file, "com.acme.sales"))
960			.collect::<Vec<_>>();
961		let before_symbols = RecordTable::from_shards(
962			(0..3)
963				.map(|file| {
964					Arc::from(vec![invoice_with_lines(
965						file,
966						"com.acme.sales",
967						&format!("Sales{file}"),
968						Some((4, 4)),
969					)])
970				})
971				.collect(),
972		);
973		let before =
974			SymbolInventoryIndex::build(ResourceGeneration::new(1), &sources, &before_symbols);
975		let compiled = super::super::compile_workspace_rules(
976			&statistic_config("count(member) >= 3 => avg(member, lines) <= 2"),
977			"code+moniker://",
978		)
979		.expect("statistic plan");
980		let before_result = super::super::evaluate_workspace_rules(&before, &compiled, false);
981		assert!(before_result.groups[0].passed);
982
983		let after_symbols = RecordTable::from_shards(
984			(0..3)
985				.map(|file| {
986					Arc::from(vec![invoice_with_lines(
987						file,
988						"com.acme.sales",
989						&format!("Sales{file}"),
990						Some(if file == 2 { (4, 13) } else { (4, 4) }),
991					)])
992				})
993				.collect(),
994		);
995		let after = before.refresh(
996			ResourceGeneration::new(2),
997			&sources,
998			&after_symbols,
999			&BTreeSet::from([2]),
1000		);
1001		let previous_ordinal = before
1002			.catalog()
1003			.ordinal(&SymbolId::at(2, 0))
1004			.expect("previous dirty ordinal");
1005		let current_ordinal = after
1006			.catalog()
1007			.ordinal(&SymbolId::at(2, 0))
1008			.expect("current dirty ordinal");
1009		let mut previous_dirty = SymbolSet::new();
1010		previous_dirty.insert(previous_ordinal);
1011		let mut current_dirty = SymbolSet::new();
1012		current_dirty.insert(current_ordinal);
1013
1014		let (groups, violations, affected) = evaluate_groups_incremental(GroupIncrementalInput {
1015			previous_inventory: &before,
1016			current_inventory: &after,
1017			previous_universe: before.all_symbols(),
1018			current_universe: after.all_symbols(),
1019			previous_dirty: &previous_dirty,
1020			current_dirty: &current_dirty,
1021			compiled: &compiled,
1022			previous: &before_result,
1023		});
1024		let full = super::super::evaluate_workspace_rules(&after, &compiled, false);
1025
1026		assert_eq!(affected, 1);
1027		assert_eq!(groups, full.groups);
1028		assert_eq!(violations.len(), 1);
1029		assert_eq!(
1030			violations[0].violation.message,
1031			full.violations[0].violation.message
1032		);
1033		assert!(
1034			violations[0]
1035				.violation
1036				.message
1037				.contains("avg(member, lines)=4")
1038		);
1039	}
1040
1041	#[test]
1042	fn moving_one_member_rebuilds_the_old_and_new_scope_keys() {
1043		let sources = vec![source(0, "com.acme.sales"), source(1, "com.acme.sales")];
1044		let before_symbols = RecordTable::from_shards(vec![
1045			Arc::from(vec![invoice(0, "com.acme.sales", "SalesA")]),
1046			Arc::from(vec![invoice(1, "com.acme.sales", "SalesB")]),
1047		]);
1048		let before =
1049			SymbolInventoryIndex::build(ResourceGeneration::new(1), &sources, &before_symbols);
1050		let compiled = super::super::compile_workspace_rules(&group_config(), "code+moniker://")
1051			.expect("plan");
1052		let before_result = super::super::evaluate_workspace_rules(&before, &compiled, false);
1053		assert_eq!(before_result.groups.len(), 1);
1054		assert!(!before_result.groups[0].passed);
1055		assert_eq!(before_result.groups[0].members.len(), 2);
1056
1057		let after_sources = vec![source(0, "com.acme.sales"), source(1, "com.acme.orders")];
1058		let after_symbols = RecordTable::from_shards(vec![
1059			Arc::from(vec![invoice(0, "com.acme.sales", "SalesA")]),
1060			Arc::from(vec![invoice(1, "com.acme.orders", "SalesB")]),
1061		]);
1062		let after = before.refresh(
1063			ResourceGeneration::new(2),
1064			&after_sources,
1065			&after_symbols,
1066			&BTreeSet::from([1]),
1067		);
1068		let after_result = super::super::evaluate_workspace_rules(&after, &compiled, false);
1069		assert_eq!(after_result.groups.len(), 2);
1070		assert!(after_result.groups.iter().all(|group| group.passed));
1071		let packages = after_result
1072			.groups
1073			.iter()
1074			.map(|group| group.key.values[1].as_str())
1075			.collect::<BTreeSet<_>>();
1076		assert_eq!(
1077			packages,
1078			BTreeSet::from(["com.acme.orders", "com.acme.sales"])
1079		);
1080	}
1081
1082	#[test]
1083	fn segment_projection_preserves_component_boundaries() {
1084		let sources = vec![source(0, "acme"), source(1, "acme")];
1085		let symbol = |file, identity: &str| {
1086			let mut symbol = SymbolRecord::new(
1087				SymbolId::at(file, 0),
1088				SourceId::at(file),
1089				"Invoice",
1090				"class",
1091			);
1092			symbol.identity = Arc::from(identity);
1093			symbol
1094		};
1095		let symbols = RecordTable::from_shards(vec![
1096			Arc::from(vec![symbol(
1097				0,
1098				"code+moniker://./lang:java/dir:a.b/dir:c/class:Invoice",
1099			)]),
1100			Arc::from(vec![symbol(
1101				1,
1102				"code+moniker://./lang:java/dir:a/dir:b.c/class:Invoice",
1103			)]),
1104		]);
1105		let inventory = SymbolInventoryIndex::build(ResourceGeneration::new(1), &sources, &symbols);
1106		let cfg = crate::check::config::load_from_str(
1107			r#"
1108			[[workspace.group.where]]
1109			id = "distinct-segment-sequences"
1110			members = "name = 'Invoice'"
1111			group_by = ["segment('dir')", "name"]
1112			expr = "count(member) <= 1"
1113			"#,
1114			"<test>",
1115			Some(false),
1116		)
1117		.expect("group config");
1118		let compiled =
1119			super::super::compile_workspace_rules(&cfg, "code+moniker://").expect("group plan");
1120		let result = super::super::evaluate_workspace_rules(&inventory, &compiled, false);
1121		assert_eq!(result.groups.len(), 2);
1122		assert!(result.groups.iter().all(|group| group.passed));
1123	}
1124
1125	#[test]
1126	fn canonical_scope_key_prefixes_rule_id_and_values() {
1127		let left = ScopeKey {
1128			rule_id: "workspace.group.a".to_string(),
1129			values: vec!["x".to_string(), "y".to_string()],
1130		};
1131		let right = ScopeKey {
1132			rule_id: "workspace.group.a/1:x".to_string(),
1133			values: vec!["y".to_string()],
1134		};
1135		assert_ne!(left.canonical(), right.canonical());
1136	}
1137}