1use std::collections::{BTreeMap, HashSet};
17
18use globset::{Glob, GlobSet, GlobSetBuilder};
19use serde::{Deserialize, Serialize};
20
21use crate::db::{FileComplexity, SymbolMetrics};
22use crate::error::{CtxError, Result};
23use crate::json::SymbolRef;
24
25pub const DEFAULT_RULES_PATH: &str = ".ctx/rules.toml";
27
28pub const SUPPORTED_VERSION: u32 = 1;
30
31#[derive(Debug, Default, Deserialize, Serialize)]
37#[serde(deny_unknown_fields)]
38pub struct RulesFile {
39 #[serde(default = "default_version")]
41 pub version: u32,
42
43 #[serde(default)]
45 pub layers: BTreeMap<String, Vec<String>>,
46
47 #[serde(default)]
49 pub rules: RuleGroups,
50}
51
52fn default_version() -> u32 {
53 SUPPORTED_VERSION
54}
55
56#[derive(Debug, Default, Deserialize, Serialize)]
58#[serde(deny_unknown_fields)]
59pub struct RuleGroups {
60 #[serde(default)]
61 pub forbidden: Vec<ForbiddenRule>,
62
63 #[serde(default)]
64 pub allowed_dependents: Vec<AllowedDependentsRule>,
65
66 #[serde(default)]
67 pub limit: Vec<LimitRule>,
68
69 #[serde(default)]
70 pub no_new_dependents: Vec<NoNewDependentsRule>,
71}
72
73#[derive(Debug, Deserialize, Serialize)]
75#[serde(deny_unknown_fields)]
76pub struct ForbiddenRule {
77 pub from: String,
78 pub to: String,
79 #[serde(default)]
80 pub reason: Option<String>,
81}
82
83#[derive(Debug, Deserialize, Serialize)]
90#[serde(deny_unknown_fields)]
91pub struct AllowedDependentsRule {
92 pub layer: String,
93 pub only: Vec<String>,
94 #[serde(default)]
95 pub reason: Option<String>,
96}
97
98#[derive(Debug, Deserialize, Serialize)]
100#[serde(deny_unknown_fields)]
101pub struct LimitRule {
102 pub metric: Metric,
103 #[serde(default)]
104 pub scope: Scope,
105 pub max: i64,
106 #[serde(default)]
107 pub exclude: Vec<String>,
108}
109
110#[derive(Debug, Deserialize, Serialize)]
112#[serde(deny_unknown_fields)]
113pub struct NoNewDependentsRule {
114 pub paths: Vec<String>,
115 #[serde(default)]
116 pub reason: Option<String>,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
121#[serde(rename_all = "snake_case")]
122pub enum Metric {
123 FanIn,
124 FanOut,
125 Complexity,
126 FileSymbols,
127}
128
129impl Metric {
130 pub fn as_str(self) -> &'static str {
131 match self {
132 Metric::FanIn => "fan_in",
133 Metric::FanOut => "fan_out",
134 Metric::Complexity => "complexity",
135 Metric::FileSymbols => "file_symbols",
136 }
137 }
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
142#[serde(rename_all = "snake_case")]
143pub enum Scope {
144 #[default]
145 Symbol,
146 File,
147}
148
149impl Scope {
150 pub fn as_str(self) -> &'static str {
151 match self {
152 Scope::Symbol => "symbol",
153 Scope::File => "file",
154 }
155 }
156}
157
158#[derive(Debug)]
164pub struct CompiledRules {
165 pub file: RulesFile,
166 layer_globs: Vec<(String, GlobSet)>,
168 limit_excludes: Vec<GlobSet>,
170 frozen_paths: Vec<GlobSet>,
172}
173
174impl CompiledRules {
175 pub fn compile(file: RulesFile) -> Result<Self> {
179 if file.version != SUPPORTED_VERSION {
180 return Err(CtxError::Other(format!(
181 "unsupported rules version {} (this build supports version {})",
182 file.version, SUPPORTED_VERSION
183 )));
184 }
185
186 let mut layer_globs = Vec::new();
187 for (name, patterns) in &file.layers {
188 let set = build_globset(patterns, &format!("layer '{}'", name))?;
189 layer_globs.push((name.clone(), set));
190 }
191
192 let mut limit_excludes = Vec::new();
193 for (i, rule) in file.rules.limit.iter().enumerate() {
194 limit_excludes.push(build_globset(
195 &rule.exclude,
196 &format!("rules.limit[{}].exclude", i),
197 )?);
198 }
199
200 let mut frozen_paths = Vec::new();
201 for (i, rule) in file.rules.no_new_dependents.iter().enumerate() {
202 frozen_paths.push(build_globset(
203 &rule.paths,
204 &format!("rules.no_new_dependents[{}].paths", i),
205 )?);
206 }
207
208 Ok(CompiledRules {
209 file,
210 layer_globs,
211 limit_excludes,
212 frozen_paths,
213 })
214 }
215
216 pub fn layer_of(&self, path: &str) -> Option<&str> {
220 self.layer_globs
221 .iter()
222 .find(|(_, set)| set.is_match(path))
223 .map(|(name, _)| name.as_str())
224 }
225
226 pub fn layers_of(&self, path: &str) -> Vec<&str> {
228 self.layer_globs
229 .iter()
230 .filter(|(_, set)| set.is_match(path))
231 .map(|(name, _)| name.as_str())
232 .collect()
233 }
234
235 pub fn validate(&self, indexed_files: &[String]) -> Result<()> {
242 for rule in &self.file.rules.forbidden {
244 self.require_layer(&rule.from, "rules.forbidden.from")?;
245 self.require_layer(&rule.to, "rules.forbidden.to")?;
246 }
247 for rule in &self.file.rules.allowed_dependents {
248 self.require_layer(&rule.layer, "rules.allowed_dependents.layer")?;
249 for only in &rule.only {
250 self.require_layer(only, "rules.allowed_dependents.only")?;
251 }
252 }
253
254 for rule in &self.file.rules.limit {
256 if rule.metric == Metric::FileSymbols && rule.scope == Scope::Symbol {
257 return Err(CtxError::Other(
258 "rules.limit: metric \"file_symbols\" requires scope = \"file\" \
259 (it counts symbols per file and has no per-symbol value)"
260 .to_string(),
261 ));
262 }
263 }
264
265 for path in indexed_files {
267 let layers = self.layers_of(path);
268 if layers.len() >= 2 {
269 return Err(CtxError::Other(format!(
270 "layer overlap: file '{}' matches layers [{}]; \
271 each file may belong to at most one layer",
272 path,
273 layers.join(", ")
274 )));
275 }
276 }
277
278 Ok(())
279 }
280
281 fn require_layer(&self, name: &str, context: &str) -> Result<()> {
282 if self.file.layers.contains_key(name) {
283 Ok(())
284 } else {
285 let known: Vec<&str> = self.file.layers.keys().map(|k| k.as_str()).collect();
286 Err(CtxError::Other(format!(
287 "{}: unknown layer '{}' (declared layers: [{}])",
288 context,
289 name,
290 known.join(", ")
291 )))
292 }
293 }
294
295 pub fn limit_exclude(&self, i: usize) -> &GlobSet {
297 &self.limit_excludes[i]
298 }
299
300 pub fn frozen_path(&self, i: usize) -> &GlobSet {
302 &self.frozen_paths[i]
303 }
304
305 pub fn layer_file_counts(&self, indexed_files: &[String]) -> Vec<(String, usize)> {
307 self.layer_globs
308 .iter()
309 .map(|(name, set)| {
310 let n = indexed_files.iter().filter(|f| set.is_match(f)).count();
311 (name.clone(), n)
312 })
313 .collect()
314 }
315}
316
317fn build_globset(patterns: &[String], what: &str) -> Result<GlobSet> {
318 let mut builder = GlobSetBuilder::new();
319 for pattern in patterns {
320 let glob = Glob::new(pattern).map_err(|e| {
321 CtxError::Other(format!("invalid glob '{}' in {}: {}", pattern, what, e))
322 })?;
323 builder.add(glob);
324 }
325 builder
326 .build()
327 .map_err(|e| CtxError::Other(format!("failed to build globs for {}: {}", what, e)))
328}
329
330#[derive(Debug, Clone, Serialize)]
337#[serde(untagged)]
338pub enum Endpoint {
339 Symbol(SymbolRef),
340 File { file: String },
341}
342
343impl Endpoint {
344 pub fn file(&self) -> &str {
346 match self {
347 Endpoint::Symbol(s) => &s.file,
348 Endpoint::File { file } => file,
349 }
350 }
351}
352
353#[derive(Debug, Clone)]
355pub struct FileDep {
356 pub from: Endpoint,
357 pub to: Endpoint,
358 pub kind: String,
360 pub line: Option<i64>,
363}
364
365#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
367#[serde(rename_all = "snake_case")]
368pub enum RuleKind {
369 Forbidden,
370 AllowedDependents,
371 Limit,
372 NoNewDependents,
373}
374
375impl RuleKind {
376 pub fn as_str(self) -> &'static str {
377 match self {
378 RuleKind::Forbidden => "forbidden",
379 RuleKind::AllowedDependents => "allowed_dependents",
380 RuleKind::Limit => "limit",
381 RuleKind::NoNewDependents => "no_new_dependents",
382 }
383 }
384}
385
386#[derive(Debug, Clone, Serialize)]
392pub struct Violation {
393 pub rule: RuleKind,
394 pub rule_id: String,
397 pub reason: String,
398 pub message: String,
400 pub file: String,
402 #[serde(skip_serializing_if = "Option::is_none")]
403 pub line: Option<i64>,
404 #[serde(skip_serializing_if = "Option::is_none")]
405 pub from: Option<Endpoint>,
406 #[serde(skip_serializing_if = "Option::is_none")]
407 pub to: Option<Endpoint>,
408 #[serde(skip_serializing_if = "Option::is_none")]
409 pub subject: Option<Endpoint>,
410 #[serde(skip_serializing_if = "Option::is_none")]
411 pub metric: Option<Metric>,
412 #[serde(skip_serializing_if = "Option::is_none")]
413 pub scope: Option<Scope>,
414 #[serde(skip_serializing_if = "Option::is_none")]
415 pub value: Option<i64>,
416 #[serde(skip_serializing_if = "Option::is_none")]
417 pub max: Option<i64>,
418}
419
420impl Violation {
421 fn dep(rule: RuleKind, rule_id: String, reason: String, dep: &FileDep) -> Violation {
422 let location = match dep.line {
423 Some(line) => format!("{}:{}", dep.from.file(), line),
424 None => dep.from.file().to_string(),
425 };
426 let target = match &dep.to {
427 Endpoint::Symbol(s) => format!("{} [{} {}]", s.file, dep.kind, s.name),
428 Endpoint::File { file } => format!("{} [{}]", file, dep.kind),
429 };
430 let message = format!("{} -> {}", location, target);
431 Violation {
432 rule,
433 rule_id,
434 reason,
435 message,
436 file: dep.from.file().to_string(),
437 line: dep.line,
438 from: Some(dep.from.clone()),
439 to: Some(dep.to.clone()),
440 subject: None,
441 metric: None,
442 scope: None,
443 value: None,
444 max: None,
445 }
446 }
447
448 fn endpoint_files(&self) -> Vec<&str> {
450 let mut files = vec![self.file.as_str()];
451 for endpoint in [&self.from, &self.to, &self.subject].into_iter().flatten() {
452 files.push(endpoint.file());
453 }
454 files
455 }
456}
457
458pub fn evaluate(
472 compiled: &CompiledRules,
473 deps: &[FileDep],
474 symbol_metrics: &[SymbolMetrics],
475 file_complexity: &[FileComplexity],
476 changed: Option<&HashSet<String>>,
477) -> Vec<Violation> {
478 let mut violations = Vec::new();
479
480 violations.extend(eval_forbidden(compiled, deps));
481 violations.extend(eval_allowed_dependents(compiled, deps));
482 violations.extend(eval_limits(compiled, symbol_metrics, file_complexity));
483 violations.extend(eval_no_new_dependents(compiled, deps, changed));
484
485 if let Some(changed) = changed {
486 violations.retain(|v| {
487 v.rule == RuleKind::NoNewDependents
489 || v.endpoint_files().iter().any(|f| changed.contains(*f))
490 });
491 }
492
493 violations
494}
495
496fn eval_forbidden(compiled: &CompiledRules, deps: &[FileDep]) -> Vec<Violation> {
498 let mut violations = Vec::new();
499 for rule in &compiled.file.rules.forbidden {
500 let rule_id = format!("forbidden: {} -> {}", rule.from, rule.to);
501 let reason = rule
502 .reason
503 .clone()
504 .unwrap_or_else(|| format!("layer '{}' must not depend on '{}'", rule.from, rule.to));
505 for dep in deps {
506 if compiled.layer_of(dep.from.file()) == Some(rule.from.as_str())
507 && compiled.layer_of(dep.to.file()) == Some(rule.to.as_str())
508 {
509 violations.push(Violation::dep(
510 RuleKind::Forbidden,
511 rule_id.clone(),
512 reason.clone(),
513 dep,
514 ));
515 }
516 }
517 }
518 violations
519}
520
521fn eval_allowed_dependents(compiled: &CompiledRules, deps: &[FileDep]) -> Vec<Violation> {
526 let mut violations = Vec::new();
527 for rule in &compiled.file.rules.allowed_dependents {
528 let rule_id = format!(
529 "allowed_dependents: {} only [{}]",
530 rule.layer,
531 rule.only.join(", ")
532 );
533 let reason = rule.reason.clone().unwrap_or_else(|| {
534 format!(
535 "only [{}] may depend on layer '{}'",
536 rule.only.join(", "),
537 rule.layer
538 )
539 });
540 for dep in deps {
541 if compiled.layer_of(dep.to.file()) != Some(rule.layer.as_str()) {
542 continue;
543 }
544 let Some(from_layer) = compiled.layer_of(dep.from.file()) else {
545 continue; };
547 if from_layer == rule.layer || rule.only.iter().any(|o| o == from_layer) {
548 continue;
549 }
550 violations.push(Violation::dep(
551 RuleKind::AllowedDependents,
552 rule_id.clone(),
553 format!("{} (found dependent in layer '{}')", reason, from_layer),
554 dep,
555 ));
556 }
557 }
558 violations
559}
560
561fn eval_limits(
563 compiled: &CompiledRules,
564 symbol_metrics: &[SymbolMetrics],
565 file_complexity: &[FileComplexity],
566) -> Vec<Violation> {
567 let mut violations = Vec::new();
568
569 for (i, rule) in compiled.file.rules.limit.iter().enumerate() {
570 let exclude = compiled.limit_exclude(i);
571 let rule_id = format!(
572 "limit: {} <= {} ({})",
573 rule.metric.as_str(),
574 rule.max,
575 rule.scope.as_str()
576 );
577
578 match rule.scope {
579 Scope::Symbol => {
580 for m in symbol_metrics {
582 if exclude.is_match(&m.file_path) {
583 continue;
584 }
585 let value = symbol_metric_value(rule.metric, m);
586 if value > rule.max {
587 violations.push(limit_violation(
588 rule,
589 rule_id.clone(),
590 Endpoint::Symbol(symbol_ref_of(m)),
591 format!("{}:{}", m.file_path, m.line_start),
592 m.file_path.clone(),
593 Some(m.line_start),
594 Some(&m.name),
595 value,
596 ));
597 }
598 }
599 }
600 Scope::File => {
601 let per_file: BTreeMap<String, i64> = if rule.metric == Metric::FileSymbols {
604 file_complexity
605 .iter()
606 .map(|f| (f.file_path.clone(), f.symbol_count))
607 .collect()
608 } else {
609 let mut sums: BTreeMap<String, i64> = BTreeMap::new();
610 for m in symbol_metrics {
611 *sums.entry(m.file_path.clone()).or_insert(0) +=
612 symbol_metric_value(rule.metric, m);
613 }
614 sums
615 };
616
617 for (file, value) in per_file {
618 if exclude.is_match(&file) || value <= rule.max {
619 continue;
620 }
621 violations.push(limit_violation(
622 rule,
623 rule_id.clone(),
624 Endpoint::File { file: file.clone() },
625 file.clone(),
626 file,
627 None,
628 None,
629 value,
630 ));
631 }
632 }
633 }
634 }
635
636 violations
637}
638
639fn symbol_metric_value(metric: Metric, m: &SymbolMetrics) -> i64 {
640 match metric {
641 Metric::FanIn => m.fan_in,
642 Metric::FanOut => m.fan_out,
643 Metric::Complexity => m.complexity,
644 Metric::FileSymbols => 0,
647 }
648}
649
650#[allow(clippy::too_many_arguments)]
651fn limit_violation(
652 rule: &LimitRule,
653 rule_id: String,
654 subject: Endpoint,
655 location: String,
656 file: String,
657 line: Option<i64>,
658 name: Option<&str>,
659 value: i64,
660) -> Violation {
661 let what = match name {
662 Some(name) => format!("{} ({})", location, name),
663 None => location,
664 };
665 Violation {
666 rule: RuleKind::Limit,
667 rule_id,
668 reason: format!(
669 "{} {} exceeds max {}",
670 rule.metric.as_str(),
671 value,
672 rule.max
673 ),
674 message: format!(
675 "{}: {} {} exceeds max {}",
676 what,
677 rule.metric.as_str(),
678 value,
679 rule.max
680 ),
681 file,
682 line,
683 from: None,
684 to: None,
685 subject: Some(subject),
686 metric: Some(rule.metric),
687 scope: Some(rule.scope),
688 value: Some(value),
689 max: Some(rule.max),
690 }
691}
692
693fn symbol_ref_of(m: &SymbolMetrics) -> SymbolRef {
694 SymbolRef {
695 name: m.name.clone(),
696 qualified_name: m.qualified_name.clone(),
697 kind: m.kind.clone(),
698 file: m.file_path.clone(),
699 line_start: m.line_start,
700 line_end: m.line_end,
701 }
702}
703
704fn eval_no_new_dependents(
710 compiled: &CompiledRules,
711 deps: &[FileDep],
712 changed: Option<&HashSet<String>>,
713) -> Vec<Violation> {
714 let mut violations = Vec::new();
715 for (i, rule) in compiled.file.rules.no_new_dependents.iter().enumerate() {
716 let paths = compiled.frozen_path(i);
717 let rule_id = format!("no_new_dependents: [{}]", rule.paths.join(", "));
718 let reason = rule
719 .reason
720 .clone()
721 .unwrap_or_else(|| format!("[{}] must not gain new dependents", rule.paths.join(", ")));
722 for dep in deps {
723 if !paths.is_match(dep.to.file()) || paths.is_match(dep.from.file()) {
724 continue;
725 }
726 if let Some(changed) = changed {
727 if !changed.contains(dep.from.file()) {
728 continue;
729 }
730 }
731 violations.push(Violation::dep(
732 RuleKind::NoNewDependents,
733 rule_id.clone(),
734 reason.clone(),
735 dep,
736 ));
737 }
738 }
739 violations
740}
741
742#[cfg(test)]
747mod tests {
748 use super::*;
749
750 const FULL_RULES: &str = r#"
751version = 1
752
753[layers]
754domain = ["src/domain/**"]
755application = ["src/app/**"]
756infrastructure = ["src/infra/**", "src/db/**"]
757
758[[rules.forbidden]]
759from = "domain"
760to = "infrastructure"
761reason = "Domain layer must stay persistence-agnostic"
762
763[[rules.allowed_dependents]]
764layer = "infrastructure"
765only = ["application"]
766
767[[rules.limit]]
768metric = "fan_in"
769scope = "symbol"
770max = 25
771exclude = ["src/core/**"]
772
773[[rules.limit]]
774metric = "file_symbols"
775scope = "file"
776max = 50
777
778[[rules.no_new_dependents]]
779paths = ["src/legacy/**"]
780reason = "Legacy module is frozen; do not add new callers"
781"#;
782
783 fn compile(toml_src: &str) -> CompiledRules {
784 let file: RulesFile = toml::from_str(toml_src).unwrap();
785 CompiledRules::compile(file).unwrap()
786 }
787
788 fn file_dep(from: &str, to: &str) -> FileDep {
789 FileDep {
790 from: Endpoint::File {
791 file: from.to_string(),
792 },
793 to: Endpoint::File {
794 file: to.to_string(),
795 },
796 kind: "import".to_string(),
797 line: None,
798 }
799 }
800
801 fn symbol_dep(from_file: &str, from_name: &str, to_file: &str, to_name: &str) -> FileDep {
802 let sym = |file: &str, name: &str| {
803 Endpoint::Symbol(SymbolRef {
804 name: name.to_string(),
805 qualified_name: None,
806 kind: "function".to_string(),
807 file: file.to_string(),
808 line_start: 1,
809 line_end: 2,
810 })
811 };
812 FileDep {
813 from: sym(from_file, from_name),
814 to: sym(to_file, to_name),
815 kind: "calls".to_string(),
816 line: Some(1),
817 }
818 }
819
820 fn metrics(
821 name: &str,
822 file: &str,
823 fan_in: i64,
824 fan_out: i64,
825 complexity: i64,
826 ) -> SymbolMetrics {
827 SymbolMetrics {
828 id: format!("{}::{}", file, name),
829 name: name.to_string(),
830 qualified_name: None,
831 kind: "function".to_string(),
832 file_path: file.to_string(),
833 line_start: 10,
834 line_end: 20,
835 fan_in,
836 fan_out,
837 complexity,
838 }
839 }
840
841 #[test]
844 fn test_parse_full_rules_file() {
845 let file: RulesFile = toml::from_str(FULL_RULES).unwrap();
846 assert_eq!(file.version, 1);
847 assert_eq!(file.layers.len(), 3);
848 assert_eq!(file.layers["infrastructure"].len(), 2);
849 assert_eq!(file.rules.forbidden.len(), 1);
850 assert_eq!(file.rules.forbidden[0].from, "domain");
851 assert_eq!(file.rules.allowed_dependents.len(), 1);
852 assert_eq!(file.rules.limit.len(), 2);
853 assert_eq!(file.rules.limit[0].metric, Metric::FanIn);
854 assert_eq!(file.rules.limit[0].scope, Scope::Symbol);
855 assert_eq!(file.rules.limit[0].max, 25);
856 assert_eq!(file.rules.no_new_dependents.len(), 1);
857 }
858
859 #[test]
860 fn test_parse_defaults() {
861 let file: RulesFile = toml::from_str("").unwrap();
863 assert_eq!(file.version, 1);
864 assert!(file.layers.is_empty());
865 assert!(file.rules.forbidden.is_empty());
866
867 let file: RulesFile = toml::from_str(
869 r#"
870 [[rules.limit]]
871 metric = "fan_out"
872 max = 10
873 "#,
874 )
875 .unwrap();
876 assert_eq!(file.rules.limit[0].scope, Scope::Symbol);
877 assert!(file.rules.limit[0].exclude.is_empty());
878 }
879
880 #[test]
881 fn test_parse_rejects_unknown_fields_and_bad_values() {
882 let err = toml::from_str::<RulesFile>(
884 r#"
885 [[rules.forbidden]]
886 form = "domain"
887 to = "infrastructure"
888 "#,
889 )
890 .unwrap_err();
891 assert!(err.to_string().contains("form"), "err: {}", err);
892
893 assert!(toml::from_str::<RulesFile>("[[rules.forbid]]\nx = 1").is_err());
895
896 let err = toml::from_str::<RulesFile>(
898 r#"
899 [[rules.limit]]
900 metric = "fan_inn"
901 max = 5
902 "#,
903 )
904 .unwrap_err();
905 assert!(err.to_string().contains("fan_inn"), "err: {}", err);
906
907 assert!(toml::from_str::<RulesFile>("[layers\ndomain = 1").is_err());
909 }
910
911 #[test]
912 fn test_compile_rejects_unsupported_version() {
913 let file: RulesFile = toml::from_str("version = 2").unwrap();
914 let err = CompiledRules::compile(file).unwrap_err();
915 assert!(err.to_string().contains("version 2"), "err: {}", err);
916 }
917
918 #[test]
919 fn test_compile_rejects_invalid_glob() {
920 let file: RulesFile = toml::from_str(
921 r#"
922 [layers]
923 domain = ["src/[oops"]
924 "#,
925 )
926 .unwrap();
927 let err = CompiledRules::compile(file).unwrap_err();
928 assert!(err.to_string().contains("src/[oops"), "err: {}", err);
929 }
930
931 #[test]
934 fn test_validate_ok() {
935 let compiled = compile(FULL_RULES);
936 let files = vec![
937 "src/domain/order.ts".to_string(),
938 "src/app/checkout.ts".to_string(),
939 "src/infra/db.ts".to_string(),
940 "src/unlayered.ts".to_string(),
941 ];
942 compiled.validate(&files).unwrap();
943 }
944
945 #[test]
946 fn test_validate_unknown_layer() {
947 let compiled = compile(
948 r#"
949 [layers]
950 domain = ["src/domain/**"]
951
952 [[rules.forbidden]]
953 from = "domain"
954 to = "infrastruture"
955 "#,
956 );
957 let err = compiled.validate(&[]).unwrap_err();
958 let msg = err.to_string();
959 assert!(
960 msg.contains("unknown layer 'infrastruture'"),
961 "err: {}",
962 msg
963 );
964 assert!(msg.contains("domain"), "err: {}", msg);
965 }
966
967 #[test]
968 fn test_validate_layer_overlap_names_file_and_layers() {
969 let compiled = compile(
970 r#"
971 [layers]
972 domain = ["src/domain/**"]
973 everything = ["src/**"]
974 "#,
975 );
976 let files = vec!["src/domain/order.ts".to_string()];
977 let err = compiled.validate(&files).unwrap_err();
978 let msg = err.to_string();
979 assert!(msg.contains("src/domain/order.ts"), "err: {}", msg);
980 assert!(msg.contains("domain"), "err: {}", msg);
981 assert!(msg.contains("everything"), "err: {}", msg);
982 }
983
984 #[test]
985 fn test_validate_file_symbols_symbol_scope_is_error() {
986 let compiled = compile(
987 r#"
988 [[rules.limit]]
989 metric = "file_symbols"
990 scope = "symbol"
991 max = 50
992 "#,
993 );
994 let err = compiled.validate(&[]).unwrap_err();
995 assert!(err.to_string().contains("file_symbols"), "err: {}", err);
996 }
997
998 #[test]
999 fn test_layer_of() {
1000 let compiled = compile(FULL_RULES);
1001 assert_eq!(compiled.layer_of("src/domain/order.ts"), Some("domain"));
1002 assert_eq!(compiled.layer_of("src/db/pool.ts"), Some("infrastructure"));
1003 assert_eq!(compiled.layer_of("src/other/x.ts"), None);
1004 }
1005
1006 #[test]
1009 fn test_eval_forbidden() {
1010 let compiled = compile(FULL_RULES);
1011 let deps = vec![
1012 file_dep("src/domain/order.ts", "src/infra/db.ts"), file_dep("src/app/checkout.ts", "src/infra/db.ts"), file_dep("src/domain/order.ts", "src/domain/item.ts"), symbol_dep("src/domain/pay.ts", "pay", "src/db/pool.ts", "query"), ];
1017 let violations = eval_forbidden(&compiled, &deps);
1018 assert_eq!(violations.len(), 2);
1019 assert_eq!(violations[0].rule, RuleKind::Forbidden);
1020 assert_eq!(violations[0].file, "src/domain/order.ts");
1021 assert_eq!(
1022 violations[0].reason,
1023 "Domain layer must stay persistence-agnostic"
1024 );
1025 assert_eq!(violations[1].file, "src/domain/pay.ts");
1026 assert_eq!(violations[1].line, Some(1));
1027 }
1028
1029 #[test]
1030 fn test_eval_allowed_dependents_exempts_unlayered_and_self() {
1031 let compiled = compile(FULL_RULES);
1032 let deps = vec![
1033 file_dep("src/app/checkout.ts", "src/infra/db.ts"), file_dep("src/domain/order.ts", "src/infra/db.ts"), file_dep("src/scripts/tool.ts", "src/infra/db.ts"), file_dep("src/infra/cache.ts", "src/infra/db.ts"), ];
1038 let violations = eval_allowed_dependents(&compiled, &deps);
1039 assert_eq!(violations.len(), 1);
1040 assert_eq!(violations[0].rule, RuleKind::AllowedDependents);
1041 assert_eq!(violations[0].file, "src/domain/order.ts");
1042 assert!(
1043 violations[0].reason.contains("domain"),
1044 "{}",
1045 violations[0].reason
1046 );
1047 }
1048
1049 #[test]
1050 fn test_eval_limit_symbol_scope_with_exclude() {
1051 let compiled = compile(FULL_RULES); let symbol_metrics = vec![
1053 metrics("hot", "src/app/hub.ts", 30, 0, 30), metrics("ok", "src/app/x.ts", 25, 0, 25), metrics("core", "src/core/bus.ts", 99, 0, 99), ];
1057 let violations = eval_limits(&compiled, &symbol_metrics, &[]);
1058 assert_eq!(violations.len(), 1);
1059 let v = &violations[0];
1060 assert_eq!(v.rule, RuleKind::Limit);
1061 assert_eq!(v.metric, Some(Metric::FanIn));
1062 assert_eq!(v.value, Some(30));
1063 assert_eq!(v.max, Some(25));
1064 assert_eq!(v.file, "src/app/hub.ts");
1065 match v.subject.as_ref().unwrap() {
1066 Endpoint::Symbol(s) => assert_eq!(s.name, "hot"),
1067 other => panic!("expected symbol subject, got {:?}", other),
1068 }
1069 }
1070
1071 #[test]
1072 fn test_eval_limit_file_scope_sums_and_counts() {
1073 let compiled = compile(
1074 r#"
1075 [[rules.limit]]
1076 metric = "fan_out"
1077 scope = "file"
1078 max = 10
1079
1080 [[rules.limit]]
1081 metric = "file_symbols"
1082 scope = "file"
1083 max = 2
1084 "#,
1085 );
1086 let symbol_metrics = vec![
1087 metrics("a", "src/big.ts", 0, 7, 14),
1088 metrics("b", "src/big.ts", 0, 6, 12), metrics("c", "src/small.ts", 0, 3, 6),
1090 ];
1091 let file_complexity = vec![
1092 FileComplexity {
1093 file_path: "src/big.ts".to_string(),
1094 complexity: 26,
1095 fan_out: 13,
1096 symbol_count: 3, },
1098 FileComplexity {
1099 file_path: "src/small.ts".to_string(),
1100 complexity: 6,
1101 fan_out: 3,
1102 symbol_count: 1,
1103 },
1104 ];
1105 let violations = eval_limits(&compiled, &symbol_metrics, &file_complexity);
1106 assert_eq!(violations.len(), 2);
1107 assert_eq!(violations[0].metric, Some(Metric::FanOut));
1108 assert_eq!(violations[0].value, Some(13));
1109 assert_eq!(violations[0].file, "src/big.ts");
1110 assert_eq!(violations[1].metric, Some(Metric::FileSymbols));
1111 assert_eq!(violations[1].value, Some(3));
1112 }
1113
1114 #[test]
1115 fn test_eval_no_new_dependents_without_against_reports_all_inbound() {
1116 let compiled = compile(FULL_RULES);
1117 let deps = vec![
1118 file_dep("src/app/checkout.ts", "src/legacy/billing.ts"), file_dep("src/legacy/a.ts", "src/legacy/billing.ts"), file_dep("src/app/checkout.ts", "src/app/cart.ts"), ];
1122 let violations = eval_no_new_dependents(&compiled, &deps, None);
1123 assert_eq!(violations.len(), 1);
1124 assert_eq!(violations[0].rule, RuleKind::NoNewDependents);
1125 assert_eq!(violations[0].file, "src/app/checkout.ts");
1126 assert_eq!(
1127 violations[0].reason,
1128 "Legacy module is frozen; do not add new callers"
1129 );
1130 }
1131
1132 #[test]
1133 fn test_eval_no_new_dependents_with_against_requires_changed_source() {
1134 let compiled = compile(FULL_RULES);
1135 let deps = vec![
1136 file_dep("src/app/old.ts", "src/legacy/billing.ts"), file_dep("src/app/new.ts", "src/legacy/billing.ts"), ];
1139 let changed: HashSet<String> = ["src/app/new.ts".to_string()].into();
1140 let violations = eval_no_new_dependents(&compiled, &deps, Some(&changed));
1141 assert_eq!(violations.len(), 1);
1142 assert_eq!(violations[0].file, "src/app/new.ts");
1143 }
1144
1145 #[test]
1146 fn test_evaluate_against_filters_by_endpoint() {
1147 let compiled = compile(FULL_RULES);
1148 let deps = vec![
1149 file_dep("src/domain/old.ts", "src/infra/db.ts"), file_dep("src/domain/new.ts", "src/infra/db.ts"), ];
1152 let symbol_metrics = vec![
1153 metrics("hot", "src/app/hub.ts", 30, 0, 30), metrics("warm", "src/app/new2.ts", 40, 0, 40), ];
1156
1157 let all = evaluate(&compiled, &deps, &symbol_metrics, &[], None);
1159 assert_eq!(all.len(), 6);
1161
1162 let changed: HashSet<String> = [
1163 "src/domain/new.ts".to_string(),
1164 "src/app/new2.ts".to_string(),
1165 ]
1166 .into();
1167 let filtered = evaluate(&compiled, &deps, &symbol_metrics, &[], Some(&changed));
1168 assert_eq!(filtered.len(), 3);
1169 assert!(filtered
1170 .iter()
1171 .all(|v| v.endpoint_files().iter().any(|f| changed.contains(*f))));
1172 }
1173
1174 #[test]
1175 fn test_violation_json_shape() {
1176 let compiled = compile(FULL_RULES);
1177 let deps = vec![symbol_dep(
1178 "src/domain/pay.ts",
1179 "pay",
1180 "src/infra/db.ts",
1181 "query",
1182 )];
1183 let violations = eval_forbidden(&compiled, &deps);
1184 let value = serde_json::to_value(&violations[0]).unwrap();
1185 assert_eq!(value["rule"], "forbidden");
1186 assert_eq!(
1187 value["reason"],
1188 "Domain layer must stay persistence-agnostic"
1189 );
1190 assert_eq!(value["from"]["name"], "pay");
1192 assert_eq!(value["from"]["file"], "src/domain/pay.ts");
1193 assert_eq!(value["to"]["kind"], "function");
1194 assert!(value.get("subject").is_none());
1196 assert!(value.get("metric").is_none());
1197
1198 let file_violations = eval_forbidden(
1200 &compiled,
1201 &[file_dep("src/domain/order.ts", "src/infra/db.ts")],
1202 );
1203 let value = serde_json::to_value(&file_violations[0]).unwrap();
1204 assert_eq!(
1205 value["from"],
1206 serde_json::json!({"file": "src/domain/order.ts"})
1207 );
1208 }
1209}