Skip to main content

code_moniker_check/check/
command.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, OnceLock};
4use std::time::Instant;
5
6use code_moniker_core::core::code_graph::{CodeGraph, DefRecord};
7use code_moniker_core::lang::Lang;
8use code_moniker_workspace::code::{LocalCodeIndex, LocalCodeIndexOptions};
9use code_moniker_workspace::environment;
10use code_moniker_workspace::lang::path_to_lang;
11use code_moniker_workspace::linkage::{LinkagePort, LocalLinkage};
12use code_moniker_workspace::registry::{LocalWorkspaceOptions, LocalWorkspaceRegistry};
13use code_moniker_workspace::snapshot::{
14	ChangeOverlay, RecordTable, ResourceGeneration, SourceFileRecord, SourceId,
15	SymbolInventoryIndex, SymbolSet, WorkspaceRequest, WorkspaceSnapshot, WorkspaceTimings,
16	WorkspaceTransition,
17};
18use code_moniker_workspace::source::{
19	CodeIndexMaterial, IndexedSourceFile, LocalIdentityResolver, LocalResourceCache,
20};
21
22use crate::check;
23use crate::check::config::{self, RuleSeverity};
24use crate::check::eval::CompiledRuleSpec;
25use crate::check::expr::Domain;
26
27/// One scanned file's rule outcome: the suppression-filtered violations and,
28/// when `report` is requested, the per-rule observability counts.
29#[derive(Clone, Debug)]
30pub struct FileReport {
31	pub path: PathBuf,
32	pub violations: Vec<check::Violation>,
33	pub rule_reports: Vec<check::RuleReport>,
34}
35
36/// A per-file I/O or extraction failure, accumulated rather than aborting a
37/// project scan.
38#[derive(Clone, Debug)]
39pub struct FileError {
40	pub path: PathBuf,
41	pub error: String,
42}
43
44pub trait CheckWorkspace: Sync {
45	fn is_dir(&self, path: &Path) -> anyhow::Result<bool>;
46	fn read_to_string(&self, path: &Path) -> anyhow::Result<String>;
47	fn source_graph(
48		&self,
49		file: &environment::SourceFile,
50		ctx: &environment::ExtractContext,
51	) -> anyhow::Result<(String, CodeGraph)> {
52		let source = self.read_to_string(&file.path)?;
53		let graph = environment::extract_source_with(file.lang, &source, &file.anchor, ctx);
54		Ok((source, graph))
55	}
56	fn source_set(
57		&self,
58		root: &Path,
59		files: &[PathBuf],
60	) -> anyhow::Result<environment::SourceFileSet>;
61	fn source_catalog(&self, root: &Path) -> anyhow::Result<environment::SourceFileSet>;
62	fn exists(&self, path: &Path) -> bool;
63	fn linked_snapshot(
64		&self,
65		_source_set: &environment::SourceFileSet,
66		_scheme: &str,
67	) -> anyhow::Result<Option<Arc<WorkspaceSnapshot>>>;
68}
69
70#[derive(Clone, Copy, Debug, Default)]
71pub struct FsCheckWorkspace;
72
73impl CheckWorkspace for FsCheckWorkspace {
74	fn is_dir(&self, path: &Path) -> anyhow::Result<bool> {
75		let meta = std::fs::metadata(path)
76			.map_err(|e| anyhow::anyhow!("cannot stat {}: {e}", path.display()))?;
77		Ok(meta.is_dir())
78	}
79
80	fn read_to_string(&self, path: &Path) -> anyhow::Result<String> {
81		std::fs::read_to_string(path)
82			.map_err(|e| anyhow::anyhow!("cannot read {}: {e}", path.display()))
83	}
84
85	fn source_set(
86		&self,
87		root: &Path,
88		files: &[PathBuf],
89	) -> anyhow::Result<environment::SourceFileSet> {
90		if files.is_empty() {
91			environment::discover_sources(&[root.to_path_buf()], None)
92		} else {
93			environment::discover_source_files(root, files, None)
94		}
95	}
96
97	fn source_catalog(&self, root: &Path) -> anyhow::Result<environment::SourceFileSet> {
98		environment::discover_source_catalog(root, None)
99	}
100
101	fn exists(&self, path: &Path) -> bool {
102		path.exists()
103	}
104
105	fn linked_snapshot(
106		&self,
107		source_set: &environment::SourceFileSet,
108		scheme: &str,
109	) -> anyhow::Result<Option<Arc<WorkspaceSnapshot>>> {
110		build_fs_linked_snapshot(source_set, scheme).map(Some)
111	}
112}
113
114fn build_fs_linked_snapshot(
115	source_set: &environment::SourceFileSet,
116	scheme: &str,
117) -> anyhow::Result<Arc<WorkspaceSnapshot>> {
118	let paths = source_set
119		.roots
120		.iter()
121		.map(|root| root.input.clone())
122		.collect();
123	let options =
124		LocalWorkspaceOptions::new(paths, None).with_identity(LocalIdentityResolver::new(scheme));
125	let mut registry = LocalWorkspaceRegistry::local(options);
126	match registry
127		.commands()
128		.refresh(WorkspaceRequest::new("check-workspace-linkage"))
129	{
130		WorkspaceTransition::Ready { .. } => registry
131			.queries()
132			.snapshot_arc()
133			.ok_or_else(|| anyhow::anyhow!("workspace refresh completed without a snapshot")),
134		WorkspaceTransition::Failed { failure, .. } => {
135			anyhow::bail!("workspace linkage build failed: {}", failure.message)
136		}
137	}
138}
139
140#[derive(Clone)]
141pub struct IndexedCheckWorkspace {
142	root: PathBuf,
143	material: Arc<CodeIndexMaterial>,
144	snapshot: Arc<WorkspaceSnapshot>,
145}
146
147impl IndexedCheckWorkspace {
148	pub fn from_snapshot(
149		root: impl Into<PathBuf>,
150		cache: &LocalResourceCache,
151		snapshot: Arc<WorkspaceSnapshot>,
152	) -> anyhow::Result<Self> {
153		let material = indexed_material_for_snapshot(cache, &snapshot)?;
154		Ok(Self {
155			root: root.into(),
156			material,
157			snapshot,
158		})
159	}
160}
161
162fn indexed_material_for_snapshot(
163	cache: &LocalResourceCache,
164	snapshot: &WorkspaceSnapshot,
165) -> anyhow::Result<Arc<CodeIndexMaterial>> {
166	if snapshot.linkage.index_generation != snapshot.index.generation {
167		anyhow::bail!(
168			"indexed snapshot generation mismatch: linkage uses {}, index uses {}",
169			snapshot.linkage.index_generation.value(),
170			snapshot.index.generation.value()
171		);
172	}
173	let material = cache
174		.index_material(snapshot.index.generation)
175		.ok_or_else(|| {
176			anyhow::anyhow!(
177				"indexed source material is unavailable for generation {}",
178				snapshot.index.generation.value()
179			)
180		})?;
181	if material.identity.scheme() != snapshot.index.identity_scheme {
182		anyhow::bail!(
183			"indexed snapshot scheme mismatch: material uses {}, index uses {}",
184			material.identity.scheme(),
185			snapshot.index.identity_scheme
186		);
187	}
188	Ok(material)
189}
190
191fn indexed_file<'a>(
192	material: &'a CodeIndexMaterial,
193	root: &Path,
194	path: &Path,
195) -> Option<&'a IndexedSourceFile> {
196	let absolute = if path.is_absolute() {
197		path.to_path_buf()
198	} else {
199		root.join(path)
200	};
201	material.files.iter().find_map(|file| {
202		(file.path == path || file.path == absolute || file.rel_path == path || file.anchor == path)
203			.then_some(file.as_ref())
204	})
205}
206
207fn indexed_source_file<'a>(
208	material: &'a CodeIndexMaterial,
209	file: &environment::SourceFile,
210) -> Option<&'a IndexedSourceFile> {
211	let file_idx = material.source_set().files.iter().position(|candidate| {
212		candidate.source == file.source
213			&& candidate.path == file.path
214			&& candidate.rel_path == file.rel_path
215			&& candidate.anchor == file.anchor
216			&& candidate.lang == file.lang
217	})?;
218	let indexed = material.files.get(file_idx)?;
219	(indexed.source_root == file.source).then_some(indexed)
220}
221
222fn indexed_file_selected(
223	source_set: &environment::SourceFileSet,
224	root: &Path,
225	file: &environment::SourceFile,
226	requested: &[PathBuf],
227) -> bool {
228	if file.retired
229		|| source_set
230			.roots
231			.get(file.source)
232			.is_none_or(|source_root| source_root.input != root)
233	{
234		return false;
235	}
236	if requested.is_empty() {
237		return true;
238	}
239	requested.iter().any(|path| {
240		let absolute = if path.is_absolute() {
241			path.clone()
242		} else {
243			root.join(path)
244		};
245		file.path == *path
246			|| file.path == absolute
247			|| file.rel_path == *path
248			|| file.anchor == *path
249	})
250}
251
252fn ensure_indexed_root(expected: &Path, actual: &Path) -> anyhow::Result<()> {
253	if actual == expected {
254		return Ok(());
255	}
256	anyhow::bail!(
257		"indexed workspace root mismatch: expected {}, got {}",
258		expected.display(),
259		actual.display()
260	);
261}
262
263impl CheckWorkspace for IndexedCheckWorkspace {
264	fn is_dir(&self, path: &Path) -> anyhow::Result<bool> {
265		Ok(path == self.root)
266	}
267
268	fn read_to_string(&self, path: &Path) -> anyhow::Result<String> {
269		indexed_file(&self.material, &self.root, path)
270			.map(|file| file.source.clone())
271			.ok_or_else(|| anyhow::anyhow!("cannot read {}: not indexed", path.display()))
272	}
273
274	fn source_graph(
275		&self,
276		file: &environment::SourceFile,
277		_ctx: &environment::ExtractContext,
278	) -> anyhow::Result<(String, CodeGraph)> {
279		let indexed = indexed_source_file(&self.material, file)
280			.ok_or_else(|| anyhow::anyhow!("cannot read {}: not indexed", file.path.display()))?;
281		Ok((indexed.source.clone(), indexed.graph.clone()))
282	}
283
284	fn source_set(
285		&self,
286		root: &Path,
287		files: &[PathBuf],
288	) -> anyhow::Result<environment::SourceFileSet> {
289		ensure_indexed_root(&self.root, root)?;
290		let source_set = self.material.source_set();
291		Ok(environment::SourceFileSet {
292			roots: source_set.roots.clone(),
293			files: source_set
294				.files
295				.iter()
296				.filter(|file| indexed_file_selected(source_set, &self.root, file, files))
297				.cloned()
298				.collect(),
299			multi: source_set.multi,
300		})
301	}
302
303	fn source_catalog(&self, root: &Path) -> anyhow::Result<environment::SourceFileSet> {
304		self.source_set(root, &[])
305	}
306
307	fn exists(&self, path: &Path) -> bool {
308		indexed_file(&self.material, &self.root, path).is_some()
309	}
310
311	fn linked_snapshot(
312		&self,
313		_source_set: &environment::SourceFileSet,
314		scheme: &str,
315	) -> anyhow::Result<Option<Arc<WorkspaceSnapshot>>> {
316		if scheme != self.snapshot.index.identity_scheme {
317			anyhow::bail!(
318				"indexed workspace scheme mismatch: expected {}, got {scheme}",
319				self.snapshot.index.identity_scheme
320			);
321		}
322		Ok(Some(Arc::clone(&self.snapshot)))
323	}
324}
325
326#[derive(Clone, Debug)]
327pub struct MemoryCheckWorkspace {
328	root: PathBuf,
329	files: BTreeMap<PathBuf, MemorySourceFile>,
330}
331
332#[derive(Clone, Debug)]
333struct MemorySourceFile {
334	body: String,
335	lang: Lang,
336}
337
338impl MemoryCheckWorkspace {
339	pub fn new(root: impl Into<PathBuf>) -> Self {
340		Self {
341			root: root.into(),
342			files: BTreeMap::new(),
343		}
344	}
345
346	pub fn with_file(
347		mut self,
348		path: impl Into<PathBuf>,
349		body: impl Into<String>,
350		lang: Lang,
351	) -> Self {
352		self.files.insert(
353			normalize_relative(path.into()),
354			MemorySourceFile {
355				body: body.into(),
356				lang,
357			},
358		);
359		self
360	}
361
362	pub fn root(&self) -> &Path {
363		&self.root
364	}
365}
366
367impl CheckWorkspace for MemoryCheckWorkspace {
368	fn is_dir(&self, path: &Path) -> anyhow::Result<bool> {
369		Ok(path == self.root || path == Path::new("."))
370	}
371
372	fn read_to_string(&self, path: &Path) -> anyhow::Result<String> {
373		let rel = memory_rel_path(&self.root, path);
374		self.files
375			.get(&rel)
376			.map(|file| file.body.clone())
377			.ok_or_else(|| anyhow::anyhow!("cannot read {}: not found", path.display()))
378	}
379
380	fn source_set(
381		&self,
382		root: &Path,
383		files: &[PathBuf],
384	) -> anyhow::Result<environment::SourceFileSet> {
385		ensure_memory_root(&self.root, root)?;
386		Ok(environment::SourceFileSet {
387			roots: vec![memory_source_root(&self.root)],
388			files: memory_source_files(&self.root, &self.files, files),
389			multi: false,
390		})
391	}
392
393	fn source_catalog(&self, root: &Path) -> anyhow::Result<environment::SourceFileSet> {
394		self.source_set(root, &[])
395	}
396
397	fn exists(&self, path: &Path) -> bool {
398		let rel = memory_rel_path(&self.root, path);
399		self.files.contains_key(&rel)
400	}
401
402	fn linked_snapshot(
403		&self,
404		source_set: &environment::SourceFileSet,
405		scheme: &str,
406	) -> anyhow::Result<Option<Arc<WorkspaceSnapshot>>> {
407		build_memory_linked_snapshot(self, source_set, scheme).map(Some)
408	}
409}
410
411fn ensure_memory_root(expected: &Path, actual: &Path) -> anyhow::Result<()> {
412	if actual == expected {
413		return Ok(());
414	}
415	anyhow::bail!(
416		"memory workspace root mismatch: expected {}, got {}",
417		expected.display(),
418		actual.display()
419	);
420}
421
422fn build_memory_linked_snapshot(
423	workspace: &MemoryCheckWorkspace,
424	source_set: &environment::SourceFileSet,
425	scheme: &str,
426) -> anyhow::Result<Arc<WorkspaceSnapshot>> {
427	let identity = LocalIdentityResolver::new(scheme);
428	let files = memory_indexed_files(workspace, source_set, &identity)?;
429	let cache = LocalResourceCache::default();
430	let mut code_index = LocalCodeIndex::new(LocalCodeIndexOptions::default(), cache.clone());
431	let (catalog, index) = code_index
432		.build_index_from_extracted(source_set.clone(), identity, files)
433		.map_err(|failure| anyhow::anyhow!(failure.message))?;
434	let mut linker = LocalLinkage::new(cache);
435	let linkage = linker
436		.resolve_linkage(&index)
437		.map_err(|failure| anyhow::anyhow!(failure.message))?;
438	let changes = ChangeOverlay::new(
439		catalog.generation,
440		catalog.generation,
441		index.generation,
442		Vec::new(),
443	);
444	Ok(Arc::new(WorkspaceSnapshot {
445		generation: linkage.generation,
446		catalog,
447		index,
448		linkage,
449		changes,
450		timings: WorkspaceTimings::default(),
451	}))
452}
453
454fn memory_indexed_files(
455	workspace: &MemoryCheckWorkspace,
456	source_set: &environment::SourceFileSet,
457	identity: &LocalIdentityResolver,
458) -> anyhow::Result<Vec<IndexedSourceFile>> {
459	source_set
460		.files
461		.iter()
462		.enumerate()
463		.map(|(file_idx, file)| {
464			let source = workspace
465				.files
466				.get(&file.rel_path)
467				.ok_or_else(|| anyhow::anyhow!("cannot read {}: not found", file.path.display()))?;
468			let root = source_set
469				.roots
470				.get(file.source)
471				.ok_or_else(|| anyhow::anyhow!("source root {} is unavailable", file.source))?;
472			let ctx = file.extraction_context(root);
473			Ok(IndexedSourceFile {
474				source_root: file.source,
475				source_id: identity.source_id(file_idx, &file.rel_path),
476				source_uri: identity.source_uri(&file.rel_path),
477				identity: LocalIdentityResolver::new(identity.scheme()),
478				path: file.path.to_path_buf(),
479				rel_path: file.rel_path.to_path_buf(),
480				anchor: file.anchor.to_path_buf(),
481				lang: file.lang,
482				graph: environment::extract_source_with(
483					file.lang,
484					&source.body,
485					&file.anchor,
486					&ctx,
487				),
488				source: source.body.to_owned(),
489				extraction_cache: "provided",
490				extraction_duration: std::time::Duration::ZERO,
491			})
492		})
493		.collect()
494}
495
496fn memory_rel_path(root: &Path, path: &Path) -> PathBuf {
497	normalize_relative(path.strip_prefix(root).unwrap_or(path).to_path_buf())
498}
499
500fn memory_source_root(root: &Path) -> environment::SourceRoot {
501	environment::SourceRoot {
502		input: root.to_path_buf(),
503		path: root.to_path_buf(),
504		label: ".".to_string(),
505		ctx: environment::ExtractContext::default(),
506		source_groups: Default::default(),
507	}
508}
509
510fn memory_source_files(
511	root: &Path,
512	files: &BTreeMap<PathBuf, MemorySourceFile>,
513	requested: &[PathBuf],
514) -> Vec<environment::SourceFile> {
515	files
516		.iter()
517		.filter(|(path, _)| memory_file_selected(root, path, requested))
518		.map(|(rel_path, file)| environment::SourceFile {
519			source: 0,
520			path: root.join(rel_path),
521			rel_path: rel_path.clone(),
522			anchor: rel_path.clone(),
523			lang: file.lang,
524			root_moniker: environment::source_root_moniker(
525				file.lang,
526				rel_path,
527				&environment::ExtractContext::default(),
528			),
529			source_group: None,
530			srcset: None,
531			retired: false,
532		})
533		.collect()
534}
535
536fn memory_file_selected(root: &Path, path: &Path, requested: &[PathBuf]) -> bool {
537	requested.is_empty()
538		|| requested.iter().any(|candidate| {
539			let candidate = normalize_relative(candidate.clone());
540			candidate == path || normalize_relative(root.join(&candidate)) == path
541		})
542}
543
544/// How a consumer wants embedded default rules to participate in a ruleset.
545#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
546pub enum DefaultRulesSelection {
547	#[default]
548	Config,
549	Enabled,
550	Disabled,
551}
552
553impl DefaultRulesSelection {
554	pub fn from_override(value: Option<bool>) -> Self {
555		match value {
556			Some(true) => Self::Enabled,
557			Some(false) => Self::Disabled,
558			None => Self::Config,
559		}
560	}
561
562	pub fn as_override(self) -> Option<bool> {
563		match self {
564			Self::Config => None,
565			Self::Enabled => Some(true),
566			Self::Disabled => Some(false),
567		}
568	}
569}
570
571/// Ruleset construction contract shared by CLI, MCP, views, and agent integrations.
572#[derive(Clone, Debug, Eq, PartialEq)]
573pub struct RuleSetRequest {
574	pub rules: Option<PathBuf>,
575	pub inline_rules: Vec<String>,
576	pub default_rules: DefaultRulesSelection,
577	pub profile: Option<String>,
578	pub scheme: String,
579	pub project_root: Option<PathBuf>,
580}
581
582impl RuleSetRequest {
583	pub fn new(rules: Option<PathBuf>, scheme: impl Into<String>) -> Self {
584		Self {
585			rules,
586			inline_rules: Vec::new(),
587			default_rules: DefaultRulesSelection::Config,
588			profile: None,
589			scheme: scheme.into(),
590			project_root: None,
591		}
592	}
593
594	pub fn with_rules(rules: impl Into<PathBuf>, scheme: impl Into<String>) -> Self {
595		Self::new(Some(rules.into()), scheme)
596	}
597
598	pub fn with_default_rules(mut self, default_rules: DefaultRulesSelection) -> Self {
599		self.default_rules = default_rules;
600		self
601	}
602
603	pub fn with_inline_rules(mut self, inline_rules: Vec<String>) -> Self {
604		self.inline_rules = inline_rules;
605		self
606	}
607
608	pub fn with_profile(mut self, profile: Option<String>) -> Self {
609		self.profile = profile;
610		self
611	}
612
613	pub fn with_project_root(mut self, project_root: impl Into<PathBuf>) -> Self {
614		self.project_root = Some(project_root.into());
615		self
616	}
617
618	pub fn rules_path(&self) -> Option<&Path> {
619		self.rules.as_deref()
620	}
621
622	pub fn scheme(&self) -> &str {
623		&self.scheme
624	}
625
626	pub fn load_config(&self) -> anyhow::Result<check::Config> {
627		let mut cfg = if let Some(project_root) = &self.project_root {
628			config::load_project_with_cli_sources(
629				project_root,
630				self.rules_path(),
631				&self.inline_rules,
632				self.default_rules.as_override(),
633			)?
634		} else {
635			config::load_with_cli_sources(
636				self.rules_path(),
637				&self.inline_rules,
638				self.default_rules.as_override(),
639			)?
640		};
641		if let Some(profile) = &self.profile {
642			cfg.apply_profile(profile)?;
643		}
644		Ok(cfg)
645	}
646
647	pub fn compiled_specs_for_langs(
648		&self,
649		langs: impl IntoIterator<Item = Lang>,
650	) -> anyhow::Result<Vec<CompiledRuleSpec>> {
651		let cfg = self.load_config()?;
652		compiled_specs_with_config(&cfg, langs, &self.scheme)
653	}
654
655	pub fn check_source(
656		&self,
657		source: &str,
658		anchor: &Path,
659		lang: Lang,
660		report: bool,
661	) -> anyhow::Result<SourceReport> {
662		let cfg = self.load_config()?;
663		check_source_with_config(&cfg, source, anchor, lang, &self.scheme, report)
664	}
665}
666
667/// Executable check request over either a file, a project root, or a filtered
668/// set of project-relative files.
669#[derive(Clone, Debug, Eq, PartialEq)]
670pub struct CheckRequest {
671	pub path: PathBuf,
672	pub rules: RuleSetRequest,
673	pub report: bool,
674	pub files: Vec<PathBuf>,
675}
676
677impl CheckRequest {
678	pub fn new(path: impl Into<PathBuf>, rules: RuleSetRequest) -> Self {
679		let path = path.into();
680		Self {
681			rules: rules.with_project_root(path.clone()),
682			path,
683			report: false,
684			files: Vec::new(),
685		}
686	}
687
688	pub fn with_report(mut self, report: bool) -> Self {
689		self.report = report;
690		self
691	}
692
693	pub fn with_files(mut self, files: Vec<PathBuf>) -> Self {
694		self.files = files;
695		self
696	}
697
698	pub fn run(&self) -> anyhow::Result<CheckRun> {
699		self.run_with_workspace(&FsCheckWorkspace)
700	}
701
702	pub fn run_with_workspace(&self, workspace: &dyn CheckWorkspace) -> anyhow::Result<CheckRun> {
703		let started = Instant::now();
704		let cfg = self.rules.load_config()?;
705		let (reports, errors, skip_reason) = if workspace.is_dir(&self.path)? {
706			self.run_directory(&cfg, workspace)?
707		} else {
708			self.run_single_file(&cfg, workspace)?
709		};
710		Ok(CheckRun {
711			reports,
712			errors,
713			elapsed_ms: started.elapsed().as_millis().try_into().unwrap_or(u64::MAX),
714			skip_reason,
715		})
716	}
717
718	fn run_directory(
719		&self,
720		cfg: &check::Config,
721		workspace: &dyn CheckWorkspace,
722	) -> anyhow::Result<(Vec<FileReport>, Vec<FileError>, Option<CheckSkipReason>)> {
723		let (reports, errors) = if self.files.is_empty() {
724			check_project_workspace(&self.path, cfg, self.rules.scheme(), self.report, workspace)?
725		} else {
726			check_project_files_workspace(
727				&self.path,
728				&self.files,
729				cfg,
730				self.rules.scheme(),
731				self.report,
732				workspace,
733			)?
734		};
735		let skip_reason = if !self.files.is_empty() && reports.is_empty() && errors.is_empty() {
736			Some(CheckSkipReason::NoMatchingFiles)
737		} else {
738			None
739		};
740		Ok((reports, errors, skip_reason))
741	}
742
743	fn run_single_file(
744		&self,
745		cfg: &check::Config,
746		workspace: &dyn CheckWorkspace,
747	) -> anyhow::Result<(Vec<FileReport>, Vec<FileError>, Option<CheckSkipReason>)> {
748		if !self.files.is_empty() {
749			anyhow::bail!("--file can only be used when check PATH is a directory");
750		}
751		let excluded = path_excluded(&self.path, cfg);
752		match check_one_file_workspace(
753			&self.path,
754			cfg,
755			self.rules.scheme(),
756			self.report,
757			workspace,
758		)? {
759			Some(report) => Ok((vec![report], Vec::new(), None)),
760			None if excluded => Ok((
761				Vec::new(),
762				Vec::new(),
763				Some(CheckSkipReason::ExcludedSingleFile),
764			)),
765			None => Ok((
766				Vec::new(),
767				Vec::new(),
768				Some(CheckSkipReason::UnsupportedSingleFile),
769			)),
770		}
771	}
772}
773
774/// Empty-scan reason. Renderers use it to preserve silent text hooks while
775/// still allowing structured JSON for intentionally empty scans.
776#[derive(Clone, Copy, Debug, Eq, PartialEq)]
777pub enum CheckSkipReason {
778	ExcludedSingleFile,
779	UnsupportedSingleFile,
780	NoMatchingFiles,
781}
782
783/// Structured result of a check request. It contains no terminal formatting or
784/// process exit policy.
785#[derive(Clone, Debug)]
786pub struct CheckRun {
787	pub reports: Vec<FileReport>,
788	pub errors: Vec<FileError>,
789	pub elapsed_ms: u64,
790	pub skip_reason: Option<CheckSkipReason>,
791}
792
793impl CheckRun {
794	pub fn any_error_violation(&self) -> bool {
795		self.reports.iter().any(|report| {
796			report
797				.violations
798				.iter()
799				.any(|violation| violation.severity.is_error())
800		})
801	}
802
803	pub fn any_error(&self) -> bool {
804		!self.errors.is_empty()
805	}
806
807	pub fn violation_counts(&self) -> ViolationCounts {
808		violation_counts(&self.reports)
809	}
810
811	pub fn summary(&self) -> CheckSummary {
812		let counts = self.violation_counts();
813		CheckSummary {
814			files_scanned: self.reports.len(),
815			files_with_violations: counts.files_with,
816			total_violations: counts.total,
817			total_rule_errors: counts.errors,
818			total_warnings: counts.warnings,
819			files_with_errors: self.errors.len(),
820			total_errors: self.errors.len(),
821			elapsed_ms: self.elapsed_ms,
822			failed_rules: self.failed_rule_summary(),
823			violations_by_srcset: self.violations_by_srcset(),
824		}
825	}
826
827	pub fn failed_rule_summary(&self) -> Vec<FailedRuleSummary> {
828		failed_rule_summary(&self.reports)
829	}
830
831	pub fn violations_by_srcset(&self) -> std::collections::BTreeMap<String, usize> {
832		violations_by_srcset(&self.reports)
833	}
834
835	pub fn file_violations(&self) -> impl Iterator<Item = (&Path, &check::eval::Violation)> {
836		self.reports.iter().flat_map(|report| {
837			report
838				.violations
839				.iter()
840				.map(move |violation| (report.path.as_path(), violation))
841		})
842	}
843
844	pub fn error_summaries(&self) -> impl Iterator<Item = (&Path, &str)> {
845		self.errors
846			.iter()
847			.map(|error| (error.path.as_path(), error.error.as_str()))
848	}
849
850	pub fn rule_violation_totals(&self) -> std::collections::BTreeMap<&str, usize> {
851		let mut totals = std::collections::BTreeMap::new();
852		for report in &self.reports {
853			for rule in &report.rule_reports {
854				*totals.entry(rule.rule_id.as_str()).or_insert(0usize) += rule.violations;
855			}
856		}
857		totals
858	}
859}
860
861/// Serializable aggregate counters for renderers and machine consumers.
862#[derive(Clone, Debug, serde::Serialize)]
863pub struct CheckSummary {
864	pub files_scanned: usize,
865	pub files_with_violations: usize,
866	pub total_violations: usize,
867	pub total_rule_errors: usize,
868	pub total_warnings: usize,
869	pub files_with_errors: usize,
870	pub total_errors: usize,
871	pub elapsed_ms: u64,
872	pub failed_rules: Vec<FailedRuleSummary>,
873	#[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
874	pub violations_by_srcset: std::collections::BTreeMap<String, usize>,
875}
876
877/// Per-rule failure count, sorted by severity and volume by [`CheckRun`].
878#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
879pub struct FailedRuleSummary {
880	pub rule_id: String,
881	pub severity: RuleSeverity,
882	pub violations: usize,
883}
884
885/// Count of suppression-filtered violations in a check result.
886#[derive(Clone, Debug, Default, Eq, PartialEq)]
887pub struct ViolationCounts {
888	pub total: usize,
889	pub errors: usize,
890	pub warnings: usize,
891	pub files_with: usize,
892}
893
894/// Rules and violations from evaluating one in-memory source.
895#[derive(Clone, Debug)]
896pub struct SourceReport {
897	pub rules: Vec<CompiledRuleSpec>,
898	pub violations: Vec<check::Violation>,
899	pub rule_reports: Vec<check::RuleReport>,
900}
901
902pub fn check_source_with_config(
903	cfg: &check::Config,
904	source: &str,
905	anchor: &Path,
906	lang: Lang,
907	scheme: &str,
908	report: bool,
909) -> anyhow::Result<SourceReport> {
910	let graph = environment::extract_source_with(
911		lang,
912		source,
913		anchor,
914		&environment::ExtractContext::default(),
915	);
916	check_graph_with_config(cfg, &graph, source, lang, scheme, report)
917}
918
919pub fn check_graph_with_config(
920	cfg: &check::Config,
921	graph: &CodeGraph,
922	source: &str,
923	lang: Lang,
924	scheme: &str,
925	report: bool,
926) -> anyhow::Result<SourceReport> {
927	let compiled = check::compile_rules(cfg, lang, scheme)?;
928	let raw = check::evaluate_compiled(graph, source, lang, scheme, &compiled);
929	let violations = check::apply_suppressions(graph, source, raw);
930	let rule_reports = if report {
931		let mut rule_reports = check::rule_report_compiled(graph, source, lang, scheme, &compiled);
932		align_report_violations_with_suppressions(&mut rule_reports, &violations);
933		rule_reports
934	} else {
935		Vec::new()
936	};
937	Ok(SourceReport {
938		rules: compiled.specs(lang),
939		violations,
940		rule_reports,
941	})
942}
943
944pub fn compiled_specs_with_config(
945	cfg: &check::Config,
946	langs: impl IntoIterator<Item = Lang>,
947	scheme: &str,
948) -> anyhow::Result<Vec<CompiledRuleSpec>> {
949	let mut specs = crate::check::workspace_eval::compile_workspace_rules(cfg, scheme)?.specs();
950	for lang in langs {
951		let compiled = check::compile_rules(cfg, lang, scheme)?;
952		specs.extend(compiled.specs(lang));
953	}
954	specs.sort_by(|a, b| a.rule_id.cmp(&b.rule_id));
955	Ok(specs)
956}
957
958pub fn check_one_file(
959	path: &Path,
960	cfg: &check::Config,
961	scheme: &str,
962	report: bool,
963) -> anyhow::Result<Option<FileReport>> {
964	check_one_file_workspace(path, cfg, scheme, report, &FsCheckWorkspace)
965}
966
967pub fn check_one_file_workspace(
968	path: &Path,
969	cfg: &check::Config,
970	scheme: &str,
971	report: bool,
972	workspace: &dyn CheckWorkspace,
973) -> anyhow::Result<Option<FileReport>> {
974	let Ok(lang) = path_to_lang(path) else {
975		return Ok(None);
976	};
977	let excludes = check::UriExclusionMatcher::new(&cfg.exclude.uris);
978	if excludes.matches_path(path) {
979		return Ok(None);
980	}
981	let compiled = check::compile_rules(cfg, lang, scheme)?;
982	let ctx = CompiledCheck {
983		scheme,
984		compiled: &compiled,
985		report,
986		workspace,
987		requirements: None,
988	};
989	check_one_compiled(path, None, lang, &ctx).map(Some)
990}
991
992struct CompiledCheck<'a> {
993	scheme: &'a str,
994	compiled: &'a check::CompiledRules,
995	report: bool,
996	workspace: &'a dyn CheckWorkspace,
997	requirements: Option<&'a dyn check::RequirementResolver>,
998}
999
1000/// `moniker_anchor` overrides the path passed to the extractor - used by
1001/// project mode to anchor each file's moniker on its path relative to the
1002/// scan root. `None` means "same as `fs_path`" (single-file mode).
1003fn check_one_compiled(
1004	fs_path: &Path,
1005	moniker_anchor: Option<&Path>,
1006	lang: code_moniker_core::lang::Lang,
1007	ctx: &CompiledCheck<'_>,
1008) -> anyhow::Result<FileReport> {
1009	let source = ctx.workspace.read_to_string(fs_path)?;
1010	let graph = environment::extract_source_with(
1011		lang,
1012		&source,
1013		moniker_anchor.unwrap_or(fs_path),
1014		&environment::ExtractContext::default(),
1015	);
1016	let raw = check::evaluate_compiled(&graph, &source, lang, ctx.scheme, ctx.compiled);
1017	let violations = check::apply_suppressions(&graph, &source, raw);
1018	let rule_reports = if ctx.report {
1019		let mut rule_reports =
1020			check::rule_report_compiled(&graph, &source, lang, ctx.scheme, ctx.compiled);
1021		align_report_violations_with_suppressions(&mut rule_reports, &violations);
1022		rule_reports
1023	} else {
1024		Vec::new()
1025	};
1026	Ok(FileReport {
1027		path: fs_path.to_path_buf(),
1028		violations,
1029		rule_reports,
1030	})
1031}
1032
1033struct CheckedSourceFile {
1034	file: environment::SourceFile,
1035	source: String,
1036	graph: CodeGraph,
1037	report: FileReport,
1038}
1039
1040fn check_source_file_compiled(
1041	file: &environment::SourceFile,
1042	ctx: &environment::ExtractContext,
1043	check_ctx: &CompiledCheck<'_>,
1044) -> anyhow::Result<CheckedSourceFile> {
1045	let (source, graph) = check_ctx.workspace.source_graph(file, ctx)?;
1046	let raw = check::evaluate_compiled_with_requirements(
1047		&graph,
1048		&source,
1049		file.lang,
1050		check_ctx.scheme,
1051		check_ctx.compiled,
1052		check_ctx.requirements,
1053	);
1054	let violations = check::apply_suppressions(&graph, &source, raw);
1055	let rule_reports = if check_ctx.report {
1056		let mut rule_reports = check::rule_report_compiled_with_requirements(
1057			&graph,
1058			&source,
1059			file.lang,
1060			check_ctx.scheme,
1061			check_ctx.compiled,
1062			check_ctx.requirements,
1063		);
1064		align_report_violations_with_suppressions(&mut rule_reports, &violations);
1065		rule_reports
1066	} else {
1067		Vec::new()
1068	};
1069	Ok(CheckedSourceFile {
1070		file: file.clone(),
1071		source,
1072		graph,
1073		report: FileReport {
1074			path: file.path.clone(),
1075			violations,
1076			rule_reports,
1077		},
1078	})
1079}
1080
1081/// Project-mode scan. Per-file I/O errors are accumulated in `Vec<FileError>`
1082/// rather than aborting the scan. Rules are compiled once per language and
1083/// shared across the parallel pool.
1084pub fn check_project(
1085	root: &Path,
1086	cfg: &check::Config,
1087	scheme: &str,
1088	report: bool,
1089) -> anyhow::Result<(Vec<FileReport>, Vec<FileError>)> {
1090	check_project_workspace(root, cfg, scheme, report, &FsCheckWorkspace)
1091}
1092
1093pub fn check_project_workspace(
1094	root: &Path,
1095	cfg: &check::Config,
1096	scheme: &str,
1097	report: bool,
1098	workspace: &dyn CheckWorkspace,
1099) -> anyhow::Result<(Vec<FileReport>, Vec<FileError>)> {
1100	let source_set = workspace.source_set(root, &[])?;
1101	let requirements = FileRequirementResolver::new(
1102		root.to_path_buf(),
1103		filtered_source_set(&source_set, cfg),
1104		&cfg.exclude.uris,
1105		workspace,
1106	);
1107	check_source_set(
1108		&source_set,
1109		cfg,
1110		scheme,
1111		Some(&requirements),
1112		workspace,
1113		SourceSetCheckMode {
1114			report,
1115			workspace_rules: true,
1116		},
1117	)
1118}
1119
1120pub fn check_project_files(
1121	root: &Path,
1122	files: &[PathBuf],
1123	cfg: &check::Config,
1124	scheme: &str,
1125	report: bool,
1126) -> anyhow::Result<(Vec<FileReport>, Vec<FileError>)> {
1127	check_project_files_workspace(root, files, cfg, scheme, report, &FsCheckWorkspace)
1128}
1129
1130pub fn check_project_files_workspace(
1131	root: &Path,
1132	files: &[PathBuf],
1133	cfg: &check::Config,
1134	scheme: &str,
1135	report: bool,
1136	workspace: &dyn CheckWorkspace,
1137) -> anyhow::Result<(Vec<FileReport>, Vec<FileError>)> {
1138	let source_set = workspace.source_set(root, files)?;
1139	let requirements =
1140		FileRequirementResolver::new(root.to_path_buf(), None, &cfg.exclude.uris, workspace);
1141	let (reports, mut errors) = check_source_set(
1142		&source_set,
1143		cfg,
1144		scheme,
1145		Some(&requirements),
1146		workspace,
1147		SourceSetCheckMode {
1148			report,
1149			workspace_rules: false,
1150		},
1151	)?;
1152	if !cfg.workspace.symbol.rules.is_empty()
1153		|| !cfg.workspace.group.rules.is_empty()
1154		|| !cfg.workspace.path.is_empty()
1155	{
1156		errors.push(FileError {
1157			path: root.to_path_buf(),
1158			error: "workspace rules were not run: a file-scoped check does not provide a complete symbol inventory"
1159				.to_string(),
1160		});
1161	}
1162	if let Some(error) = requirements.source_catalog_error() {
1163		errors.push(FileError {
1164			path: root.to_path_buf(),
1165			error: error.to_string(),
1166		});
1167		errors.sort_by(|a, b| a.path.cmp(&b.path));
1168	}
1169	Ok((reports, errors))
1170}
1171
1172fn filtered_source_set(
1173	source_set: &environment::SourceFileSet,
1174	cfg: &check::Config,
1175) -> environment::SourceFileSet {
1176	let excludes = check::UriExclusionMatcher::new(&cfg.exclude.uris);
1177	filter_source_set(source_set, &excludes)
1178}
1179
1180fn filter_source_set(
1181	source_set: &environment::SourceFileSet,
1182	excludes: &check::UriExclusionMatcher,
1183) -> environment::SourceFileSet {
1184	environment::SourceFileSet {
1185		roots: source_set.roots.clone(),
1186		files: source_set
1187			.files
1188			.iter()
1189			.filter(|file| !excludes.matches_path(&file.path))
1190			.cloned()
1191			.collect(),
1192		multi: source_set.multi,
1193	}
1194}
1195
1196#[derive(Clone, Copy)]
1197struct SourceSetCheckMode {
1198	report: bool,
1199	workspace_rules: bool,
1200}
1201
1202fn check_source_set(
1203	source_set: &environment::SourceFileSet,
1204	cfg: &check::Config,
1205	scheme: &str,
1206	requirements: Option<&dyn check::RequirementResolver>,
1207	workspace: &dyn CheckWorkspace,
1208	mode: SourceSetCheckMode,
1209) -> anyhow::Result<(Vec<FileReport>, Vec<FileError>)> {
1210	use rayon::prelude::*;
1211	use std::collections::HashMap;
1212	let excludes = check::UriExclusionMatcher::new(&cfg.exclude.uris);
1213	let mut compiled: HashMap<code_moniker_core::lang::Lang, check::CompiledRules> = HashMap::new();
1214	let files: Vec<&environment::SourceFile> = source_set
1215		.files
1216		.iter()
1217		.filter(|f| !excludes.matches_path(&f.path))
1218		.collect();
1219	for f in &files {
1220		if compiled.contains_key(&f.lang) {
1221			continue;
1222		}
1223		compiled.insert(f.lang, check::compile_rules(cfg, f.lang, scheme)?);
1224	}
1225	let outcomes: Vec<Result<CheckedSourceFile, FileError>> = files
1226		.par_iter()
1227		.map(|f| {
1228			let f = *f;
1229			let rules = &compiled[&f.lang];
1230			let ctx = &source_set.roots[f.source].ctx;
1231			let check_ctx = CompiledCheck {
1232				scheme,
1233				compiled: rules,
1234				report: mode.report,
1235				workspace,
1236				requirements,
1237			};
1238			check_source_file_compiled(f, ctx, &check_ctx).map_err(|e| FileError {
1239				path: f.path.clone(),
1240				error: format!("{e:#}"),
1241			})
1242		})
1243		.collect();
1244	let mut checked = Vec::new();
1245	let mut errors = Vec::new();
1246	for o in outcomes {
1247		match o {
1248			Ok(r) => checked.push(r),
1249			Err(e) => errors.push(e),
1250		}
1251	}
1252	checked.sort_by(|a, b| a.report.path.cmp(&b.report.path));
1253	if mode.workspace_rules && errors.is_empty() {
1254		apply_workspace_rules(
1255			&mut checked,
1256			source_set,
1257			cfg,
1258			scheme,
1259			mode.report,
1260			workspace,
1261		)?;
1262	}
1263	let reports = checked.into_iter().map(|checked| checked.report).collect();
1264	errors.sort_by(|a, b| a.path.cmp(&b.path));
1265	Ok((reports, errors))
1266}
1267
1268fn apply_workspace_rules(
1269	checked: &mut [CheckedSourceFile],
1270	source_set: &environment::SourceFileSet,
1271	cfg: &check::Config,
1272	scheme: &str,
1273	report: bool,
1274	workspace: &dyn CheckWorkspace,
1275) -> anyhow::Result<()> {
1276	let compiled = crate::check::workspace_eval::compile_workspace_rules(cfg, scheme)?;
1277	if compiled.is_empty() {
1278		return Ok(());
1279	}
1280	let (mut evaluation, source_map) = if compiled.has_linkage_rules() {
1281		let snapshot = workspace
1282			.linked_snapshot(source_set, scheme)?
1283			.ok_or_else(|| {
1284				anyhow::anyhow!(
1285					"workspace linkage rules were not run: this check workspace does not provide a linked snapshot"
1286				)
1287			})?;
1288		let source_map = linked_source_map(&snapshot, checked);
1289		let universe = linked_symbol_universe(&snapshot, &source_map);
1290		let evaluation = crate::check::workspace_eval::evaluate_workspace_rules_linked_in(
1291			&snapshot.index,
1292			&snapshot.linkage,
1293			&universe,
1294			&compiled,
1295			report,
1296		)?;
1297		(evaluation, Some(source_map))
1298	} else {
1299		let generation = ResourceGeneration::new(0);
1300		let sources = workspace_source_records(checked);
1301		let symbols = workspace_symbol_records(checked, scheme);
1302		let inventory = SymbolInventoryIndex::build(generation, &sources, &symbols);
1303		(
1304			crate::check::workspace_eval::evaluate_workspace_rules(&inventory, &compiled, report),
1305			None,
1306		)
1307	};
1308	if let Some(source_map) = &source_map {
1309		evaluation.violations.retain_mut(|violation| {
1310			let Some(checked_idx) = source_map.get(&violation.source.file()) else {
1311				return false;
1312			};
1313			violation.source = SourceId::at(*checked_idx);
1314			true
1315		});
1316	}
1317	let kept_by_rule = merge_workspace_violations(checked, evaluation.violations);
1318	for rule_report in &mut evaluation.reports {
1319		rule_report.violations = kept_by_rule
1320			.get(&rule_report.rule_id)
1321			.copied()
1322			.unwrap_or_default();
1323		if rule_report.verdict.is_some() {
1324			rule_report.verdict = Some(if rule_report.violations > 0 {
1325				crate::RuleVerdict::Fail
1326			} else if rule_report.inconclusive.unwrap_or_default() > 0 {
1327				crate::RuleVerdict::Inconclusive
1328			} else {
1329				crate::RuleVerdict::Pass
1330			});
1331		}
1332	}
1333	if report && let Some(first) = checked.first_mut() {
1334		first.report.rule_reports.extend(evaluation.reports);
1335		first
1336			.report
1337			.rule_reports
1338			.sort_by(|left, right| left.rule_id.cmp(&right.rule_id));
1339	}
1340	Ok(())
1341}
1342
1343fn linked_source_map(
1344	snapshot: &WorkspaceSnapshot,
1345	checked: &[CheckedSourceFile],
1346) -> std::collections::HashMap<usize, usize> {
1347	use std::collections::HashMap;
1348
1349	let by_path = checked
1350		.iter()
1351		.enumerate()
1352		.map(|(idx, checked)| (checked.file.path.clone(), idx))
1353		.collect::<HashMap<_, _>>();
1354	let by_root_relative = checked
1355		.iter()
1356		.enumerate()
1357		.map(|(idx, checked)| ((checked.file.source, checked.file.rel_path.clone()), idx))
1358		.collect::<HashMap<_, _>>();
1359	snapshot
1360		.index
1361		.sources
1362		.iter()
1363		.filter_map(|source| {
1364			let checked_idx = by_path
1365				.get(Path::new(&source.path))
1366				.or_else(|| {
1367					by_root_relative.get(&(source.source_root, PathBuf::from(&source.rel_path)))
1368				})
1369				.copied()?;
1370			Some((source.id.file(), checked_idx))
1371		})
1372		.collect()
1373}
1374
1375fn linked_symbol_universe(
1376	snapshot: &WorkspaceSnapshot,
1377	source_map: &std::collections::HashMap<usize, usize>,
1378) -> SymbolSet {
1379	let mut universe = SymbolSet::new();
1380	for source_idx in source_map.keys() {
1381		if let Some(symbols) = snapshot
1382			.index
1383			.inventory
1384			.facets()
1385			.symbols_by_source(SourceId::at(*source_idx))
1386		{
1387			universe.union_with(symbols);
1388		}
1389	}
1390	universe
1391}
1392
1393fn workspace_source_records(checked: &[CheckedSourceFile]) -> Vec<SourceFileRecord> {
1394	checked
1395		.iter()
1396		.enumerate()
1397		.map(|(file_idx, checked)| SourceFileRecord {
1398			id: SourceId::at(file_idx),
1399			uri: checked.file.path.display().to_string(),
1400			source_root: checked.file.source,
1401			path: checked.file.path.display().to_string(),
1402			rel_path: checked.file.rel_path.display().to_string(),
1403			anchor: checked.file.anchor.display().to_string(),
1404			language: checked.file.lang.tag().to_string(),
1405			text: String::new(),
1406		})
1407		.collect()
1408}
1409
1410fn workspace_symbol_records(
1411	checked: &[CheckedSourceFile],
1412	scheme: &str,
1413) -> RecordTable<code_moniker_workspace::snapshot::SymbolRecord> {
1414	let shards = checked
1415		.iter()
1416		.enumerate()
1417		.map(|(file_idx, checked)| {
1418			Arc::from(environment::symbol_records_for_graph(
1419				file_idx,
1420				SourceId::at(file_idx),
1421				&checked.graph,
1422				&checked.source,
1423				checked.file.lang,
1424				scheme,
1425			))
1426		})
1427		.collect();
1428	RecordTable::from_shards(shards)
1429}
1430
1431fn merge_workspace_violations(
1432	checked: &mut [CheckedSourceFile],
1433	violations: Vec<crate::check::workspace_eval::WorkspaceSymbolViolation>,
1434) -> BTreeMap<String, usize> {
1435	let mut by_source =
1436		BTreeMap::<usize, Vec<crate::check::workspace_eval::WorkspaceSymbolViolation>>::new();
1437	for workspace_violation in violations {
1438		by_source
1439			.entry(workspace_violation.source.file())
1440			.or_default()
1441			.push(workspace_violation);
1442	}
1443	let mut kept_by_rule = BTreeMap::new();
1444	for (file_idx, workspace_violations) in by_source {
1445		let Some(checked) = checked.get_mut(file_idx) else {
1446			continue;
1447		};
1448		let (suppressible, fixed): (
1449			Vec<crate::check::workspace_eval::WorkspaceSymbolViolation>,
1450			Vec<crate::check::workspace_eval::WorkspaceSymbolViolation>,
1451		) = workspace_violations
1452			.into_iter()
1453			.partition(|violation| violation.source_suppression);
1454		let mut suppressible = check::apply_suppressions(
1455			&checked.graph,
1456			&checked.source,
1457			suppressible
1458				.into_iter()
1459				.map(|violation| violation.violation)
1460				.collect(),
1461		);
1462		let mut violations = fixed
1463			.into_iter()
1464			.map(|violation| violation.violation)
1465			.collect::<Vec<_>>();
1466		violations.append(&mut suppressible);
1467		for violation in &violations {
1468			*kept_by_rule
1469				.entry(violation.rule_id.to_owned())
1470				.or_insert(0) += 1;
1471		}
1472		checked.report.violations.extend(violations);
1473		checked.report.violations.sort_by(|left, right| {
1474			left.lines
1475				.cmp(&right.lines)
1476				.then_with(|| left.rule_id.cmp(&right.rule_id))
1477		});
1478	}
1479	kept_by_rule
1480}
1481
1482struct FileRequirementResolver<'a> {
1483	root: PathBuf,
1484	source_set: OnceLock<Result<environment::SourceFileSet, String>>,
1485	file_defs: OnceLock<Vec<OnceLock<Vec<DefRecord>>>>,
1486	excludes: check::UriExclusionMatcher,
1487	workspace: &'a dyn CheckWorkspace,
1488}
1489
1490impl<'a> FileRequirementResolver<'a> {
1491	fn new(
1492		root: PathBuf,
1493		source_set: impl Into<Option<environment::SourceFileSet>>,
1494		exclude_uris: &[String],
1495		workspace: &'a dyn CheckWorkspace,
1496	) -> Self {
1497		let source_set_cell = OnceLock::new();
1498		if let Some(source_set) = source_set.into() {
1499			source_set_cell
1500				.set(Ok(source_set))
1501				.unwrap_or_else(|_| unreachable!("new source catalog cell"));
1502		}
1503		Self {
1504			root,
1505			source_set: source_set_cell,
1506			file_defs: OnceLock::new(),
1507			excludes: check::UriExclusionMatcher::new(exclude_uris),
1508			workspace,
1509		}
1510	}
1511}
1512
1513impl check::RequirementResolver for FileRequirementResolver<'_> {
1514	fn exists(&self, pattern: &str, _source: &DefRecord, _scheme: &str) -> bool {
1515		let Some(candidates) = source_candidates_from_requirement(&self.root, pattern) else {
1516			return false;
1517		};
1518		let Ok(path_pattern) = check::path::parse(pattern) else {
1519			return false;
1520		};
1521		for path in candidates {
1522			if !self.workspace.exists(&path) {
1523				continue;
1524			}
1525			let Ok(lang) = path_to_lang(&path) else {
1526				continue;
1527			};
1528			let Ok(source) = self.workspace.read_to_string(&path) else {
1529				continue;
1530			};
1531			let graph = environment::extract_source_with(
1532				lang,
1533				&source,
1534				&anchor_for_requirement(&self.root, &path),
1535				&environment::ExtractContext::default(),
1536			);
1537			if graph
1538				.defs()
1539				.any(|def| check::path::matches(&path_pattern, &def.moniker))
1540			{
1541				return true;
1542			}
1543		}
1544		false
1545	}
1546
1547	fn descendant_defs<'a>(&'a self, owner: &DefRecord, inner: &Domain) -> Vec<&'a DefRecord> {
1548		use rayon::prelude::*;
1549
1550		let Ok(source_set) = self.source_set() else {
1551			return Vec::new();
1552		};
1553		let file_defs = self.file_defs(source_set.files.len());
1554		let candidate_indexes = source_set
1555			.files
1556			.iter()
1557			.enumerate()
1558			.filter_map(|(idx, file)| {
1559				file.root_moniker
1560					.as_ref()
1561					.is_some_and(|root| {
1562						root != &owner.moniker && owner.moniker.is_ancestor_of(root)
1563					})
1564					.then_some(idx)
1565			})
1566			.collect::<Vec<_>>();
1567		candidate_indexes.par_iter().for_each(|idx| {
1568			file_defs[*idx].get_or_init(|| {
1569				collect_file_defs(&source_set.files[*idx], source_set, self.workspace)
1570			});
1571		});
1572		candidate_indexes
1573			.into_iter()
1574			.flat_map(|idx| {
1575				file_defs[idx]
1576					.get()
1577					.into_iter()
1578					.flat_map(|defs| defs.iter())
1579			})
1580			.filter(|def| owner.moniker.is_ancestor_of(&def.moniker))
1581			.filter(|def| lazy_domain_matches(inner, def))
1582			.collect()
1583	}
1584}
1585
1586impl FileRequirementResolver<'_> {
1587	fn file_defs(&self, file_count: usize) -> &[OnceLock<Vec<DefRecord>>] {
1588		self.file_defs
1589			.get_or_init(|| (0..file_count).map(|_| OnceLock::new()).collect())
1590			.as_slice()
1591	}
1592
1593	fn source_set(&self) -> Result<&environment::SourceFileSet, &str> {
1594		match self.source_set.get_or_init(|| {
1595			self.workspace
1596				.source_catalog(&self.root)
1597				.map(|source_set| filter_source_set(&source_set, &self.excludes))
1598				.map_err(|error| {
1599					format!(
1600						"cannot build lazy source catalog for `{}`: {error:#}",
1601						self.root.display()
1602					)
1603				})
1604		}) {
1605			Ok(source_set) => Ok(source_set),
1606			Err(error) => Err(error),
1607		}
1608	}
1609
1610	fn source_catalog_error(&self) -> Option<&str> {
1611		self.source_set
1612			.get()
1613			.and_then(|result| result.as_ref().err())
1614			.map(String::as_str)
1615	}
1616}
1617
1618fn collect_file_defs(
1619	file: &environment::SourceFile,
1620	source_set: &environment::SourceFileSet,
1621	workspace: &dyn CheckWorkspace,
1622) -> Vec<DefRecord> {
1623	let Ok(source) = workspace.read_to_string(&file.path) else {
1624		return Vec::new();
1625	};
1626	let ctx = &source_set.roots[file.source].ctx;
1627	let graph = environment::extract_source_with(file.lang, &source, &file.anchor, ctx);
1628	if file
1629		.root_moniker
1630		.as_ref()
1631		.is_none_or(|catalog_root| catalog_root != graph.root())
1632	{
1633		return Vec::new();
1634	}
1635	graph.defs().cloned().collect()
1636}
1637
1638fn normalize_relative(path: PathBuf) -> PathBuf {
1639	path.components()
1640		.filter_map(|component| match component {
1641			std::path::Component::Normal(part) => Some(PathBuf::from(part)),
1642			std::path::Component::CurDir => None,
1643			_ => None,
1644		})
1645		.collect()
1646}
1647
1648fn lazy_domain_matches(domain: &Domain, def: &DefRecord) -> bool {
1649	match domain {
1650		Domain::Children(kind) => def.kind.as_ref() == kind.as_bytes(),
1651		Domain::ChildrenByShape(shape) => {
1652			def.shape().is_some_and(|actual| actual.as_str() == shape)
1653		}
1654		Domain::Descendants(inner) => lazy_domain_matches(inner, def),
1655		Domain::Pairs(_)
1656		| Domain::Segments
1657		| Domain::OutRefs
1658		| Domain::InRefs
1659		| Domain::SourceOutRefs
1660		| Domain::SourceInRefs
1661		| Domain::TargetOutRefs
1662		| Domain::TargetInRefs
1663		| Domain::SourceAncestorOutRefs
1664		| Domain::SourceAncestorInRefs => false,
1665	}
1666}
1667
1668fn source_candidates_from_requirement(root: &Path, pattern: &str) -> Option<Vec<PathBuf>> {
1669	let mut dirs = Vec::new();
1670	let mut module = None;
1671	for step in pattern.split('/') {
1672		if let Some(dir) = literal_step_name(step, "dir") {
1673			dirs.push(dir.to_string());
1674		} else if let Some(name) = literal_step_name(step, "module") {
1675			module = Some(name.to_string());
1676		}
1677	}
1678	let module = module?;
1679	let base = dirs
1680		.iter()
1681		.fold(root.to_path_buf(), |path, dir| path.join(dir));
1682	if module == "mod" {
1683		Some(vec![base.join("mod.rs")])
1684	} else {
1685		Some(vec![
1686			base.join(format!("{module}.rs")),
1687			base.join(module).join("mod.rs"),
1688		])
1689	}
1690}
1691
1692fn literal_step_name<'a>(step: &'a str, kind: &str) -> Option<&'a str> {
1693	let (step_kind, name) = step.split_once(':')?;
1694	(step_kind == kind && !name.contains(['*', '{', '}', '/'])).then_some(name)
1695}
1696
1697fn anchor_for_requirement(root: &Path, path: &Path) -> PathBuf {
1698	path.strip_prefix(root).unwrap_or(path).to_path_buf()
1699}
1700
1701fn align_report_violations_with_suppressions(
1702	rule_reports: &mut [check::RuleReport],
1703	violations: &[check::Violation],
1704) {
1705	use std::collections::HashMap;
1706	let mut counts: HashMap<&str, usize> = HashMap::new();
1707	for v in violations {
1708		*counts.entry(v.rule_id.as_str()).or_insert(0) += 1;
1709	}
1710	for report in rule_reports {
1711		report.violations = counts.get(report.rule_id.as_str()).copied().unwrap_or(0);
1712	}
1713}
1714
1715fn path_excluded(path: &Path, cfg: &check::Config) -> bool {
1716	check::UriExclusionMatcher::new(&cfg.exclude.uris).matches_path(path)
1717}
1718
1719fn violation_counts(reports: &[FileReport]) -> ViolationCounts {
1720	let mut counts = ViolationCounts::default();
1721	for report in reports {
1722		if report.violations.is_empty() {
1723			continue;
1724		}
1725		counts.files_with += 1;
1726		for violation in &report.violations {
1727			counts.total += 1;
1728			if violation.severity.is_error() {
1729				counts.errors += 1;
1730			} else {
1731				counts.warnings += 1;
1732			}
1733		}
1734	}
1735	counts
1736}
1737
1738fn violations_by_srcset(reports: &[FileReport]) -> std::collections::BTreeMap<String, usize> {
1739	let mut counts = std::collections::BTreeMap::new();
1740	let mut unspecified = 0usize;
1741	for report in reports {
1742		for violation in &report.violations {
1743			if let Some(srcset) = &violation.srcset {
1744				*counts.entry(srcset.clone()).or_default() += 1;
1745			} else {
1746				unspecified += 1;
1747			}
1748		}
1749	}
1750	if !counts.is_empty() && unspecified > 0 {
1751		counts.insert("unspecified".to_string(), unspecified);
1752	}
1753	counts
1754}
1755
1756fn failed_rule_summary(reports: &[FileReport]) -> Vec<FailedRuleSummary> {
1757	use std::collections::BTreeMap;
1758	let mut by_rule: BTreeMap<(String, RuleSeverity), usize> = BTreeMap::new();
1759	for report in reports {
1760		for violation in &report.violations {
1761			*by_rule
1762				.entry((violation.rule_id.clone(), violation.severity))
1763				.or_default() += 1;
1764		}
1765	}
1766	let mut out: Vec<_> = by_rule
1767		.into_iter()
1768		.map(|((rule_id, severity), violations)| FailedRuleSummary {
1769			rule_id,
1770			severity,
1771			violations,
1772		})
1773		.collect();
1774	out.sort_by(|a, b| {
1775		b.violations
1776			.cmp(&a.violations)
1777			.then_with(|| b.severity.cmp(&a.severity))
1778			.then_with(|| a.rule_id.cmp(&b.rule_id))
1779	});
1780	out
1781}
1782
1783#[cfg(test)]
1784mod tests {
1785	use super::*;
1786	use std::sync::Mutex;
1787	use std::sync::atomic::{AtomicUsize, Ordering};
1788
1789	struct RecordingWorkspace {
1790		inner: MemoryCheckWorkspace,
1791		reads: Mutex<Vec<PathBuf>>,
1792		catalog_calls: AtomicUsize,
1793		fail_catalog: bool,
1794	}
1795
1796	impl RecordingWorkspace {
1797		fn new(inner: MemoryCheckWorkspace) -> Self {
1798			Self {
1799				inner,
1800				reads: Mutex::new(Vec::new()),
1801				catalog_calls: AtomicUsize::new(0),
1802				fail_catalog: false,
1803			}
1804		}
1805
1806		fn with_catalog_error(mut self) -> Self {
1807			self.fail_catalog = true;
1808			self
1809		}
1810
1811		fn reads(&self) -> Vec<PathBuf> {
1812			self.reads.lock().expect("read log").clone()
1813		}
1814	}
1815
1816	impl CheckWorkspace for RecordingWorkspace {
1817		fn is_dir(&self, path: &Path) -> anyhow::Result<bool> {
1818			self.inner.is_dir(path)
1819		}
1820
1821		fn read_to_string(&self, path: &Path) -> anyhow::Result<String> {
1822			self.reads
1823				.lock()
1824				.expect("read log")
1825				.push(path.to_path_buf());
1826			self.inner.read_to_string(path)
1827		}
1828
1829		fn source_set(
1830			&self,
1831			root: &Path,
1832			files: &[PathBuf],
1833		) -> anyhow::Result<environment::SourceFileSet> {
1834			self.inner.source_set(root, files)
1835		}
1836
1837		fn source_catalog(&self, root: &Path) -> anyhow::Result<environment::SourceFileSet> {
1838			self.catalog_calls.fetch_add(1, Ordering::Relaxed);
1839			if self.fail_catalog {
1840				anyhow::bail!("catalog unavailable");
1841			}
1842			self.inner.source_catalog(root)
1843		}
1844
1845		fn exists(&self, path: &Path) -> bool {
1846			self.inner.exists(path)
1847		}
1848
1849		fn linked_snapshot(
1850			&self,
1851			source_set: &environment::SourceFileSet,
1852			scheme: &str,
1853		) -> anyhow::Result<Option<Arc<WorkspaceSnapshot>>> {
1854			self.inner.linked_snapshot(source_set, scheme)
1855		}
1856	}
1857
1858	#[test]
1859	fn check_request_rejects_source_groups_from_another_project_rules_file() {
1860		let analyzed = tempfile::tempdir().expect("analyzed project");
1861		let external = tempfile::tempdir().expect("external rules project");
1862		let external_rules = external.path().join(".code-moniker.toml");
1863		std::fs::write(
1864			&external_rules,
1865			r#"
1866default_rules = false
1867
1868[[workspace.source_group]]
1869roots = ["src"]
1870"#,
1871		)
1872		.expect("write external project config");
1873		let request = CheckRequest::new(
1874			analyzed.path(),
1875			RuleSetRequest::with_rules(&external_rules, "code+moniker://"),
1876		);
1877
1878		let error = request
1879			.run()
1880			.expect_err("structural config must belong to the analyzed project");
1881		assert!(
1882			error
1883				.to_string()
1884				.contains("may be declared only in the canonical"),
1885			"{error:#}"
1886		);
1887	}
1888
1889	#[test]
1890	fn descendant_catalog_reads_only_candidate_roots_once() {
1891		let root = Path::new("/project");
1892		let workspace = RecordingWorkspace::new(
1893			MemoryCheckWorkspace::new(root)
1894				.with_file("src/tools/mod.rs", "mod read; mod symbols;", Lang::Rs)
1895				.with_file("src/tools/read.rs", "fn same_helper() {}", Lang::Rs)
1896				.with_file("src/tools/symbols.rs", "fn same_helper() {}", Lang::Rs)
1897				.with_file("src/unrelated.rs", "fn same_helper() {}", Lang::Rs),
1898		);
1899		let catalog = workspace.source_catalog(root).expect("source catalog");
1900		let resolver = FileRequirementResolver::new(root.to_path_buf(), catalog, &[], &workspace);
1901		let graph = environment::extract_source_with(
1902			Lang::Rs,
1903			"mod read; mod symbols;",
1904			Path::new("src/tools/mod.rs"),
1905			&environment::ExtractContext::default(),
1906		);
1907		let owner = graph.defs().next().expect("module root");
1908		let domain = Domain::Children("fn".to_string());
1909
1910		let first = check::RequirementResolver::descendant_defs(&resolver, owner, &domain);
1911		assert_eq!(first.len(), 2);
1912		let second = check::RequirementResolver::descendant_defs(&resolver, owner, &domain);
1913		assert_eq!(second.len(), 2);
1914
1915		let mut reads = workspace.reads();
1916		reads.sort();
1917		assert_eq!(
1918			reads,
1919			vec![
1920				root.join("src/tools/read.rs"),
1921				root.join("src/tools/symbols.rs")
1922			]
1923		);
1924		assert!(!reads.contains(&root.join("src/unrelated.rs")));
1925		assert!(!reads.contains(&root.join("src/tools/mod.rs")));
1926	}
1927
1928	#[test]
1929	fn false_lazy_rule_does_not_build_the_source_catalog() {
1930		let root = Path::new("/project");
1931		let workspace = RecordingWorkspace::new(
1932			MemoryCheckWorkspace::new(root)
1933				.with_file("src/lib.rs", "pub fn ready() {}", Lang::Rs)
1934				.with_file("src/tools/mod.rs", "mod read;", Lang::Rs)
1935				.with_file("src/tools/read.rs", "fn helper() {}", Lang::Rs),
1936		);
1937		let rules = RuleSetRequest::new(None, "code+moniker://")
1938			.with_default_rules(DefaultRulesSelection::Disabled)
1939			.with_inline_rules(vec![
1940				r#"
1941				[[rust.module.where]]
1942				id = "tools-descendants"
1943				expr = "uri ~ '**/dir:src/module:tools' => count(descendants(fn)) = 0"
1944				message = "tools descendants"
1945				"#
1946				.to_string(),
1947			]);
1948		let request = CheckRequest::new(root, rules).with_files(vec![PathBuf::from("src/lib.rs")]);
1949
1950		let run = request
1951			.run_with_workspace(&workspace)
1952			.expect("filtered check");
1953
1954		assert_eq!(run.reports.len(), 1);
1955		assert!(run.reports[0].violations.is_empty());
1956		assert_eq!(workspace.catalog_calls.load(Ordering::Relaxed), 0);
1957		assert_eq!(workspace.reads(), vec![root.join("src/lib.rs")]);
1958	}
1959
1960	#[test]
1961	fn reached_lazy_rule_reports_source_catalog_errors() {
1962		let root = Path::new("/project");
1963		let workspace = RecordingWorkspace::new(MemoryCheckWorkspace::new(root).with_file(
1964			"src/tools/mod.rs",
1965			"pub fn ready() {}",
1966			Lang::Rs,
1967		))
1968		.with_catalog_error();
1969		let rules = RuleSetRequest::new(None, "code+moniker://")
1970			.with_default_rules(DefaultRulesSelection::Disabled)
1971			.with_inline_rules(vec![
1972				r#"
1973				[[rust.module.where]]
1974				id = "tools-descendants"
1975				expr = "uri ~ '**/dir:src/module:tools' => count(descendants(fn)) = 0"
1976				message = "tools descendants"
1977				"#
1978				.to_string(),
1979			]);
1980		let request =
1981			CheckRequest::new(root, rules).with_files(vec![PathBuf::from("src/tools/mod.rs")]);
1982
1983		let run = request
1984			.run_with_workspace(&workspace)
1985			.expect("filtered check");
1986
1987		assert!(run.any_error());
1988		assert_eq!(run.errors.len(), 1);
1989		assert_eq!(run.errors[0].path, root);
1990		assert!(run.errors[0].error.contains("catalog unavailable"));
1991		assert_eq!(workspace.catalog_calls.load(Ordering::Relaxed), 1);
1992	}
1993
1994	#[test]
1995	fn indexed_workspace_is_built_from_one_cached_snapshot_generation() {
1996		let temp = tempfile::tempdir().expect("tempdir");
1997		let source = temp.path().join("lib.rs");
1998		std::fs::write(&source, "pub fn stale() {}\n").expect("write initial source");
1999		let cache = LocalResourceCache::default();
2000		let mut registry = LocalWorkspaceRegistry::local_with_cache(
2001			LocalWorkspaceOptions::new(vec![temp.path().to_path_buf()], None),
2002			cache.clone(),
2003		);
2004
2005		assert!(matches!(
2006			registry
2007				.commands()
2008				.refresh(WorkspaceRequest::new("initial")),
2009			WorkspaceTransition::Ready { .. }
2010		));
2011		let stale = registry.queries().snapshot_arc().expect("stale snapshot");
2012		let stale_material = cache
2013			.index_material(stale.index.generation)
2014			.expect("stale material");
2015
2016		std::fs::write(&source, "pub fn current() {}\n").expect("write refreshed source");
2017		assert!(matches!(
2018			registry
2019				.commands()
2020				.refresh(WorkspaceRequest::new("refresh")),
2021			WorkspaceTransition::Ready { .. }
2022		));
2023		let current = registry.queries().snapshot_arc().expect("current snapshot");
2024
2025		assert!(
2026			Arc::strong_count(&stale_material) > 0,
2027			"the old material remains available to a caller holding it"
2028		);
2029		let error = IndexedCheckWorkspace::from_snapshot(temp.path(), &cache, Arc::clone(&stale))
2030			.err()
2031			.expect("an evicted snapshot generation must be rejected");
2032		assert!(
2033			error
2034				.to_string()
2035				.contains(&stale.index.generation.value().to_string())
2036		);
2037
2038		let workspace =
2039			IndexedCheckWorkspace::from_snapshot(temp.path(), &cache, Arc::clone(&current))
2040				.expect("current generation");
2041		assert_eq!(
2042			workspace.read_to_string(&source).expect("indexed source"),
2043			"pub fn current() {}\n"
2044		);
2045		assert_eq!(
2046			workspace
2047				.linked_snapshot(workspace.material.source_set(), "code+moniker://")
2048				.expect("linked snapshot")
2049				.expect("snapshot")
2050				.index
2051				.generation,
2052			current.index.generation
2053		);
2054	}
2055}