Skip to main content

code_moniker_check/check/
workspace_eval.rs

1mod group;
2mod incremental;
3mod linkage;
4mod path_rule;
5
6use std::collections::{BTreeMap, BTreeSet};
7
8use code_moniker_workspace::snapshot::{
9	CodeIndex, LinkageSnapshot, ResourceGeneration, SourceId, SymbolInventoryIndex, SymbolSet,
10};
11use rustc_hash::FxHashMap;
12use thiserror::Error;
13
14use crate::check::config::{Config, ConfigError, RuleEntry};
15use crate::check::eval::{CompiledRuleSpec, RuleReport, Violation};
16use crate::check::expr::{self, Atom, Domain, Lhs, LhsExpr, Node, NumberExpr, Op, Rhs};
17use crate::check::path::{self, Step};
18
19pub use group::{ScopeKey, WorkspaceGroupResult};
20
21#[derive(Debug)]
22struct CompiledWorkspaceSymbolRule {
23	rule_id: String,
24	raw_expr: String,
25	expanded_expr: String,
26	root: Node,
27	severity: crate::check::config::RuleSeverity,
28	message: Option<String>,
29	rationale: Option<String>,
30	capabilities: Vec<String>,
31	plan: WorkspaceRulePlan,
32}
33
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35enum WorkspaceRulePlan {
36	Inventory,
37	Linkage,
38}
39
40impl WorkspaceRulePlan {
41	fn as_str(self) -> &'static str {
42		match self {
43			Self::Inventory => "t1_inventory",
44			Self::Linkage => "t2_linkage",
45		}
46	}
47}
48
49#[derive(Debug, Default)]
50pub struct CompiledWorkspaceRules {
51	symbol: Vec<CompiledWorkspaceSymbolRule>,
52	group: Vec<group::CompiledWorkspaceGroupRule>,
53	path: Vec<path_rule::CompiledWorkspacePathRule>,
54	min_linkage_coverage: usize,
55}
56
57impl CompiledWorkspaceRules {
58	pub fn is_empty(&self) -> bool {
59		self.symbol.is_empty() && self.group.is_empty() && self.path.is_empty()
60	}
61
62	pub fn has_linkage_rules(&self) -> bool {
63		!self.path.is_empty()
64			|| self
65				.symbol
66				.iter()
67				.any(|rule| rule.plan == WorkspaceRulePlan::Linkage)
68	}
69
70	pub fn specs(&self) -> Vec<CompiledRuleSpec> {
71		let mut specs = self
72			.symbol
73			.iter()
74			.map(|rule| CompiledRuleSpec {
75				rule_id: rule.rule_id.to_owned(),
76				severity: rule.severity,
77				lang: "workspace".to_string(),
78				root: "workspace".to_string(),
79				subject: "symbol".to_string(),
80				plan: rule.plan.as_str().to_string(),
81				capabilities: rule.capabilities.to_vec(),
82				group_by: Vec::new(),
83				domain: "workspace symbols".to_string(),
84				kind: None,
85				expr: rule.raw_expr.to_owned(),
86				expanded_expr: rule.expanded_expr.to_owned(),
87				message: rule.message.to_owned(),
88				rationale: rule.rationale.to_owned(),
89				require_doc_comment: None,
90			})
91			.collect();
92		group::append_group_specs(self, &mut specs);
93		path_rule::append_path_specs(self, &mut specs);
94		specs
95	}
96}
97
98#[derive(Clone, Debug)]
99pub struct WorkspaceSymbolViolation {
100	pub source: SourceId,
101	pub symbol: Option<code_moniker_workspace::snapshot::SymbolId>,
102	pub source_suppression: bool,
103	pub violation: Violation,
104}
105
106#[derive(Clone, Debug, Default)]
107pub struct WorkspaceEvaluation {
108	pub violations: Vec<WorkspaceSymbolViolation>,
109	pub violation_sets: BTreeMap<String, SymbolSet>,
110	pub groups: Vec<WorkspaceGroupResult>,
111	pub reports: Vec<RuleReport>,
112}
113
114#[derive(Debug, Error, Eq, PartialEq)]
115#[error(
116	"linkage snapshot targets index generation {linkage_index_generation:?}, current index is {index_generation:?}"
117)]
118pub struct WorkspaceLinkageError {
119	index_generation: ResourceGeneration,
120	linkage_index_generation: ResourceGeneration,
121}
122
123impl WorkspaceLinkageError {
124	pub fn index_generation(&self) -> ResourceGeneration {
125		self.index_generation
126	}
127
128	pub fn linkage_index_generation(&self) -> ResourceGeneration {
129		self.linkage_index_generation
130	}
131}
132
133pub(crate) use incremental::{WorkspaceIncrementalInput, evaluate_workspace_rules_incremental};
134
135pub fn compile_workspace_rules(
136	cfg: &Config,
137	scheme: &str,
138) -> Result<CompiledWorkspaceRules, ConfigError> {
139	let aliases = crate::check::config::resolve_aliases(&cfg.aliases)?;
140	let allowed = crate::check::config::allowed_workspace_kinds();
141	let mut symbol = Vec::with_capacity(cfg.workspace.symbol.rules.len());
142	for (index, entry) in cfg.workspace.symbol.rules.iter().enumerate() {
143		let id = entry.fallback_id(index);
144		let at = format!("workspace.symbol.{id}");
145		symbol.push(compile_symbol_rule(entry, at, scheme, &allowed, &aliases)?);
146	}
147	let group = group::compile_groups(cfg, scheme, &allowed, &aliases)?;
148	let path = path_rule::compile_paths(cfg, scheme, &allowed, &aliases)?;
149	Ok(CompiledWorkspaceRules {
150		symbol,
151		group,
152		path,
153		min_linkage_coverage: cfg.workspace.min_linkage_coverage.unwrap_or(100),
154	})
155}
156
157fn compile_symbol_rule(
158	entry: &RuleEntry,
159	at: String,
160	scheme: &str,
161	allowed_kinds: &[&str],
162	aliases: &std::collections::HashMap<String, String>,
163) -> Result<CompiledWorkspaceSymbolRule, ConfigError> {
164	let expanded = crate::check::config::substitute_aliases(&entry.expr, aliases, &at)?;
165	let parsed = match expr::parse(&expanded, scheme, allowed_kinds) {
166		Ok(parsed) => parsed,
167		Err(error) => {
168			return Err(ConfigError::InvalidExpr { at, error });
169		}
170	};
171	let (capabilities, plan) = classify_symbol_plan(&parsed.root, &at)?;
172	Ok(CompiledWorkspaceSymbolRule {
173		rule_id: at,
174		raw_expr: entry.expr.to_owned(),
175		expanded_expr: expanded,
176		root: parsed.root,
177		severity: entry.severity,
178		message: entry.message.to_owned(),
179		rationale: entry.rationale.to_owned(),
180		capabilities,
181		plan,
182	})
183}
184
185fn classify_symbol_plan(
186	node: &Node,
187	at: &str,
188) -> Result<(Vec<String>, WorkspaceRulePlan), ConfigError> {
189	let mut capabilities = BTreeSet::new();
190	let mut plan = WorkspaceRulePlan::Inventory;
191	collect_capabilities(node, at, &mut capabilities, &mut plan)?;
192	Ok((capabilities.into_iter().collect(), plan))
193}
194
195fn classify_t1(node: &Node, at: &str) -> Result<Vec<String>, ConfigError> {
196	let (capabilities, plan) = classify_symbol_plan(node, at)?;
197	if plan == WorkspaceRulePlan::Linkage {
198		return unsupported(at, "linkage.group");
199	}
200	Ok(capabilities)
201}
202
203fn collect_capabilities(
204	node: &Node,
205	at: &str,
206	capabilities: &mut BTreeSet<String>,
207	plan: &mut WorkspaceRulePlan,
208) -> Result<(), ConfigError> {
209	match node {
210		Node::Atom(atom) => collect_atom_capability(atom, at, capabilities, plan),
211		Node::And(nodes) | Node::Or(nodes) => {
212			for node in nodes {
213				collect_capabilities(node, at, capabilities, plan)?;
214			}
215			Ok(())
216		}
217		Node::Not(node) => collect_capabilities(node, at, capabilities, plan),
218		Node::Implies(left, right) => {
219			collect_capabilities(left, at, capabilities, plan)?;
220			collect_capabilities(right, at, capabilities, plan)
221		}
222		Node::Require(_) => unsupported(at, "inventory.require"),
223		Node::VerticalLayout(_) => unsupported(at, "local.vertical_layout"),
224		Node::Quantifier { .. } => unsupported(at, "inventory.quantifier"),
225	}
226}
227
228fn collect_atom_capability(
229	atom: &Atom,
230	at: &str,
231	capabilities: &mut BTreeSet<String>,
232	plan: &mut WorkspaceRulePlan,
233) -> Result<(), ConfigError> {
234	let LhsExpr::Attr(lhs) = &atom.lhs else {
235		return collect_linkage_count_capability(atom, at, capabilities, plan);
236	};
237	let facet = match lhs {
238		Lhs::Name => "name",
239		Lhs::Kind => "kind",
240		Lhs::Shape => "shape",
241		Lhs::Visibility => "visibility",
242		Lhs::Srcset => "srcset",
243		Lhs::Moniker => "uri",
244		other => return unsupported(at, &format!("projection.{}", other.as_str())),
245	};
246	let operation = match atom.op {
247		Op::Eq | Op::Ne => "exact",
248		Op::RegexMatch | Op::RegexNoMatch => "regex",
249		Op::PathMatch => "path",
250		other => return unsupported(at, &format!("operator.{other:?}")),
251	};
252	if atom.op == Op::PathMatch && *lhs != Lhs::Moniker {
253		return unsupported(at, "path.non-uri");
254	}
255	capabilities.insert(format!("{facet}.{operation}"));
256	Ok(())
257}
258
259fn collect_linkage_count_capability(
260	atom: &Atom,
261	at: &str,
262	capabilities: &mut BTreeSet<String>,
263	plan: &mut WorkspaceRulePlan,
264) -> Result<(), ConfigError> {
265	let LhsExpr::Number(NumberExpr::Count { domain, filter }) = &atom.lhs else {
266		return unsupported(at, "non-attribute-expression");
267	};
268	if filter.is_some() {
269		return unsupported(at, "linkage.filtered-count");
270	}
271	let domain = match domain {
272		Domain::InRefs => "in_refs",
273		Domain::OutRefs => "out_refs",
274		_ => return unsupported(at, "linkage.unsupported-domain"),
275	};
276	if !matches!(atom.rhs, Rhs::Number(NumberExpr::Literal(_)))
277		|| !matches!(atom.op, Op::Eq | Op::Ne | Op::Lt | Op::Le | Op::Gt | Op::Ge)
278	{
279		return unsupported(at, "linkage.count-comparison");
280	}
281	capabilities.insert(format!("{domain}.count"));
282	*plan = WorkspaceRulePlan::Linkage;
283	Ok(())
284}
285
286fn unsupported<T>(at: &str, capability: &str) -> Result<T, ConfigError> {
287	Err(ConfigError::UnsupportedWorkspaceExpr {
288		at: at.to_string(),
289		capability: capability.to_string(),
290	})
291}
292
293pub fn evaluate_workspace_rules(
294	inventory: &SymbolInventoryIndex,
295	compiled: &CompiledWorkspaceRules,
296	report: bool,
297) -> WorkspaceEvaluation {
298	evaluate_workspace_rules_in(inventory, inventory.all_symbols(), compiled, report)
299}
300
301pub fn evaluate_workspace_rules_linked(
302	index: &CodeIndex,
303	linkage: &LinkageSnapshot,
304	compiled: &CompiledWorkspaceRules,
305	report: bool,
306) -> Result<WorkspaceEvaluation, WorkspaceLinkageError> {
307	evaluate_workspace_rules_linked_in(
308		index,
309		linkage,
310		index.inventory.all_symbols(),
311		compiled,
312		report,
313	)
314}
315
316pub fn evaluate_workspace_rules_linked_in(
317	index: &CodeIndex,
318	linkage: &LinkageSnapshot,
319	universe: &SymbolSet,
320	compiled: &CompiledWorkspaceRules,
321	report: bool,
322) -> Result<WorkspaceEvaluation, WorkspaceLinkageError> {
323	if linkage.index_generation != index.generation {
324		return Err(WorkspaceLinkageError {
325			index_generation: index.generation,
326			linkage_index_generation: linkage.index_generation,
327		});
328	}
329	Ok(evaluate_workspace_rules_linked_in_current(
330		index, linkage, universe, compiled, report,
331	))
332}
333
334pub(crate) fn evaluate_workspace_rules_linked_in_current(
335	index: &CodeIndex,
336	linkage: &LinkageSnapshot,
337	universe: &SymbolSet,
338	compiled: &CompiledWorkspaceRules,
339	report: bool,
340) -> WorkspaceEvaluation {
341	let mut evaluation = evaluate_workspace_rules_in(&index.inventory, universe, compiled, report);
342	linkage::evaluate_linkage_rules(index, linkage, universe, compiled, report, &mut evaluation);
343	path_rule::evaluate_path_rules(index, linkage, universe, compiled, report, &mut evaluation);
344	sort_workspace_violations(&mut evaluation.violations);
345	evaluation
346}
347
348pub fn evaluate_workspace_rules_in(
349	inventory: &SymbolInventoryIndex,
350	universe: &SymbolSet,
351	compiled: &CompiledWorkspaceRules,
352	report: bool,
353) -> WorkspaceEvaluation {
354	let mut evaluation = WorkspaceEvaluation::default();
355	let mut atom_cache = FxHashMap::<String, SymbolSet>::default();
356	for rule in &compiled.symbol {
357		if rule.plan != WorkspaceRulePlan::Inventory {
358			continue;
359		}
360		let truth = eval_node(&rule.root, inventory, universe, &mut atom_cache);
361		let violations = universe.difference(&truth);
362		evaluation
363			.violation_sets
364			.insert(rule.rule_id.clone(), violations.clone());
365		if report {
366			let antecedent_truth = match &rule.root {
367				Node::Implies(antecedent, _) => {
368					Some(eval_node(antecedent, inventory, universe, &mut atom_cache))
369				}
370				_ => None,
371			};
372			let matches = antecedent_truth.as_ref().map_or_else(
373				|| truth.len(),
374				|antecedent| truth.intersection(antecedent).len(),
375			);
376			let antecedent_matches = antecedent_truth.as_ref().map(SymbolSet::len);
377			evaluation.reports.push(rule_report(
378				rule,
379				universe,
380				matches,
381				&violations,
382				antecedent_matches,
383			));
384		}
385		for ordinal in violations.iter() {
386			let Some(record) = inventory.record(ordinal) else {
387				continue;
388			};
389			let explanation = rule.message.as_deref().map(|message| {
390				render_template(
391					message,
392					&[
393						("name", record.name.as_ref()),
394						("kind", record.kind.as_ref()),
395						("moniker", record.identity.as_ref()),
396						("expr", &rule.raw_expr),
397					],
398				)
399			});
400			evaluation.violations.push(WorkspaceSymbolViolation {
401				source: record.source,
402				symbol: Some(record.id),
403				source_suppression: true,
404				violation: Violation {
405					rule_id: rule.rule_id.clone(),
406					severity: rule.severity,
407					moniker: record.identity.to_string(),
408					srcset: (!record.srcset.is_empty()).then(|| record.srcset.to_string()),
409					kind: record.kind.to_string(),
410					lines: record.line_range.unwrap_or((0, 0)),
411					message: format!(
412						"{} `{}` fails workspace assertion `{}`",
413						record.kind, record.name, rule.raw_expr
414					),
415					explanation,
416				},
417			});
418		}
419	}
420	group::evaluate_groups(
421		inventory,
422		universe,
423		compiled,
424		report,
425		&mut atom_cache,
426		&mut evaluation,
427	);
428	sort_workspace_violations(&mut evaluation.violations);
429	evaluation
430}
431
432fn sort_workspace_violations(violations: &mut [WorkspaceSymbolViolation]) {
433	violations.sort_by(|left, right| {
434		left.violation
435			.rule_id
436			.cmp(&right.violation.rule_id)
437			.then_with(|| left.violation.moniker.cmp(&right.violation.moniker))
438	});
439}
440
441fn rule_report(
442	rule: &CompiledWorkspaceSymbolRule,
443	universe: &SymbolSet,
444	matches: usize,
445	violations: &SymbolSet,
446	antecedent_matches: Option<usize>,
447) -> RuleReport {
448	RuleReport {
449		rule_id: rule.rule_id.clone(),
450		severity: rule.severity,
451		domain: "workspace symbols".to_string(),
452		evaluated: universe.len(),
453		matches,
454		violations: violations.len(),
455		antecedent_matches,
456		warning: None,
457		inconclusive: None,
458		verdict: None,
459		coverage: None,
460		path: None,
461	}
462}
463
464fn eval_node(
465	node: &Node,
466	inventory: &SymbolInventoryIndex,
467	universe: &SymbolSet,
468	atom_cache: &mut FxHashMap<String, SymbolSet>,
469) -> SymbolSet {
470	match node {
471		Node::Atom(atom) => {
472			if let Some(cached) = atom_cache.get(&atom.raw) {
473				return cached.to_owned();
474			}
475			let result = eval_atom(atom, inventory, universe);
476			atom_cache.insert(atom.raw.to_owned(), result.to_owned());
477			result
478		}
479		Node::And(nodes) => nodes
480			.iter()
481			.map(|node| eval_node(node, inventory, universe, atom_cache))
482			.reduce(|left, right| left.intersection(&right))
483			.unwrap_or_else(|| universe.clone()),
484		Node::Or(nodes) => nodes
485			.iter()
486			.map(|node| eval_node(node, inventory, universe, atom_cache))
487			.reduce(|left, right| left.union(&right))
488			.unwrap_or_default(),
489		Node::Not(node) => universe.difference(&eval_node(node, inventory, universe, atom_cache)),
490		Node::Implies(left, right) => universe
491			.difference(&eval_node(left, inventory, universe, atom_cache))
492			.union(&eval_node(right, inventory, universe, atom_cache)),
493		Node::Require(_) | Node::VerticalLayout(_) | Node::Quantifier { .. } => SymbolSet::new(),
494	}
495}
496
497fn eval_atom(atom: &Atom, inventory: &SymbolInventoryIndex, universe: &SymbolSet) -> SymbolSet {
498	let LhsExpr::Attr(lhs) = &atom.lhs else {
499		return SymbolSet::new();
500	};
501	match atom.op {
502		Op::Eq | Op::Ne => eval_exact(*lhs, &atom.rhs, atom.op, inventory, universe),
503		Op::RegexMatch | Op::RegexNoMatch => eval_regex(*lhs, atom, inventory, universe),
504		Op::PathMatch => eval_path(*lhs, &atom.rhs, inventory, universe),
505		_ => SymbolSet::new(),
506	}
507}
508
509fn eval_exact(
510	lhs: Lhs,
511	rhs: &Rhs,
512	op: Op,
513	inventory: &SymbolInventoryIndex,
514	universe: &SymbolSet,
515) -> SymbolSet {
516	let Rhs::Str(value) = rhs else {
517		return SymbolSet::new();
518	};
519	let matched = match lhs {
520		Lhs::Name => inventory.facets().symbols_by_name(value).cloned(),
521		Lhs::Kind => inventory.facets().symbols_by_kind(value).cloned(),
522		Lhs::Shape => inventory.facets().symbols_by_shape(value).cloned(),
523		Lhs::Visibility => inventory.facets().symbols_by_visibility(value).cloned(),
524		Lhs::Srcset => inventory.facets().symbols_by_srcset(value).cloned(),
525		Lhs::Moniker => inventory.facets().symbols_by_identity(value).cloned(),
526		_ => None,
527	}
528	.unwrap_or_default();
529	if op == Op::Ne {
530		universe.difference(&matched)
531	} else {
532		matched.intersection(universe)
533	}
534}
535
536fn eval_regex(
537	lhs: Lhs,
538	atom: &Atom,
539	inventory: &SymbolInventoryIndex,
540	universe: &SymbolSet,
541) -> SymbolSet {
542	let Some(regex) = atom.regex.as_ref() else {
543		return SymbolSet::new();
544	};
545	let matched = match lhs {
546		Lhs::Name => union_postings(inventory.facets().name_postings(), |value| {
547			regex.is_match(value)
548		}),
549		Lhs::Kind => union_postings(inventory.facets().kind_postings(), |value| {
550			regex.is_match(value)
551		}),
552		Lhs::Shape => union_postings(inventory.facets().shape_postings(), |value| {
553			regex.is_match(value)
554		}),
555		Lhs::Visibility => union_postings(inventory.facets().visibility_postings(), |value| {
556			regex.is_match(value)
557		}),
558		Lhs::Srcset => union_postings(inventory.facets().srcset_postings(), |value| {
559			regex.is_match(value)
560		}),
561		_ => SymbolSet::new(),
562	};
563	if atom.op == Op::RegexNoMatch {
564		universe.difference(&matched)
565	} else {
566		matched.intersection(universe)
567	}
568}
569
570fn union_postings<'a>(
571	postings: impl Iterator<Item = (&'a str, &'a SymbolSet)>,
572	matches: impl Fn(&str) -> bool,
573) -> SymbolSet {
574	let mut result = SymbolSet::new();
575	for (value, symbols) in postings {
576		if matches(value) {
577			result.union_with(symbols);
578		}
579	}
580	result
581}
582
583fn eval_path(
584	lhs: Lhs,
585	rhs: &Rhs,
586	inventory: &SymbolInventoryIndex,
587	universe: &SymbolSet,
588) -> SymbolSet {
589	if lhs != Lhs::Moniker {
590		return SymbolSet::new();
591	}
592	let Rhs::PathPattern(pattern) = rhs else {
593		return SymbolSet::new();
594	};
595	let candidates = exact_segment_candidates(pattern, inventory)
596		.map(|candidates| candidates.intersection(universe))
597		.unwrap_or_else(|| universe.clone());
598	candidates
599		.iter()
600		.filter(|ordinal| {
601			let Some(record) = inventory.record(*ordinal) else {
602				return false;
603			};
604			let segments = record
605				.segments
606				.iter()
607				.map(|segment| (segment.kind.as_ref(), segment.name.as_ref()))
608				.collect::<Vec<_>>();
609			path::matches_text_segments(pattern, &segments)
610		})
611		.collect()
612}
613
614fn exact_segment_candidates(
615	pattern: &path::Pattern,
616	inventory: &SymbolInventoryIndex,
617) -> Option<SymbolSet> {
618	pattern
619		.steps
620		.iter()
621		.filter_map(|step| match step {
622			Step::Literal { kind, name } => Some((
623				std::str::from_utf8(kind).ok()?,
624				std::str::from_utf8(name).ok()?,
625			)),
626			_ => None,
627		})
628		.filter_map(|(kind, name)| inventory.facets().symbols_by_segment(kind, name).cloned())
629		.reduce(|left, right| left.intersection(&right))
630}
631
632fn render_template(template: &str, values: &[(&str, &str)]) -> String {
633	let mut rendered = template.to_string();
634	for (name, value) in values {
635		rendered = rendered.replace(&format!("{{{name}}}"), value);
636	}
637	rendered
638}
639
640#[cfg(test)]
641mod tests {
642	use std::sync::Arc;
643
644	use code_moniker_workspace::snapshot::{
645		RecordTable, ResourceGeneration, SourceFileRecord, SymbolId, SymbolRecord,
646	};
647
648	use super::*;
649
650	fn fixture() -> SymbolInventoryIndex {
651		let sources = vec![
652			SourceFileRecord {
653				id: SourceId::at(0),
654				uri: "good".to_string(),
655				source_root: 0,
656				path: "src/main/java/acme/infra/GoodRepository.java".to_string(),
657				rel_path: "src/main/java/acme/infra/GoodRepository.java".to_string(),
658				anchor: "src/main/java/acme/infra/GoodRepository.java".to_string(),
659				language: "java".to_string(),
660				text: String::new(),
661			},
662			SourceFileRecord {
663				id: SourceId::at(1),
664				uri: "bad".to_string(),
665				source_root: 0,
666				path: "src/main/java/acme/domain/BadRepository.java".to_string(),
667				rel_path: "src/main/java/acme/domain/BadRepository.java".to_string(),
668				anchor: "src/main/java/acme/domain/BadRepository.java".to_string(),
669				language: "java".to_string(),
670				text: String::new(),
671			},
672			SourceFileRecord {
673				id: SourceId::at(2),
674				uri: "other".to_string(),
675				source_root: 0,
676				path: "src/main/java/acme/domain/Helper.java".to_string(),
677				rel_path: "src/main/java/acme/domain/Helper.java".to_string(),
678				anchor: "src/main/java/acme/domain/Helper.java".to_string(),
679				language: "java".to_string(),
680				text: String::new(),
681			},
682			SourceFileRecord {
683				id: SourceId::at(3),
684				uri: "test".to_string(),
685				source_root: 0,
686				path: "src/test/java/acme/TestHelper.java".to_string(),
687				rel_path: "src/test/java/acme/TestHelper.java".to_string(),
688				anchor: "src/test/java/acme/TestHelper.java".to_string(),
689				language: "java".to_string(),
690				text: String::new(),
691			},
692		];
693		let symbol = |file, name: &str, dir: &str, srcset: &str| {
694			let mut symbol =
695				SymbolRecord::new(SymbolId::at(file, 0), SourceId::at(file), name, "class");
696			symbol.identity = Arc::from(format!(
697				"code+moniker://./lang:java/srcset:{srcset}/package:acme/dir:{dir}/class:{name}"
698			));
699			symbol.line_range = Some((3, 3));
700			symbol
701		};
702		let symbols = RecordTable::from_shards(vec![
703			Arc::from(vec![symbol(0, "GoodRepository", "infra", "main")]),
704			Arc::from(vec![symbol(1, "BadRepository", "domain", "main")]),
705			Arc::from(vec![symbol(2, "Helper", "domain", "main")]),
706			Arc::from(vec![symbol(3, "TestHelper", "test", "test")]),
707		]);
708		SymbolInventoryIndex::build(ResourceGeneration::new(1), &sources, &symbols)
709	}
710
711	#[test]
712	fn implication_uses_active_universe_and_path_posting() {
713		let cfg = crate::check::config::load_from_str(
714			r#"
715			[[workspace.symbol.where]]
716			id = "repositories-under-infra"
717			expr = "name =~ Repository$ => uri ~ '**/dir:infra/**'"
718			"#,
719			"<test>",
720			Some(false),
721		)
722		.expect("config");
723		let compiled = compile_workspace_rules(&cfg, "code+moniker://").expect("workspace compile");
724		let result = evaluate_workspace_rules(&fixture(), &compiled, true);
725		assert_eq!(result.violations.len(), 1);
726		assert!(
727			result.violations[0]
728				.violation
729				.moniker
730				.ends_with("class:BadRepository")
731		);
732		assert_eq!(result.reports[0].antecedent_matches, Some(2));
733		assert_eq!(result.reports[0].matches, 1);
734	}
735
736	#[test]
737	fn srcset_uses_inventory_postings_as_a_workspace_facet() {
738		let cfg = crate::check::config::load_from_str(
739			r#"
740			[[workspace.symbol.where]]
741			id = "main-only"
742			expr = "srcset = 'main'"
743			"#,
744			"<test>",
745			Some(false),
746		)
747		.expect("config");
748		let compiled = compile_workspace_rules(&cfg, "code+moniker://").expect("workspace compile");
749		assert_eq!(
750			compiled.specs()[0].capabilities,
751			vec!["srcset.exact".to_string()]
752		);
753		let result = evaluate_workspace_rules(&fixture(), &compiled, true);
754		assert_eq!(result.violations.len(), 1);
755		assert!(
756			result.violations[0]
757				.violation
758				.moniker
759				.ends_with("class:TestHelper")
760		);
761	}
762
763	#[test]
764	fn java_inventory_keeps_package_segments_for_path_rules() {
765		let source = "package com.acme.infra;\n\npublic class GoodRepository {}\n";
766		let graph = code_moniker_workspace::environment::extract_source_with(
767			code_moniker_core::lang::Lang::Java,
768			source,
769			std::path::Path::new("src/main/java/com/acme/infra/GoodRepository.java"),
770			&code_moniker_workspace::environment::ExtractContext::default(),
771		);
772		let symbols = code_moniker_workspace::environment::symbol_records_for_graph(
773			0,
774			SourceId::at(0),
775			&graph,
776			source,
777			code_moniker_core::lang::Lang::Java,
778			"code+moniker://",
779		);
780		let source_record = SourceFileRecord {
781			id: SourceId::at(0),
782			uri: "good".to_string(),
783			source_root: 0,
784			path: "src/main/java/com/acme/infra/GoodRepository.java".to_string(),
785			rel_path: "src/main/java/com/acme/infra/GoodRepository.java".to_string(),
786			anchor: "src/main/java/com/acme/infra/GoodRepository.java".to_string(),
787			language: "java".to_string(),
788			text: String::new(),
789		};
790		let inventory = SymbolInventoryIndex::build(
791			ResourceGeneration::new(1),
792			&[source_record],
793			&RecordTable::from_shards(vec![Arc::from(symbols)]),
794		);
795		let repository = inventory
796			.all_symbols()
797			.iter()
798			.filter_map(|ordinal| inventory.record(ordinal))
799			.find(|record| record.kind.as_ref() == "class")
800			.expect("repository class");
801		assert!(
802			repository
803				.segments
804				.iter()
805				.any(|segment| segment.kind.as_ref() == "package"
806					&& segment.name.as_ref().ends_with("infra")),
807			"{repository:#?}"
808		);
809		let pattern = path::parse("**/package:infra/**").expect("package path pattern");
810		let segments = repository
811			.segments
812			.iter()
813			.map(|segment| (segment.kind.as_ref(), segment.name.as_ref()))
814			.collect::<Vec<_>>();
815		assert!(
816			path::matches_text_segments(&pattern, &segments),
817			"{segments:#?}"
818		);
819	}
820
821	#[test]
822	fn linkage_reference_counts_classify_as_t2() {
823		let cfg = crate::check::config::load_from_str(
824			r#"
825			[[workspace.symbol.where]]
826			id = "used-types"
827			expr = "shape = 'type' => count(in_refs) >= 1"
828			"#,
829			"<test>",
830			Some(false),
831		)
832		.expect("workspace linkage config");
833		let compiled =
834			compile_workspace_rules(&cfg, "code+moniker://").expect("workspace linkage plan");
835		let specs = compiled.specs();
836		assert_eq!(specs.len(), 1);
837		assert_eq!(specs[0].plan, "t2_linkage");
838		assert_eq!(
839			specs[0].capabilities,
840			vec!["in_refs.count".to_string(), "shape.exact".to_string()]
841		);
842	}
843}