1use std::collections::HashMap;
53use std::sync::Arc;
54
55use crate::runtime::mesh::build_conformance_vector;
56use dashmap::DashMap;
57use lsp_max_ast::AutoLspAdapter;
58use lsp_max_protocol::{ConformanceVector, LawAxis, MaxDiagnostic};
59use lsp_types_max::{
60 Diagnostic, DiagnosticOptions, DiagnosticServerCapabilities, DocumentDiagnosticReport,
61 DocumentDiagnosticReportResult, FullDocumentDiagnosticReport,
62 RelatedFullDocumentDiagnosticReport,
63};
64use lsp_types_max::{
65 DiagnosticSeverity, DidChangeTextDocumentParams, DidCloseTextDocumentParams,
66 DidOpenTextDocumentParams, DocumentUri, InitializeResult, NumberOrString, Position, Range,
67 ServerCapabilities, ServerInfo, TextDocumentSyncCapability, TextDocumentSyncKind,
68 WorkDoneProgressOptions,
69};
70use regex::Regex;
71use serde::{Deserialize, Serialize};
72
73pub type Finding = (MaxDiagnostic, Diagnostic);
80
81pub type ClassifiedFindings = (Vec<Finding>, Vec<Finding>);
84
85#[derive(Debug, Deserialize, Serialize, Clone)]
96pub struct Rule {
97 pub id: String,
99 pub name: String,
101 pub severity: String,
103 pub pattern: String,
105 pub path_globs: Vec<String>,
107 pub exclude_globs: Vec<String>,
109 pub message: String,
111 pub rationale: String,
113 #[serde(default)]
116 pub eval_budget: EvalBudget,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
136#[serde(rename_all = "snake_case")]
137pub enum EvalBudget {
138 #[default]
140 Sync,
141 Background,
143}
144
145#[derive(Debug, Deserialize, Serialize, Clone)]
150pub struct RulePack {
151 pub rules: Vec<Rule>,
153 #[serde(default)]
155 pub id: String,
156 #[serde(default)]
158 pub version: String,
159 #[serde(default)]
161 pub depends_on: Vec<String>,
162}
163
164#[derive(Debug, Clone)]
170pub struct ComposedPacks {
171 pub ordered: Vec<RulePack>,
173 pub conflicts: Vec<PackConflict>,
175}
176
177#[derive(Debug, Clone)]
179pub struct PackConflict {
180 pub rule_id: String,
182 pub pack_a: String,
184 pub pack_b: String,
186}
187
188pub fn compose_packs(packs: &[RulePack]) -> ComposedPacks {
199 let by_id: HashMap<&str, &RulePack> = packs
201 .iter()
202 .filter(|p| !p.id.is_empty())
203 .map(|p| (p.id.as_str(), p))
204 .collect();
205
206 let mut ordered: Vec<RulePack> = Vec::with_capacity(packs.len());
208 let mut visited: std::collections::HashSet<&str> = Default::default();
209 let mut in_stack: std::collections::HashSet<&str> = Default::default();
210
211 fn visit<'a>(
212 id: &'a str,
213 by_id: &HashMap<&'a str, &'a RulePack>,
214 visited: &mut std::collections::HashSet<&'a str>,
215 in_stack: &mut std::collections::HashSet<&'a str>,
216 ordered: &mut Vec<RulePack>,
217 ) {
218 if visited.contains(id) {
219 return;
220 }
221 if in_stack.contains(id) {
222 return;
224 }
225 if let Some(pack) = by_id.get(id) {
226 in_stack.insert(id);
227 for dep in &pack.depends_on {
228 visit(dep.as_str(), by_id, visited, in_stack, ordered);
229 }
230 in_stack.remove(id);
231 visited.insert(id);
232 ordered.push((*pack).clone());
233 }
234 }
235
236 for pack in packs.iter().filter(|p| p.id.is_empty()) {
238 ordered.push(pack.clone());
239 }
240 for pack in packs.iter().filter(|p| !p.id.is_empty()) {
241 visit(&pack.id, &by_id, &mut visited, &mut in_stack, &mut ordered);
242 }
243
244 let mut seen_rules: HashMap<&str, &str> = Default::default();
246 let mut conflicts = Vec::new();
247 for pack in &ordered {
248 for rule in &pack.rules {
249 if let Some(owner) = seen_rules.get(rule.id.as_str()) {
250 conflicts.push(PackConflict {
251 rule_id: rule.id.clone(),
252 pack_a: owner.to_string(),
253 pack_b: pack.id.clone(),
254 });
255 } else {
256 seen_rules.insert(rule.id.as_str(), pack.id.as_str());
257 }
258 }
259 }
260
261 ComposedPacks { ordered, conflicts }
262}
263
264#[derive(Debug, Clone, Default)]
277pub struct ValidatedRulePackSet {
278 ordered: Vec<RulePack>,
279}
280
281impl ValidatedRulePackSet {
282 pub fn new(packs: &[RulePack]) -> Result<Self, Vec<PackConflict>> {
284 let composed = compose_packs(packs);
285 if composed.conflicts.is_empty() {
286 Ok(Self {
287 ordered: composed.ordered,
288 })
289 } else {
290 Err(composed.conflicts)
291 }
292 }
293
294 pub fn empty() -> Self {
296 Self::default()
297 }
298
299 pub fn packs(&self) -> &[RulePack] {
301 &self.ordered
302 }
303
304 pub fn merge(mut self, other: ValidatedRulePackSet) -> Result<Self, Vec<PackConflict>> {
306 let combined: Vec<RulePack> = self.ordered.drain(..).chain(other.ordered).collect();
307 Self::new(&combined)
308 }
309}
310
311#[derive(Clone, Debug)]
330pub struct RulePackSnapshot {
331 pub index: Arc<DashMap<String, IndexedDoc>>,
333 pub packs: Arc<Vec<RulePack>>,
335 pub seq: u64,
338}
339
340#[derive(Debug, Clone)]
342pub struct IndexedDoc {
343 pub content: String,
345 pub conformance: Option<ConformanceVector>,
348 pub version: i32,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct CrossFileRule {
371 pub id: String,
373 pub name: String,
375 pub severity: String,
377 pub source_glob: String,
379 pub source_pattern: String,
381 pub target_glob: String,
383 pub target_pattern: String,
386 pub message: String,
388 pub rationale: String,
390}
391
392#[derive(Debug, Clone)]
395pub struct CrossFileViolation {
396 pub source_uri: String,
398 pub line: u32,
400 pub col_start: u32,
402 pub col_end: u32,
404 pub matched_text: String,
406 pub rule: CrossFileRule,
408}
409
410#[derive(Clone, Debug, Default)]
415pub struct WorkspaceIndex {
416 docs: Arc<DashMap<String, IndexedDoc>>,
417 seq: Arc<std::sync::atomic::AtomicU64>,
418}
419
420impl WorkspaceIndex {
421 pub fn new() -> Self {
423 Self::default()
424 }
425
426 pub fn get(&self, uri: &str) -> Option<IndexedDoc> {
428 self.docs
429 .get(uri)
430 .map(|ref_multi| ref_multi.value().clone())
431 }
432
433 pub fn upsert(&self, uri: String, content: String, version: i32) {
435 self.docs.insert(
436 uri,
437 IndexedDoc {
438 content,
439 conformance: None,
440 version,
441 },
442 );
443 self.seq.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
444 }
445
446 pub fn set_conformance(&self, uri: &str, cv: ConformanceVector) {
448 if let Some(mut entry) = self.docs.get_mut(uri) {
449 entry.conformance = Some(cv);
450 }
451 }
452
453 pub fn remove(&self, uri: &str) {
455 self.docs.remove(uri);
456 self.seq.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
457 }
458
459 pub fn snapshot(&self, packs: Arc<Vec<RulePack>>) -> RulePackSnapshot {
461 RulePackSnapshot {
462 index: Arc::clone(&self.docs),
463 packs,
464 seq: self.seq.load(std::sync::atomic::Ordering::Relaxed),
465 }
466 }
467
468 #[tracing::instrument(
476 name = "workspace_conformance",
477 skip(self),
478 fields(
479 doc_count = self.docs.len(),
480 admitted_count = tracing::field::Empty,
481 refused_count = tracing::field::Empty,
482 score = tracing::field::Empty,
483 )
484 )]
485 pub fn workspace_conformance(&self) -> ConformanceVector {
486 let mut workspace_refused: std::collections::HashSet<LawAxis> = Default::default();
487 let mut workspace_admitted: std::collections::HashSet<LawAxis> = Default::default();
488
489 for entry in self.docs.iter() {
490 if let Some(cv) = &entry.value().conformance {
491 for axis in &cv.refused {
492 workspace_refused.insert(axis.clone());
493 workspace_admitted.remove(axis);
494 }
495 for axis in &cv.admitted {
496 if !workspace_refused.contains(axis) {
497 workspace_admitted.insert(axis.clone());
498 }
499 }
500 }
501 }
502
503 let refused: Vec<LawAxis> = workspace_refused.into_iter().collect();
504 let admitted: Vec<LawAxis> = workspace_admitted.into_iter().collect();
505
506 let covered: std::collections::HashSet<&LawAxis> =
508 refused.iter().chain(admitted.iter()).collect();
509 let unknown: Vec<LawAxis> = LawAxis::all_named()
510 .iter()
511 .filter(|a| !covered.contains(a))
512 .cloned()
513 .collect();
514
515 let cv_admitted_len = admitted.len();
516 let cv_refused_len = refused.len();
517 let total = cv_admitted_len + cv_refused_len;
518 let score = if total == 0 {
519 None
520 } else {
521 Some(cv_admitted_len as f64 / total as f64 * 100.0)
522 };
523
524 let span = tracing::Span::current();
525 span.record("admitted_count", cv_admitted_len);
526 span.record("refused_count", cv_refused_len);
527 if let Some(s) = score {
528 span.record("score", s);
529 }
530
531 let mut cv = ConformanceVector {
532 admitted,
533 refused,
534 unknown,
535 score,
536 strict_mode: true,
537 process_quality: None,
538 ..Default::default()
539 };
540 cv.sync_bits_from_vecs();
541 cv
542 }
543}
544
545#[derive(Debug)]
550pub struct WorkspaceRuleEvaluator;
551
552impl WorkspaceRuleEvaluator {
553 pub fn evaluate(
558 snapshot: &RulePackSnapshot,
559 rules: &[CrossFileRule],
560 ) -> Vec<CrossFileViolation> {
561 let mut violations = Vec::new();
562
563 for rule in rules {
564 let source_re = match Regex::new(&rule.source_pattern) {
565 Ok(r) => r,
566 Err(e) => {
567 tracing::warn!(
568 rule_id = %rule.id,
569 error = %e,
570 "CrossFileRule: skipping rule with invalid source_pattern regex"
571 );
572 continue;
573 }
574 };
575
576 let target_evidence: Option<bool> = if rule.target_pattern.is_empty() {
579 None
580 } else {
581 let target_re = match Regex::new(&rule.target_pattern) {
582 Ok(r) => r,
583 Err(e) => {
584 tracing::warn!(
585 rule_id = %rule.id,
586 error = %e,
587 "CrossFileRule: skipping rule with invalid target_pattern regex"
588 );
589 continue;
590 }
591 };
592 let found = snapshot.index.iter().any(|entry| {
594 let uri = entry.key();
595 glob_matches(&rule.target_glob, uri)
596 && target_re.is_match(&entry.value().content)
597 });
598 Some(found)
599 };
600
601 for entry in snapshot.index.iter() {
603 let uri = entry.key();
604 if !glob_matches(&rule.source_glob, uri) {
605 continue;
606 }
607 for (line_idx, line) in entry.value().content.lines().enumerate() {
608 for mat in source_re.find_iter(line) {
609 let violated = match target_evidence {
611 Some(found) => !found,
612 None => true, };
614 if violated {
615 violations.push(CrossFileViolation {
616 source_uri: uri.clone(),
617 line: line_idx as u32,
618 col_start: mat.start() as u32,
619 col_end: mat.end() as u32,
620 matched_text: mat.as_str().to_string(),
621 rule: rule.clone(),
622 });
623 }
624 }
625 }
626 }
627 }
628
629 violations
630 }
631}
632
633pub fn glob_matches(glob: &str, uri: &str) -> bool {
636 if glob.is_empty() || glob == "**" {
637 return true;
638 }
639 let escaped = regex::escape(glob)
641 .replace(r"\*\*", ".*")
642 .replace(r"\*", "[^/]*");
643 Regex::new(&format!("(?i){}", escaped))
644 .map(|re| re.is_match(uri))
645 .unwrap_or(false)
646}
647
648pub fn severity_to_law_axis(severity: &str) -> LawAxis {
664 match severity {
665 "error" => LawAxis::Domain,
666 "warning" => LawAxis::Protocol,
667 "info" => LawAxis::Documentation,
668 "hint" => LawAxis::Fixture,
669 other => LawAxis::Custom(other.to_string()),
670 }
671}
672
673pub fn severity_to_lsp(severity: &str) -> DiagnosticSeverity {
675 match severity {
676 "error" => DiagnosticSeverity::ERROR,
677 "warning" => DiagnosticSeverity::WARNING,
678 "info" => DiagnosticSeverity::INFORMATION,
679 "hint" => DiagnosticSeverity::HINT,
680 _ => DiagnosticSeverity::WARNING,
681 }
682}
683
684#[allow(async_fn_in_trait)]
691pub trait RulePackServer {
692 fn rule_packs(&self) -> &ValidatedRulePackSet;
694
695 fn cross_file_rules(&self) -> &[CrossFileRule] {
697 &[]
698 }
699
700 fn grammar(&self) -> tree_sitter::Language;
702
703 fn server_name(&self) -> &'static str;
705
706 fn client(&self) -> &crate::service::Client;
708
709 fn adapter(&self) -> &AutoLspAdapter;
711
712 fn workspace_index(&self) -> Option<&WorkspaceIndex> {
715 None
716 }
717
718 fn spc_monitor(&self) -> Option<&std::sync::Mutex<crate::primitives::SpcMonitor>> {
721 None
722 }
723
724 fn latency_trackers(
726 &self,
727 ) -> Option<&Arc<DashMap<String, crate::primitives::RuleLatencyTracker>>> {
728 None
729 }
730
731 fn rule_circuit_breaker(
733 &self,
734 ) -> Option<&Arc<parking_lot::Mutex<crate::primitives::CircuitBreaker>>> {
735 None
736 }
737
738 fn server_capabilities(&self) -> ServerCapabilities {
746 let has_cross_file =
747 !self.cross_file_rules().is_empty() || self.workspace_index().is_some();
748 ServerCapabilities {
749 text_document_sync: Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL)),
750 diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
751 identifier: Some(self.server_name().to_string()),
752 inter_file_dependencies: has_cross_file,
753 workspace_diagnostics: has_cross_file,
754 work_done_progress_options: WorkDoneProgressOptions {
755 work_done_progress: None,
756 },
757 })),
758 ..ServerCapabilities::default()
759 }
760 }
761
762 fn build_initialize_result(&self) -> InitializeResult {
764 InitializeResult {
765 capabilities: self.server_capabilities(),
766 server_info: Some(ServerInfo {
767 name: self.server_name().to_string(),
768 version: Some(env!("CARGO_PKG_VERSION").to_string()),
769 }),
770 offset_encoding: None,
771 }
772 }
773
774 async fn handle_did_open(&self, params: DidOpenTextDocumentParams) {
779 let uri = params.text_document.uri.clone();
780 let content = params.text_document.text.clone();
781
782 if let Some(idx) = self.workspace_index() {
784 idx.upsert(uri.as_str().to_string(), content.clone(), 0);
785 }
786
787 self.adapter()
788 .handle_did_open(params.clone(), self.grammar());
789 self.publish_findings_classified(uri, &content).await;
790 }
791
792 async fn handle_did_change(&self, params: DidChangeTextDocumentParams) {
794 let uri = params.text_document.uri.clone();
795 if let Some(change) = params.content_changes.last() {
796 let content = change.text.clone();
797 let version = params.text_document.version;
798
799 if let Some(idx) = self.workspace_index() {
800 idx.upsert(uri.as_str().to_string(), content.clone(), version);
801 }
802
803 self.adapter().handle_did_change(params, self.grammar());
804 self.publish_findings_classified(uri, &content).await;
805 } else {
806 self.adapter().handle_did_change(params, self.grammar());
807 }
808 }
809
810 fn handle_did_close(&self, params: DidCloseTextDocumentParams) {
812 if let Some(idx) = self.workspace_index() {
813 idx.remove(params.text_document.uri.as_str());
814 }
815 self.adapter().handle_did_close(params);
816 }
817
818 #[tracing::instrument(
823 name = "scan_uri_classified",
824 skip(self, content),
825 fields(
826 uri = ?uri,
827 content_bytes = content.len(),
828 sync_count = tracing::field::Empty,
829 bg_count = tracing::field::Empty,
830 )
831 )]
832 fn scan_uri_classified(&self, uri: &DocumentUri, content: &str) -> ClassifiedFindings {
833 if let Some(cb_arc) = self.rule_circuit_breaker() {
835 if !cb_arc.lock().is_allowed() {
836 tracing::warn!(uri = ?uri, "CircuitBreaker: scan short-circuited (Open)");
837 return (Vec::new(), Vec::new());
838 }
839 }
840
841 let mut sync_r = Vec::new();
842 let mut bg_r = Vec::new();
843
844 for pack in self.rule_packs().packs() {
845 for rule in &pack.rules {
846 let re = match Regex::new(&rule.pattern) {
847 Ok(r) => r,
848 Err(e) => {
849 tracing::warn!(
850 rule_id = %rule.id,
851 error = %e,
852 "RulePackServer: skipping rule with invalid regex"
853 );
854 if let Some(cb) = self.rule_circuit_breaker() {
855 cb.lock().record_failure();
856 }
857 continue;
858 }
859 };
860
861 let mut rule_findings = Vec::new();
862 for (line_idx, line) in content.lines().enumerate() {
863 for mat in re.find_iter(line) {
864 let line_u32 = line_idx as u32;
865 let start_char = mat.start() as u32;
866 let end_char = mat.end() as u32;
867
868 let lsp_diag = Diagnostic {
869 range: Range::new(
870 Position::new(line_u32, start_char),
871 Position::new(line_u32, end_char),
872 ),
873 severity: Some(severity_to_lsp(&rule.severity)),
874 code: Some(NumberOrString::String(rule.id.clone())),
875 source: Some(self.server_name().to_string()),
876 message: format!("{} — {}", rule.message, mat.as_str()),
877 related_information: None,
878 code_description: None,
879 tags: None,
880 data: Some(serde_json::json!({
881 "rule_id": rule.id,
882 "rule_name": rule.name,
883 "rationale": rule.rationale,
884 "uri": uri.as_str(),
885 })),
886 };
887
888 let max_diag = MaxDiagnostic {
889 lsp: lsp_diag.clone(),
890 diagnostic_id: format!("{}-{}:{}", rule.id, line_u32, start_char),
891 law_id: rule.id.clone(),
892 law_axis: severity_to_law_axis(&rule.severity),
893 violated_invariant: rule.rationale.clone(),
894 ..MaxDiagnostic::default()
895 };
896
897 rule_findings.push((max_diag, lsp_diag));
898 }
899 }
900
901 let effective_budget = if rule.eval_budget == EvalBudget::Sync {
903 if self
904 .latency_trackers()
905 .and_then(|m| m.get(&rule.id))
906 .map(|t| t.is_reclassified())
907 .unwrap_or(false)
908 {
909 EvalBudget::Background
910 } else {
911 EvalBudget::Sync
912 }
913 } else {
914 EvalBudget::Background
915 };
916
917 match effective_budget {
918 EvalBudget::Sync => {
919 let t0 = std::time::Instant::now();
920 sync_r.extend(rule_findings);
921 let elapsed = t0.elapsed();
922 if let Some(trackers) = self.latency_trackers() {
923 let mut entry = trackers.entry(rule.id.clone()).or_default();
924 let promoted = entry.record(elapsed);
925 if promoted {
926 tracing::info!(
927 rule_id = %rule.id,
928 elapsed_ms = elapsed.as_millis(),
929 "EvalBudget: Sync → Background (3 consecutive slow evals)"
930 );
931 }
932 }
933 if let Some(cb) = self.rule_circuit_breaker() {
934 cb.lock().record_success();
935 }
936 }
937 EvalBudget::Background => {
938 bg_r.extend(rule_findings);
939 if let Some(cb) = self.rule_circuit_breaker() {
940 cb.lock().record_success();
941 }
942 }
943 }
944 }
945 }
946
947 let span = tracing::Span::current();
948 span.record("sync_count", sync_r.len());
949 span.record("bg_count", bg_r.len());
950
951 (sync_r, bg_r)
952 }
953
954 fn scan_uri(&self, uri: &DocumentUri, content: &str) -> Vec<Finding> {
956 let (mut sync_r, bg_r) = self.scan_uri_classified(uri, content);
957 sync_r.extend(bg_r);
958 sync_r
959 }
960
961 async fn publish_findings_classified(&self, uri: DocumentUri, content: &str) {
966 let t_scan = std::time::Instant::now();
967 let (sync_findings, bg_findings) = self.scan_uri_classified(&uri, content);
968 let scan_ms = t_scan.elapsed().as_secs_f64() * 1000.0;
969
970 if let Some(mon) = self.spc_monitor() {
971 if let Ok(mut guard) = mon.lock() {
972 match guard.push(scan_ms) {
973 Some(crate::primitives::SpcAlert::Rule1(v)) => {
974 tracing::warn!(duration_ms = v, "SPC Rule1: scan latency spike");
975 }
976 Some(crate::primitives::SpcAlert::Rule2)
977 | Some(crate::primitives::SpcAlert::Rule3) => {
978 tracing::warn!(duration_ms = scan_ms, "SPC: structural scan latency drift");
979 }
980 Some(crate::primitives::SpcAlert::Rule4) => {
981 tracing::warn!(
982 duration_ms = scan_ms,
983 "SPC Rule4: latency near control limit"
984 );
985 }
986 None => {}
987 }
988 }
989 }
990
991 let mut all_lsp: Vec<Diagnostic> = sync_findings.iter().map(|(_, d)| d.clone()).collect();
992 all_lsp.extend(bg_findings.iter().map(|(_, d)| d.clone()));
993
994 let old_score: f64 = self
996 .workspace_index()
997 .map(|idx| idx.workspace_conformance().score.unwrap_or(100.0))
998 .unwrap_or(100.0);
999
1000 if let Some(idx) = self.workspace_index() {
1002 let all_max: Vec<MaxDiagnostic> = sync_findings
1003 .iter()
1004 .chain(bg_findings.iter())
1005 .map(|(m, _)| m.clone())
1006 .collect();
1007 let cv = build_conformance_vector(&all_max);
1008 idx.set_conformance(uri.as_str(), cv);
1009 }
1010
1011 let new_score: f64 = self
1013 .workspace_index()
1014 .map(|idx| idx.workspace_conformance().score.unwrap_or(100.0))
1015 .unwrap_or(100.0);
1016
1017 #[allow(clippy::float_cmp)]
1018 if (old_score - new_score).abs() > f64::EPSILON {
1019 if let Ok(mut registry) = crate::lock_registry() {
1020 registry.action_seq = registry.action_seq.saturating_add(1);
1021 let seq = registry.action_seq;
1022 const MAX_DELTA_LOG: usize = 4096;
1023 registry.conformance_delta_log.push_back(
1024 crate::max_runtime::ConformanceDeltaEntry {
1025 seq,
1026 instance_id: uri.to_string(),
1027 old_score,
1028 new_score,
1029 timestamp: crate::rfc3339_now(),
1030 },
1031 );
1032 if registry.conformance_delta_log.len() > MAX_DELTA_LOG {
1033 registry.conformance_delta_log.pop_front();
1034 }
1035 }
1036 }
1037
1038 self.client().publish_diagnostics(uri, all_lsp, None).await;
1039 }
1040
1041 async fn publish_findings(&self, uri: DocumentUri, content: &str) {
1043 self.publish_findings_classified(uri, content).await;
1044 }
1045
1046 fn pull_document_diagnostics(&self, uri: &DocumentUri) -> DocumentDiagnosticReportResult {
1048 let rule_diags: Vec<Diagnostic> = self
1049 .adapter()
1050 .get_document(uri, |doc| {
1051 let content = doc.as_str().to_string();
1052 self.scan_uri(uri, &content)
1053 .into_iter()
1054 .map(|(_, d)| d)
1055 .collect::<Vec<_>>()
1056 })
1057 .unwrap_or_default();
1058
1059 DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1060 RelatedFullDocumentDiagnosticReport {
1061 related_documents: None,
1062 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1063 result_id: None,
1064 items: rule_diags,
1065 },
1066 },
1067 ))
1068 }
1069
1070 fn conformance_for_content(&self, uri: &DocumentUri, content: &str) -> ConformanceVector {
1072 let max_diags: Vec<MaxDiagnostic> = self
1073 .scan_uri(uri, content)
1074 .into_iter()
1075 .map(|(m, _)| m)
1076 .collect();
1077 build_conformance_vector(&max_diags)
1078 }
1079
1080 fn snapshot_conformance(&self, uri: &DocumentUri) -> ConformanceVector {
1083 let opt_vec: Option<ConformanceVector> = self.adapter().get_document(uri, |doc| {
1084 let content = doc.as_str().to_string();
1085 self.conformance_for_content(uri, &content)
1086 });
1087
1088 opt_vec.unwrap_or_else(|| ConformanceVector {
1089 admitted: vec![],
1090 refused: vec![],
1091 unknown: lsp_max_protocol::LawAxis::all_named().to_vec(),
1092 score: None,
1093 strict_mode: true,
1094 process_quality: None,
1095 ..Default::default()
1096 })
1097 }
1098
1099 fn workspace_conformance(&self) -> ConformanceVector {
1105 self.workspace_index()
1106 .map(|idx| idx.workspace_conformance())
1107 .unwrap_or_else(|| ConformanceVector {
1108 admitted: vec![],
1109 refused: vec![],
1110 unknown: lsp_max_protocol::LawAxis::all_named().to_vec(),
1111 score: None,
1112 strict_mode: true,
1113 process_quality: None,
1114 ..Default::default()
1115 })
1116 }
1117
1118 fn evaluate_cross_file_rules(&self) -> Option<Vec<CrossFileViolation>> {
1122 let idx = self.workspace_index()?;
1123 let packs = Arc::new(self.rule_packs().packs().to_vec());
1124 let snapshot = idx.snapshot(packs);
1125 let violations = WorkspaceRuleEvaluator::evaluate(&snapshot, self.cross_file_rules());
1126 Some(violations)
1127 }
1128
1129 fn cross_file_violations_as_diagnostics(
1131 &self,
1132 violations: &[CrossFileViolation],
1133 ) -> HashMap<String, Vec<Diagnostic>> {
1134 let mut by_uri: HashMap<String, Vec<Diagnostic>> = Default::default();
1135 for v in violations {
1136 let lsp_diag = Diagnostic {
1137 range: Range::new(
1138 Position::new(v.line, v.col_start),
1139 Position::new(v.line, v.col_end),
1140 ),
1141 severity: Some(severity_to_lsp(&v.rule.severity)),
1142 code: Some(NumberOrString::String(v.rule.id.clone())),
1143 source: Some(format!("{}/cross-file", self.server_name())),
1144 message: format!("{} — `{}`", v.rule.message, v.matched_text),
1145 related_information: None,
1146 code_description: None,
1147 tags: None,
1148 data: Some(serde_json::json!({
1149 "rule_id": v.rule.id,
1150 "cross_file": true,
1151 "rationale": v.rule.rationale,
1152 })),
1153 };
1154 by_uri
1155 .entry(v.source_uri.clone())
1156 .or_default()
1157 .push(lsp_diag);
1158 }
1159 by_uri
1160 }
1161
1162 async fn publish_cross_file_diagnostics(&self) {
1164 if let Some(violations) = self.evaluate_cross_file_rules() {
1165 let by_uri = self.cross_file_violations_as_diagnostics(&violations);
1166 for (uri_str, diags) in by_uri {
1167 if let Ok(uri) = uri_str.parse::<lsp_types_max::Uri>() {
1168 self.client().publish_diagnostics(uri, diags, None).await;
1169 }
1170 }
1171 }
1172 }
1173}
1174
1175#[derive(Debug)]
1187pub struct PrimitivesBundle {
1188 pub spc_monitor: std::sync::Mutex<crate::primitives::SpcMonitor>,
1190 pub latency_trackers: Arc<DashMap<String, crate::primitives::RuleLatencyTracker>>,
1192 pub circuit_breaker: Arc<parking_lot::Mutex<crate::primitives::CircuitBreaker>>,
1194}
1195
1196impl Default for PrimitivesBundle {
1197 fn default() -> Self {
1198 Self {
1199 spc_monitor: std::sync::Mutex::new(crate::primitives::SpcMonitor::default()),
1200 latency_trackers: Arc::new(DashMap::new()),
1201 circuit_breaker: Arc::new(parking_lot::Mutex::new(
1202 crate::primitives::CircuitBreaker::default(),
1203 )),
1204 }
1205 }
1206}
1207
1208impl PrimitivesBundle {
1209 pub fn new() -> Self {
1211 Self::default()
1212 }
1213
1214 pub fn spc_monitor_ref(&self) -> &std::sync::Mutex<crate::primitives::SpcMonitor> {
1216 &self.spc_monitor
1217 }
1218
1219 pub fn latency_trackers_ref(
1221 &self,
1222 ) -> &Arc<DashMap<String, crate::primitives::RuleLatencyTracker>> {
1223 &self.latency_trackers
1224 }
1225
1226 pub fn circuit_breaker_ref(
1228 &self,
1229 ) -> &Arc<parking_lot::Mutex<crate::primitives::CircuitBreaker>> {
1230 &self.circuit_breaker
1231 }
1232}
1233
1234#[cfg(test)]
1239mod tests {
1240 use super::*;
1241 use lsp_max_protocol::LawAxis;
1242
1243 #[test]
1246 fn test_severity_error_maps_to_domain() {
1247 assert_eq!(severity_to_law_axis("error"), LawAxis::Domain);
1248 }
1249
1250 #[test]
1251 fn test_severity_warning_maps_to_protocol() {
1252 assert_eq!(severity_to_law_axis("warning"), LawAxis::Protocol);
1253 }
1254
1255 #[test]
1256 fn test_severity_info_maps_to_documentation() {
1257 assert_eq!(severity_to_law_axis("info"), LawAxis::Documentation);
1258 }
1259
1260 #[test]
1261 fn test_severity_hint_maps_to_fixture() {
1262 assert_eq!(severity_to_law_axis("hint"), LawAxis::Fixture);
1263 }
1264
1265 #[test]
1266 fn test_severity_unknown_maps_to_custom() {
1267 let axis = severity_to_law_axis("critical");
1268 assert!(matches!(axis, LawAxis::Custom(_)));
1269 if let LawAxis::Custom(s) = axis {
1270 assert_eq!(s, "critical");
1271 }
1272 }
1273
1274 #[test]
1277 fn test_eval_budget_default_is_sync() {
1278 let rule: Rule = serde_json::from_str(
1279 r#"{
1280 "id":"X","name":"X","severity":"error","pattern":"x",
1281 "path_globs":[],"exclude_globs":[],"message":"m","rationale":"r"
1282 }"#,
1283 )
1284 .unwrap();
1285 assert_eq!(rule.eval_budget, EvalBudget::Sync);
1286 }
1287
1288 #[test]
1289 fn test_eval_budget_background_deserialises() {
1290 let rule: Rule = serde_json::from_str(
1291 r#"{
1292 "id":"X","name":"X","severity":"error","pattern":"x",
1293 "path_globs":[],"exclude_globs":[],"message":"m","rationale":"r",
1294 "eval_budget":"background"
1295 }"#,
1296 )
1297 .unwrap();
1298 assert_eq!(rule.eval_budget, EvalBudget::Background);
1299 }
1300
1301 struct TestServer {
1304 packs: ValidatedRulePackSet,
1305 grammar: tree_sitter::Language,
1306 adapter: AutoLspAdapter,
1307 index: WorkspaceIndex,
1308 primitives: PrimitivesBundle,
1309 }
1310
1311 impl TestServer {
1312 fn new(packs: Vec<RulePack>) -> Self {
1313 Self {
1314 packs: ValidatedRulePackSet::new(&packs).unwrap_or_default(),
1315 grammar: tree_sitter_rust::LANGUAGE.into(),
1316 adapter: AutoLspAdapter::new_default(),
1317 index: WorkspaceIndex::new(),
1318 primitives: PrimitivesBundle::new(),
1319 }
1320 }
1321 #[allow(dead_code)]
1322 fn with_cross_file_rules(packs: Vec<RulePack>, _cross: Vec<CrossFileRule>) -> Self {
1323 Self::new(packs)
1324 }
1325 }
1326
1327 impl RulePackServer for TestServer {
1328 fn rule_packs(&self) -> &ValidatedRulePackSet {
1329 &self.packs
1330 }
1331 fn grammar(&self) -> tree_sitter::Language {
1332 self.grammar.clone()
1333 }
1334 fn server_name(&self) -> &'static str {
1335 "test-server"
1336 }
1337 fn client(&self) -> &crate::service::Client {
1338 panic!("TestServer::client() not used in unit tests")
1339 }
1340 fn adapter(&self) -> &AutoLspAdapter {
1341 &self.adapter
1342 }
1343 fn workspace_index(&self) -> Option<&WorkspaceIndex> {
1344 Some(&self.index)
1345 }
1346 fn spc_monitor(&self) -> Option<&std::sync::Mutex<crate::primitives::SpcMonitor>> {
1347 Some(self.primitives.spc_monitor_ref())
1348 }
1349 fn latency_trackers(
1350 &self,
1351 ) -> Option<&Arc<DashMap<String, crate::primitives::RuleLatencyTracker>>> {
1352 Some(self.primitives.latency_trackers_ref())
1353 }
1354 fn rule_circuit_breaker(
1355 &self,
1356 ) -> Option<&Arc<parking_lot::Mutex<crate::primitives::CircuitBreaker>>> {
1357 Some(self.primitives.circuit_breaker_ref())
1358 }
1359 }
1360
1361 fn make_pack(id: &str, severity: &str, pattern: &str) -> RulePack {
1362 RulePack {
1363 rules: vec![Rule {
1364 id: id.to_string(),
1365 name: id.to_string(),
1366 severity: severity.to_string(),
1367 pattern: pattern.to_string(),
1368 path_globs: vec![],
1369 exclude_globs: vec![],
1370 message: "violation".to_string(),
1371 rationale: "testing".to_string(),
1372 eval_budget: EvalBudget::Sync,
1373 }],
1374 id: id.to_string(),
1375 version: "1.0.0".to_string(),
1376 depends_on: vec![],
1377 }
1378 }
1379
1380 #[test]
1383 fn test_scan_uri_detects_match() {
1384 let pack = make_pack("TEST-001", "error", r"unwrap\(\)");
1385 let server = TestServer::new(vec![pack]);
1386 let uri: DocumentUri = "file:///tmp/test.rs".parse().unwrap();
1387 let content = "let x = foo.unwrap();\n";
1388 let findings = server.scan_uri(&uri, content);
1389 assert_eq!(findings.len(), 1);
1390 let (max_diag, lsp_diag) = &findings[0];
1391 assert_eq!(max_diag.law_axis, LawAxis::Domain);
1392 assert_eq!(lsp_diag.severity, Some(DiagnosticSeverity::ERROR));
1393 assert_eq!(
1394 lsp_diag.code,
1395 Some(NumberOrString::String("TEST-001".to_string()))
1396 );
1397 assert_eq!(lsp_diag.range.start.line, 0);
1398 assert_eq!(lsp_diag.range.start.character, 12);
1399 }
1400
1401 #[test]
1402 fn test_scan_uri_no_match_returns_empty() {
1403 let pack = make_pack("TEST-002", "warning", r"panic!");
1404 let server = TestServer::new(vec![pack]);
1405 let uri: DocumentUri = "file:///tmp/test.rs".parse().unwrap();
1406 let findings = server.scan_uri(&uri, "fn clean() {}\n");
1407 assert!(findings.is_empty());
1408 }
1409
1410 #[test]
1411 fn test_scan_uri_invalid_regex_skipped() {
1412 let bad = make_pack("BAD-001", "error", r"[invalid(regex");
1413 let good = make_pack("GOOD-001", "warning", r"todo!");
1414 let server = TestServer::new(vec![bad, good]);
1415 let uri: DocumentUri = "file:///tmp/test.rs".parse().unwrap();
1416 let findings = server.scan_uri(&uri, concat!("let _ = todo", "!();\n"));
1417 assert_eq!(findings.len(), 1);
1418 assert_eq!(findings[0].0.law_id, "GOOD-001");
1419 }
1420
1421 #[test]
1422 fn test_scan_uri_multiple_matches_on_one_line() {
1423 let pack = make_pack("TEST-003", "error", r"unwrap\(\)");
1424 let server = TestServer::new(vec![pack]);
1425 let uri: DocumentUri = "file:///tmp/test.rs".parse().unwrap();
1426 let findings = server.scan_uri(&uri, "let a = x.unwrap(); let b = y.unwrap();\n");
1427 assert_eq!(findings.len(), 2);
1428 }
1429
1430 #[test]
1433 fn test_scan_uri_classified_splits_by_budget() {
1434 let mut sync_rule = make_pack("SYNC-001", "error", r"unwrap\(\)");
1435 sync_rule.rules[0].eval_budget = EvalBudget::Sync;
1436
1437 let mut bg_rule = make_pack("BG-001", "warning", r"todo!");
1438 bg_rule.rules[0].eval_budget = EvalBudget::Background;
1439
1440 let server = TestServer::new(vec![sync_rule, bg_rule]);
1441 let uri: DocumentUri = "file:///tmp/test.rs".parse().unwrap();
1442 let content = concat!("x.unwrap(); todo", "!()\n");
1443 let (sync_r, bg_r) = server.scan_uri_classified(&uri, content);
1444 assert_eq!(sync_r.len(), 1);
1445 assert_eq!(sync_r[0].0.law_id, "SYNC-001");
1446 assert_eq!(bg_r.len(), 1);
1447 assert_eq!(bg_r[0].0.law_id, "BG-001");
1448 }
1449
1450 #[test]
1453 fn test_conformance_unknown_never_collapses() {
1454 let pack = make_pack("TEST-004", "error", r"THIS_NEVER_MATCHES_XYZZY");
1455 let server = TestServer::new(vec![pack]);
1456 let uri: DocumentUri = "file:///tmp/test.rs".parse().unwrap();
1457 let vec = server.conformance_for_content(&uri, "fn clean() {}\n");
1458 assert!(vec.admitted.is_empty());
1459 assert!(vec.refused.is_empty());
1460 for axis in LawAxis::all_named() {
1461 assert!(vec.unknown.contains(axis));
1462 }
1463 }
1464
1465 #[test]
1466 fn test_conformance_error_refuses_domain_axis() {
1467 let pack = make_pack("TEST-005", "error", r"unwrap\(\)");
1468 let server = TestServer::new(vec![pack]);
1469 let uri: DocumentUri = "file:///tmp/test.rs".parse().unwrap();
1470 let vec = server.conformance_for_content(&uri, "x.unwrap();\n");
1471 assert!(vec.refused.contains(&LawAxis::Domain));
1472 assert!(!vec.admitted.contains(&LawAxis::Domain));
1473 }
1474
1475 #[test]
1476 fn test_snapshot_conformance_all_unknown_when_not_open() {
1477 let pack = make_pack("TEST-006", "error", r"unwrap\(\)");
1478 let server = TestServer::new(vec![pack]);
1479 let uri: DocumentUri = "file:///tmp/not_opened.rs".parse().unwrap();
1480 let vec = server.snapshot_conformance(&uri);
1481 assert!(vec.admitted.is_empty());
1482 assert!(vec.refused.is_empty());
1483 for axis in LawAxis::all_named() {
1484 assert!(vec.unknown.contains(axis));
1485 }
1486 }
1487
1488 #[test]
1491 fn test_workspace_index_upsert_and_remove() {
1492 let idx = WorkspaceIndex::new();
1493 idx.upsert("file:///a.rs".to_string(), "let x = 1;".to_string(), 1);
1494 assert!(idx.docs.contains_key("file:///a.rs"));
1495 idx.remove("file:///a.rs");
1496 assert!(!idx.docs.contains_key("file:///a.rs"));
1497 }
1498
1499 #[test]
1500 fn test_workspace_index_seq_increments() {
1501 let idx = WorkspaceIndex::new();
1502 let s0 = idx.seq.load(std::sync::atomic::Ordering::Relaxed);
1503 idx.upsert("file:///a.rs".to_string(), "x".to_string(), 1);
1504 let s1 = idx.seq.load(std::sync::atomic::Ordering::Relaxed);
1505 assert!(s1 > s0);
1506 }
1507
1508 #[test]
1509 fn test_workspace_conformance_refused_propagates() {
1510 let idx = WorkspaceIndex::new();
1511
1512 idx.upsert("file:///a.rs".to_string(), "fn clean() {}".to_string(), 1);
1514 idx.set_conformance(
1515 "file:///a.rs",
1516 ConformanceVector {
1517 admitted: vec![LawAxis::Protocol],
1518 refused: vec![],
1519 unknown: LawAxis::all_named()
1520 .iter()
1521 .filter(|a| **a != LawAxis::Protocol)
1522 .cloned()
1523 .collect(),
1524 score: Some(100.0),
1525 strict_mode: true,
1526 process_quality: None,
1527 ..Default::default()
1528 },
1529 );
1530
1531 idx.upsert("file:///b.rs".to_string(), "x.unwrap()".to_string(), 1);
1533 idx.set_conformance(
1534 "file:///b.rs",
1535 ConformanceVector {
1536 admitted: vec![],
1537 refused: vec![LawAxis::Domain],
1538 unknown: LawAxis::all_named()
1539 .iter()
1540 .filter(|a| **a != LawAxis::Domain)
1541 .cloned()
1542 .collect(),
1543 score: Some(0.0),
1544 strict_mode: true,
1545 process_quality: None,
1546 ..Default::default()
1547 },
1548 );
1549
1550 let wc = idx.workspace_conformance();
1551 assert!(wc.refused.contains(&LawAxis::Domain));
1553 assert!(wc.admitted.contains(&LawAxis::Protocol));
1555 assert!(!wc.admitted.contains(&LawAxis::Domain));
1557 }
1558
1559 #[test]
1562 fn test_snapshot_is_isolated_from_subsequent_mutations() {
1563 let idx = WorkspaceIndex::new();
1564 idx.upsert("file:///a.rs".to_string(), "original".to_string(), 1);
1565 let packs = Arc::new(vec![]);
1566 let snap = idx.snapshot(packs.clone());
1567
1568 idx.upsert("file:///a.rs".to_string(), "mutated".to_string(), 2);
1570
1571 let snap_seq = snap.seq;
1576 let live_seq = idx.seq.load(std::sync::atomic::Ordering::Relaxed);
1577 assert!(live_seq > snap_seq, "seq must advance after mutation");
1578 }
1579
1580 #[test]
1583 fn test_compose_packs_no_deps_preserves_order() {
1584 let a = make_pack("alpha", "error", "x");
1585 let b = make_pack("beta", "warning", "y");
1586 let composed = compose_packs(&[a, b]);
1587 assert!(composed.conflicts.is_empty());
1588 assert_eq!(composed.ordered.len(), 2);
1590 }
1591
1592 #[test]
1593 fn test_compose_packs_dep_ordering() {
1594 let mut base = make_pack("base", "error", "x");
1595 let mut derived = make_pack("derived", "warning", "y");
1596 derived.depends_on = vec!["base".to_string()];
1598 let composed = compose_packs(&[derived, base.clone()]);
1600 assert!(composed.conflicts.is_empty());
1601 assert_eq!(composed.ordered[0].id, "base");
1602 assert_eq!(composed.ordered[1].id, "derived");
1603 let _ = &mut base; }
1605
1606 #[test]
1607 fn test_compose_packs_detects_conflict() {
1608 let mut a = make_pack("pack-a", "error", "x");
1609 let mut b = make_pack("pack-b", "error", "y");
1610 a.rules[0].id = "CONFLICT-001".to_string();
1612 b.rules[0].id = "CONFLICT-001".to_string();
1613 let composed = compose_packs(&[a, b]);
1614 assert_eq!(composed.conflicts.len(), 1);
1615 assert_eq!(composed.conflicts[0].rule_id, "CONFLICT-001");
1616 }
1617
1618 #[test]
1621 fn test_cross_file_rule_fires_when_target_missing() {
1622 let idx = WorkspaceIndex::new();
1623 idx.upsert(
1625 "file:///src/main.rs".to_string(),
1626 "// Uses RULE-001".to_string(),
1627 1,
1628 );
1629 idx.upsert(
1631 "file:///rules/policy.toml".to_string(),
1632 "# no rules here".to_string(),
1633 1,
1634 );
1635
1636 let rule = CrossFileRule {
1637 id: "XF-001".to_string(),
1638 name: "rule-id-must-be-defined".to_string(),
1639 severity: "error".to_string(),
1640 source_glob: "**/*.rs".to_string(),
1641 source_pattern: r"RULE-\d+".to_string(),
1642 target_glob: "**/*.toml".to_string(),
1643 target_pattern: r"RULE-\d+".to_string(),
1644 message: "Rule ID referenced but not defined".to_string(),
1645 rationale: "Every referenced rule must have a definition".to_string(),
1646 };
1647
1648 let packs = Arc::new(vec![]);
1649 let snapshot = idx.snapshot(packs);
1650 let violations = WorkspaceRuleEvaluator::evaluate(&snapshot, &[rule]);
1651 assert_eq!(violations.len(), 1);
1652 assert_eq!(violations[0].rule.id, "XF-001");
1653 assert!(violations[0].matched_text.starts_with("RULE-"));
1654 }
1655
1656 #[test]
1657 fn test_cross_file_rule_no_violation_when_target_present() {
1658 let idx = WorkspaceIndex::new();
1659 idx.upsert(
1660 "file:///src/main.rs".to_string(),
1661 "// Uses RULE-001".to_string(),
1662 1,
1663 );
1664 idx.upsert(
1666 "file:///rules/policy.toml".to_string(),
1667 "id = \"RULE-001\"".to_string(),
1668 1,
1669 );
1670
1671 let rule = CrossFileRule {
1672 id: "XF-002".to_string(),
1673 name: "rule-id-must-be-defined".to_string(),
1674 severity: "error".to_string(),
1675 source_glob: "**/*.rs".to_string(),
1676 source_pattern: r"RULE-\d+".to_string(),
1677 target_glob: "**/*.toml".to_string(),
1678 target_pattern: r"RULE-\d+".to_string(),
1679 message: "Rule ID referenced but not defined".to_string(),
1680 rationale: "Every referenced rule must have a definition".to_string(),
1681 };
1682
1683 let packs = Arc::new(vec![]);
1684 let snapshot = idx.snapshot(packs);
1685 let violations = WorkspaceRuleEvaluator::evaluate(&snapshot, &[rule]);
1686 assert!(
1687 violations.is_empty(),
1688 "no violation when target evidence exists"
1689 );
1690 }
1691
1692 #[test]
1693 fn test_workspace_conformance_all_unknown_when_no_docs() {
1694 let pack = make_pack("TEST-007", "error", r"x");
1695 let server = TestServer::new(vec![pack]);
1696 let wc = server.workspace_conformance();
1697 assert!(wc.admitted.is_empty());
1699 assert!(wc.refused.is_empty());
1700 for axis in LawAxis::all_named() {
1701 assert!(wc.unknown.contains(axis));
1702 }
1703 }
1704
1705 #[test]
1708 fn test_glob_matches_double_star() {
1709 assert!(glob_matches("**/*.rs", "file:///src/main.rs"));
1710 assert!(glob_matches("**/*.toml", "file:///rules/policy.toml"));
1711 }
1712
1713 #[test]
1714 fn test_glob_empty_matches_all() {
1715 assert!(glob_matches("", "anything"));
1716 assert!(glob_matches("**", "file:///src/main.rs"));
1717 }
1718}