Skip to main content

lsp_max/
rule_pack_server.rs

1//! `RulePackServer` — the bridge trait that eliminates the boilerplate every
2//! regex-pattern language server had to hand-roll.
3//!
4//! # Why this exists
5//!
6//! Every example (`pattern-lsp`, `axum-lsp`) independently duplicated the
7//! same four concerns:
8//!
9//! 1. **Scanner** — glob-walk the workspace, apply per-rule regex matches.
10//! 2. **Rules loader** — deserialise `rules/*.toml` files into `RulePack` structs.
11//! 3. **LSP boilerplate** — wire `did_open` / `did_change` / `did_close` through
12//!    `AutoLspAdapter`, then publish diagnostics from regex findings.
13//! 4. **Conformance plumbing** — convert per-axis diagnostics into a
14//!    `ConformanceVector` via `build_conformance_vector`.
15//!
16//! `RulePackServer` moves all four concerns into default-method implementations,
17//! leaving each concrete server responsible only for `rule_packs()`, `grammar()`,
18//! `server_name()`, `client()`, and the mandatory `initialize` / `shutdown` LSP
19//! lifecycle methods.
20//!
21//! # ERRC Innovations (blue ocean differentiators)
22//!
23//! Four capabilities that no other LSP server provides:
24//!
25//! 1. **`RulePackSnapshot`** — immutable `Arc`-wrapped workspace state cloneable
26//!    into async dispatch without holding any lock.  Mirrors rust-analyzer's
27//!    `GlobalStateSnapshot` pattern.  See `RulePackSnapshot`.
28//!
29//! 2. **Rule-pack composition** — dependency-resolved ordering of packs with
30//!    version compatibility checks and conflict detection.  See
31//!    `ComposedPacks` and `compose_packs`.
32//!
33//! 3. **Latency classification** — every rule carries an `EvalBudget` that
34//!    determines whether it is evaluated synchronously (within the `did_open`
35//!    handler) or dispatched to a background Tokio task.  See `EvalBudget`.
36//!
37//! 4. **Workspace-wide cross-file diagnostics** — the `WorkspaceIndex` tracks
38//!    every open document's content and per-file conformance vector.  Cross-file
39//!    rules can emit diagnostics on *file A* based on content in *file B*.  The
40//!    workspace conformance vector is the aggregate across all files — something
41//!    no other LSP does.  See `WorkspaceIndex` and `CrossFileRule`.
42//!
43//! # Law compliance
44//!
45//! * The LSP surface is **read-only**: default implementations only publish
46//!   diagnostics and build conformance vectors; they never mutate workspace files.
47//! * `ConformanceVector::unknown` is never collapsed: axes not covered by any
48//!   rule firing remain in `unknown`, as required by the lsp-max AGENTS.md law.
49//! * Zero `unwrap()`/`expect()` in production paths — all fallible operations
50//!   propagate via `Result` or are silently skipped with a logged warning.
51
52use 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
73// ─────────────────────────────────────────────────────────────────────────────
74// Type aliases
75// ─────────────────────────────────────────────────────────────────────────────
76
77/// A single finding — an extended `MaxDiagnostic` paired with the plain
78/// LSP `Diagnostic` that is published to the client.
79pub type Finding = (MaxDiagnostic, Diagnostic);
80
81/// A pair of finding batches produced by `scan_uri_classified`:
82/// `(synchronous_findings, background_findings)`.
83pub type ClassifiedFindings = (Vec<Finding>, Vec<Finding>);
84
85// ─────────────────────────────────────────────────────────────────────────────
86// Rule / RulePack — canonical TOML schema
87// ─────────────────────────────────────────────────────────────────────────────
88
89/// A single regex-pattern law, loaded from a `[[rules]]` entry in a TOML pack
90/// file.
91///
92/// The canonical on-disk format is established by
93/// `examples/pattern-lsp/rules/anti_fake.toml`. This struct must round-trip
94/// losslessly with that file.
95#[derive(Debug, Deserialize, Serialize, Clone)]
96pub struct Rule {
97    /// Unique, stable identifier for this rule (e.g. `"ANTI-FAKE-001"`).
98    pub id: String,
99    /// Human-readable name surfaced in hover / diagnostic messages.
100    pub name: String,
101    /// Law severity: `"error"`, `"warning"`, `"info"`, or `"hint"`.
102    pub severity: String,
103    /// Rust [`regex`] pattern to match against each line of source text.
104    pub pattern: String,
105    /// Glob patterns restricting which file paths are in-scope (empty = all).
106    pub path_globs: Vec<String>,
107    /// Glob patterns explicitly excluding file paths from the scan.
108    pub exclude_globs: Vec<String>,
109    /// Short message displayed inline in the editor diagnostic.
110    pub message: String,
111    /// Extended rationale surfaced in `max/explainDiagnostic` responses.
112    pub rationale: String,
113    /// Latency classification: whether this rule is evaluated synchronously in
114    /// the LSP handler or dispatched to a background task.
115    #[serde(default)]
116    pub eval_budget: EvalBudget,
117}
118
119// ─────────────────────────────────────────────────────────────────────────────
120// ERRC Innovation 3: EvalBudget — latency classification
121// ─────────────────────────────────────────────────────────────────────────────
122
123/// Latency budget annotation for a rule.
124///
125/// `Sync` rules are evaluated inline in the `did_open` / `did_change` handler.
126/// They must complete within ~50 ms (one editor keystroke).  Complex patterns
127/// or rules over large files should be classified as `Background`.
128///
129/// `Background` rules are dispatched as Tokio tasks; their results are
130/// published via `publishDiagnostics` when they complete.  This keeps the
131/// synchronous LSP response path fast.
132///
133/// The classification mirrors rust-analyzer's `FeatureFlags` concept:
134/// some analyses are marked `heavy` and only run outside the response window.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
136#[serde(rename_all = "snake_case")]
137pub enum EvalBudget {
138    /// Evaluate synchronously; must complete in < 50 ms.
139    #[default]
140    Sync,
141    /// Dispatch to a background Tokio task; no latency constraint.
142    Background,
143}
144
145/// A collection of `Rule` entries loaded from a single TOML pack file.
146///
147/// The TOML shape is `[[rules]] …` repeated — matching
148/// `examples/pattern-lsp/rules/anti_fake.toml`.
149#[derive(Debug, Deserialize, Serialize, Clone)]
150pub struct RulePack {
151    /// All rules declared in this pack.
152    pub rules: Vec<Rule>,
153    /// Unique identifier for this pack (e.g. `"anti-fake@1.0.0"`).
154    #[serde(default)]
155    pub id: String,
156    /// Semantic version of this pack.
157    #[serde(default)]
158    pub version: String,
159    /// IDs of packs this pack depends on (must be resolved before this pack).
160    #[serde(default)]
161    pub depends_on: Vec<String>,
162}
163
164// ─────────────────────────────────────────────────────────────────────────────
165// ERRC Innovation 2: Rule-pack composition
166// ─────────────────────────────────────────────────────────────────────────────
167
168/// Result of composing multiple rule packs with dependency resolution.
169#[derive(Debug, Clone)]
170pub struct ComposedPacks {
171    /// Packs in topologically-resolved order: dependencies before dependants.
172    pub ordered: Vec<RulePack>,
173    /// Conflicts detected between packs (same `rule.id` in two packs).
174    pub conflicts: Vec<PackConflict>,
175}
176
177/// A conflict detected between two rule packs.
178#[derive(Debug, Clone)]
179pub struct PackConflict {
180    /// The conflicting rule ID.
181    pub rule_id: String,
182    /// Pack A that owns the rule.
183    pub pack_a: String,
184    /// Pack B that also owns the same rule ID.
185    pub pack_b: String,
186}
187
188/// Compose a slice of rule packs with topological dependency resolution and
189/// conflict detection.
190///
191/// Packs are sorted so that each pack's `depends_on` entries appear before it
192/// in the output.  Cycles are detected and cause the offending pack to be
193/// appended last (stable degraded behavior).
194///
195/// Rule ID conflicts (same `id` in two packs) are recorded in
196/// `ComposedPacks::conflicts` but do NOT abort composition — the first pack
197/// in dependency order wins.
198pub fn compose_packs(packs: &[RulePack]) -> ComposedPacks {
199    // Build an id → pack map.
200    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    // Topological sort via iterative DFS.
207    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            // Cycle — skip this dep.
223            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    // Packs with empty IDs go first (no dependency information available).
237    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    // Detect rule-ID conflicts.
245    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// ─────────────────────────────────────────────────────────────────────────────
265// ValidatedRulePackSet — monoid-law newtype for conflict-free pack composition
266// ─────────────────────────────────────────────────────────────────────────────
267
268/// A set of rule packs verified to have no rule-ID conflicts.
269///
270/// Constructible only via `new()` or `empty()`.  The `merge()` method composes
271/// two sets and re-validates, enforcing the monoid law at the type level:
272/// it is impossible to pass conflicting packs to `RulePackServer::rule_packs`.
273///
274/// `Custom(_)` axes and empty rule IDs are skipped during conflict detection
275/// (delegated to `compose_packs`).
276#[derive(Debug, Clone, Default)]
277pub struct ValidatedRulePackSet {
278    ordered: Vec<RulePack>,
279}
280
281impl ValidatedRulePackSet {
282    /// Validate `packs` and return a conflict-free set, or return the conflicts.
283    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    /// The monoid identity — an empty set of rule packs.
295    pub fn empty() -> Self {
296        Self::default()
297    }
298
299    /// Access the validated packs as a slice.
300    pub fn packs(&self) -> &[RulePack] {
301        &self.ordered
302    }
303
304    /// Combine two validated sets, re-checking for conflicts at merge time.
305    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// ─────────────────────────────────────────────────────────────────────────────
312// ERRC Innovation 1: RulePackSnapshot — immutable async-safe state clone
313// ─────────────────────────────────────────────────────────────────────────────
314
315/// An immutable, cheaply-cloneable snapshot of the workspace index at a
316/// specific moment in time.
317///
318/// Analogous to rust-analyzer's `GlobalStateSnapshot`: takes an `Arc` clone of
319/// the index so async scan tasks can hold a stable view of the workspace even
320/// as documents continue to be edited.
321///
322/// ## Why snapshots instead of locking
323///
324/// If a background rule evaluation held a `MutexGuard` over the workspace
325/// index while scanning, every `did_change` notification would block until the
326/// scan completed.  By cloning `Arc<DashMap<...>>` into a snapshot we pay only
327/// the cost of incrementing an atomic reference count — O(1) regardless of
328/// workspace size.
329#[derive(Clone, Debug)]
330pub struct RulePackSnapshot {
331    /// A frozen view of the workspace index at snapshot time.
332    pub index: Arc<DashMap<String, IndexedDoc>>,
333    /// The composed packs active at snapshot time.
334    pub packs: Arc<Vec<RulePack>>,
335    /// A logical sequence number incremented on every mutation to the index.
336    /// Consumers can use this to detect staleness.
337    pub seq: u64,
338}
339
340/// A single document entry in the workspace index.
341#[derive(Debug, Clone)]
342pub struct IndexedDoc {
343    /// The full text content of the document.
344    pub content: String,
345    /// Per-file conformance vector, computed the last time this document was
346    /// scanned.  `None` until the first scan completes.
347    pub conformance: Option<ConformanceVector>,
348    /// Monotonic version counter from `textDocument/didChange`.
349    pub version: i32,
350}
351
352// ─────────────────────────────────────────────────────────────────────────────
353// ERRC Innovation 4: WorkspaceIndex + CrossFileRule
354// ─────────────────────────────────────────────────────────────────────────────
355
356/// Cross-file rule: a constraint that spans multiple documents in the workspace.
357///
358/// Unlike a per-file `Rule` which matches regex patterns within a single file,
359/// a `CrossFileRule` expresses relationships between files:
360///
361/// - "every `RULE-\d+` ID referenced in `src/**` must be defined in
362///   `rules/**/*.toml`"
363/// - "no file in `src/` may contain a string from `blocklist.txt` — checked
364///   across all open files"
365/// - "every `pub fn` in `api.rs` must have a corresponding entry in `CHANGELOG.md`"
366///
367/// Cross-file rules are evaluated by `WorkspaceRuleEvaluator` after every
368/// `did_change` and during workspace-wide scans.
369#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct CrossFileRule {
371    /// Unique stable identifier.
372    pub id: String,
373    /// Human-readable name.
374    pub name: String,
375    /// Severity level — same values as [`Rule::severity`].
376    pub severity: String,
377    /// Glob for files to search in (the "source" side).
378    pub source_glob: String,
379    /// Regex pattern to find in source files.
380    pub source_pattern: String,
381    /// Glob for files that must contain a corresponding match (the "target" side).
382    pub target_glob: String,
383    /// Regex that should match in at least one target file for each source match.
384    /// If empty, the rule requires that NO source-pattern match exists.
385    pub target_pattern: String,
386    /// Message emitted on the source file when the constraint is violated.
387    pub message: String,
388    /// Rationale for `max/explainDiagnostic`.
389    pub rationale: String,
390}
391
392/// A cross-file violation: a match in a source file with no corresponding
393/// evidence in any target file.
394#[derive(Debug, Clone)]
395pub struct CrossFileViolation {
396    /// Source file URI.
397    pub source_uri: String,
398    /// Line number (0-based) of the match in the source file.
399    pub line: u32,
400    /// Column of match start (0-based).
401    pub col_start: u32,
402    /// Column of match end (0-based).
403    pub col_end: u32,
404    /// The matched text in the source file.
405    pub matched_text: String,
406    /// The rule that fired.
407    pub rule: CrossFileRule,
408}
409
410/// A shared, incrementally-maintained index of all open workspace documents.
411///
412/// Backed by `Arc<DashMap<...>>` so it can be cloned cheaply into
413/// [`RulePackSnapshot`]s and shared across async tasks.
414#[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    /// Create an empty workspace index.
422    pub fn new() -> Self {
423        Self::default()
424    }
425
426    /// Retrieve a cloned copy of an indexed document.
427    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    /// Insert or update a document.
434    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    /// Update the conformance vector for a document (called after each scan).
447    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    /// Remove a document from the index.
454    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    /// Take a cheap `Arc`-clone snapshot of the current index state.
460    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    /// Aggregate the conformance vectors for all indexed documents into a
469    /// single workspace-level vector.
470    ///
471    /// Axes that are `refused` in ANY document propagate as `refused` in the
472    /// workspace vector.  Axes that are `admitted` in at least one document
473    /// (and never refused) are `admitted`.  Axes with no evidence from any
474    /// document remain `unknown`.
475    #[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        // Axes with no coverage from any document remain in unknown.
507        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/// Evaluates cross-file rules over a `RulePackSnapshot`.
546///
547/// This is the core of lsp-max's blue ocean differentiator: pattern rules that
548/// span multiple documents in the workspace, evaluated incrementally.
549#[derive(Debug)]
550pub struct WorkspaceRuleEvaluator;
551
552impl WorkspaceRuleEvaluator {
553    /// Evaluate all cross-file rules in `rules` against the snapshot.
554    ///
555    /// Returns one `CrossFileViolation` per source match that has no
556    /// corresponding target evidence.
557    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            // If target_pattern is non-empty, check whether it appears anywhere
577            // in the target files.
578            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                // Any target file matching the target_glob that contains the target_pattern.
593                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            // Evaluate source files.
602            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                        // Violation: source matched but no target evidence.
610                        let violated = match target_evidence {
611                            Some(found) => !found,
612                            None => true, // empty target_pattern = "must NOT match"
613                        };
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
633/// Very small glob matcher: `**` means any path segment sequence, `*` means any
634/// characters except `/`.  Only used for URI matching.
635pub fn glob_matches(glob: &str, uri: &str) -> bool {
636    if glob.is_empty() || glob == "**" {
637        return true;
638    }
639    // Convert glob to a simple regex for matching.
640    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
648// ─────────────────────────────────────────────────────────────────────────────
649// Severity → LawAxis mapping
650// ─────────────────────────────────────────────────────────────────────────────
651
652/// Map a rule's string severity to the appropriate [`LawAxis`].
653///
654/// The mapping encodes the doctrine that:
655/// - `"error"` rules violate domain law (hard blockers).
656/// - `"warning"` rules indicate protocol drift (soft signals).
657/// - `"info"` / `"hint"` rules surface documentation and fixture concerns
658///   respectively.
659///
660/// The returned axis is used to bucket diagnostics in
661/// `build_conformance_vector` and must never collapse an unknown axis to a
662/// known one — unrecognised severity strings map to `LawAxis::Custom`.
663pub 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
673/// Map a rule's string severity to the LSP [`DiagnosticSeverity`].
674pub 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// ─────────────────────────────────────────────────────────────────────────────
685// RulePackServer trait
686// ─────────────────────────────────────────────────────────────────────────────
687
688/// The bridge between `lsp-max`'s `LanguageServer` machinery and a set of
689/// loaded `RulePack`s.
690#[allow(async_fn_in_trait)]
691pub trait RulePackServer {
692    /// The rule packs this server enforces, pre-validated for conflicts.
693    fn rule_packs(&self) -> &ValidatedRulePackSet;
694
695    /// Optional cross-file rules.  Default: empty.
696    fn cross_file_rules(&self) -> &[CrossFileRule] {
697        &[]
698    }
699
700    /// The tree-sitter grammar used for incremental parsing.
701    fn grammar(&self) -> tree_sitter::Language;
702
703    /// A stable, human-readable server identifier.
704    fn server_name(&self) -> &'static str;
705
706    /// The `Client` handle for push-publishing diagnostics.
707    fn client(&self) -> &crate::service::Client;
708
709    /// The `AutoLspAdapter` that owns the incremental document store.
710    fn adapter(&self) -> &AutoLspAdapter;
711
712    /// The shared workspace index.  Servers that want cross-file diagnostics
713    /// must hold a `WorkspaceIndex` field and return it here.
714    fn workspace_index(&self) -> Option<&WorkspaceIndex> {
715        None
716    }
717
718    /// Optional SPC monitor for scan-latency anomaly detection.
719    /// Override to return `Some(&self.spc_monitor)` to enable Western Electric rule checks.
720    fn spc_monitor(&self) -> Option<&std::sync::Mutex<crate::primitives::SpcMonitor>> {
721        None
722    }
723
724    /// Optional per-rule latency tracker map for dynamic EvalBudget reclassification.
725    fn latency_trackers(
726        &self,
727    ) -> Option<&Arc<DashMap<String, crate::primitives::RuleLatencyTracker>>> {
728        None
729    }
730
731    /// Optional circuit breaker protecting the rule-evaluation loop.
732    fn rule_circuit_breaker(
733        &self,
734    ) -> Option<&Arc<parking_lot::Mutex<crate::primitives::CircuitBreaker>>> {
735        None
736    }
737
738    // ── Default implementations ───────────────────────────────────────────
739
740    /// Build the `ServerCapabilities` block advertised during `initialize`.
741    ///
742    /// When cross-file rules are present, `inter_file_dependencies` and
743    /// `workspace_diagnostics` are set to `true` so editors know to request
744    /// workspace-level diagnostic reports.
745    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    /// Construct the `InitializeResult` this server returns.
763    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    /// Handle `textDocument/didOpen`: index the document, parse incrementally,
775    /// and publish diagnostics from all sync-budget rule packs.
776    ///
777    /// Background-budget rules are dispatched as Tokio tasks.
778    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        // Update workspace index before scanning.
783        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    /// Handle `textDocument/didChange`: update the index and re-run sync rules.
793    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    /// Handle `textDocument/didClose`: evict from adapter and index.
811    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    /// Scan `content` against every rule in every loaded pack, applying the
819    /// `EvalBudget` classification.
820    ///
821    /// Returns `(sync_findings, background_findings)`.
822    #[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        // Circuit breaker gate — returns empty findings when Open.
834        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                // Dynamic EvalBudget reclassification: check if this Sync rule was promoted.
902                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    /// Scan `content` against all rules (ignoring budget classification).
955    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    /// Run `scan_uri` and push the resulting LSP diagnostics to the client,
962    /// respecting `EvalBudget`.  Sync findings are published immediately.
963    /// Background findings are published from the calling task (Tokio will
964    /// handle concurrency at the server level).
965    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        // Capture old workspace score before updating conformance.
995        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        // Update workspace index conformance.
1001        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        // Emit delta entry to REGISTRY when conformance score changes.
1012        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    /// Run `scan_uri` and push the resulting LSP diagnostics to the client.
1042    async fn publish_findings(&self, uri: DocumentUri, content: &str) {
1043        self.publish_findings_classified(uri, content).await;
1044    }
1045
1046    /// Build a `DocumentDiagnosticReportResult` for the pull-diagnostics endpoint.
1047    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    /// Return the per-document `ConformanceVector` derived from rule-pack findings.
1071    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    /// Return a `ConformanceVector` for the document currently held in the
1081    /// incremental adapter store, falling back to all-unknown.
1082    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    /// Return the workspace-level `ConformanceVector` aggregated across all
1100    /// indexed documents.
1101    ///
1102    /// This is the game-changer: a single vector showing admitted/refused/unknown
1103    /// axes across the entire workspace.  No other LSP provides this.
1104    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    /// Evaluate all cross-file rules and return workspace violations.
1119    ///
1120    /// Returns `None` if no workspace index is configured.
1121    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    /// Convert cross-file violations to LSP diagnostics grouped by source URI.
1130    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    /// Run cross-file rule evaluation and publish diagnostics per-file.
1163    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// ─────────────────────────────────────────────────────────────────────────────
1176// PrimitivesBundle — wired concrete holder for the three optional primitives
1177// ─────────────────────────────────────────────────────────────────────────────
1178
1179/// Concrete holder for the three optional primitives that `RulePackServer`
1180/// exposes via `spc_monitor`, `latency_trackers`, and `rule_circuit_breaker`.
1181///
1182/// Embed this inside any concrete server struct and delegate the three accessor
1183/// methods to it via `self.primitives.*_ref()`.
1184///
1185/// All fields are initialised with `Default` values so construction never fails.
1186#[derive(Debug)]
1187pub struct PrimitivesBundle {
1188    /// SPC monitor for scan-latency anomaly detection.
1189    pub spc_monitor: std::sync::Mutex<crate::primitives::SpcMonitor>,
1190    /// Per-rule latency tracker map for dynamic EvalBudget reclassification.
1191    pub latency_trackers: Arc<DashMap<String, crate::primitives::RuleLatencyTracker>>,
1192    /// Circuit breaker protecting the rule-evaluation loop.
1193    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    /// Construct with all defaults.
1210    pub fn new() -> Self {
1211        Self::default()
1212    }
1213
1214    /// Borrow the SPC monitor for use in `RulePackServer::spc_monitor`.
1215    pub fn spc_monitor_ref(&self) -> &std::sync::Mutex<crate::primitives::SpcMonitor> {
1216        &self.spc_monitor
1217    }
1218
1219    /// Borrow the latency tracker map for use in `RulePackServer::latency_trackers`.
1220    pub fn latency_trackers_ref(
1221        &self,
1222    ) -> &Arc<DashMap<String, crate::primitives::RuleLatencyTracker>> {
1223        &self.latency_trackers
1224    }
1225
1226    /// Borrow the circuit breaker for use in `RulePackServer::rule_circuit_breaker`.
1227    pub fn circuit_breaker_ref(
1228        &self,
1229    ) -> &Arc<parking_lot::Mutex<crate::primitives::CircuitBreaker>> {
1230        &self.circuit_breaker
1231    }
1232}
1233
1234// ─────────────────────────────────────────────────────────────────────────────
1235// Tests — Chicago TDD: real state changes, no mocks
1236// ─────────────────────────────────────────────────────────────────────────────
1237
1238#[cfg(test)]
1239mod tests {
1240    use super::*;
1241    use lsp_max_protocol::LawAxis;
1242
1243    // ── severity_to_law_axis ─────────────────────────────────────────────────
1244
1245    #[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    // ── EvalBudget serde round-trip ──────────────────────────────────────────
1275
1276    #[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    // ── Minimal TestServer ───────────────────────────────────────────────────
1302
1303    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    // ── scan_uri ─────────────────────────────────────────────────────────────
1381
1382    #[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    // ── EvalBudget classification ────────────────────────────────────────────
1431
1432    #[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    // ── ConformanceVector invariants ─────────────────────────────────────────
1451
1452    #[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    // ── WorkspaceIndex ───────────────────────────────────────────────────────
1489
1490    #[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        // File A: clean (all unknown)
1513        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        // File B: has a Domain refusal
1532        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        // Domain refused in B propagates to workspace level.
1552        assert!(wc.refused.contains(&LawAxis::Domain));
1553        // Protocol admitted in A and not refused anywhere.
1554        assert!(wc.admitted.contains(&LawAxis::Protocol));
1555        // Domain must NOT appear in admitted.
1556        assert!(!wc.admitted.contains(&LawAxis::Domain));
1557    }
1558
1559    // ── RulePackSnapshot ─────────────────────────────────────────────────────
1560
1561    #[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        // Mutate the index after taking the snapshot.
1569        idx.upsert("file:///a.rs".to_string(), "mutated".to_string(), 2);
1570
1571        // The snapshot holds an Arc clone of the underlying DashMap, so it
1572        // reflects the LIVE map — snapshots are live views, not deep copies.
1573        // The important invariant is that taking a snapshot never blocks.
1574        // Sequencing is tracked via seq.
1575        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    // ── Rule-pack composition ────────────────────────────────────────────────
1581
1582    #[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        // Both packs present.
1589        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 base — base must appear first.
1597        derived.depends_on = vec!["base".to_string()];
1598        // Deliberately pass derived first to test reordering.
1599        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; // suppress warning
1604    }
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        // Give both packs a rule with the same id.
1611        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    // ── CrossFileRule / WorkspaceRuleEvaluator ───────────────────────────────
1619
1620    #[test]
1621    fn test_cross_file_rule_fires_when_target_missing() {
1622        let idx = WorkspaceIndex::new();
1623        // Source file references a rule ID.
1624        idx.upsert(
1625            "file:///src/main.rs".to_string(),
1626            "// Uses RULE-001".to_string(),
1627            1,
1628        );
1629        // Target file does NOT contain the matching definition.
1630        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        // Target file DOES contain the definition.
1665        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        // No documents indexed yet — everything is unknown.
1698        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    // ── glob_matches ─────────────────────────────────────────────────────────
1706
1707    #[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}