1use std::collections::HashSet;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use crate::db::{Database, EdgeSymbol};
14use crate::error::{CtxError, Result};
15use crate::gitutil;
16use crate::index;
17use crate::json::SymbolRef;
18use crate::rules::{self, CompiledRules, Endpoint, FileDep, RulesFile, Violation};
19
20pub struct CheckContext {
22 pub compiled: CompiledRules,
23 pub indexed: Vec<String>,
24 pub db: Database,
25 pub rules_path: PathBuf,
26}
27
28pub fn load_context(root: &Path, rules_path: Option<PathBuf>) -> Result<CheckContext> {
34 let rules_path = match rules_path {
35 Some(p) if p.is_absolute() => p,
36 Some(p) => root.join(p),
37 None => root.join(rules::DEFAULT_RULES_PATH),
38 };
39
40 if !rules_path.exists() {
41 return Err(CtxError::Other(format!(
42 "rules file not found: {}\n\
43 Create it with a [layers] table and [[rules.*]] entries; \
44 run 'ctx check --help' for a full example.",
45 rules_path.display()
46 )));
47 }
48 let content = fs::read_to_string(&rules_path)?;
49 let parsed: RulesFile = toml::from_str(&content).map_err(|e| {
50 CtxError::Other(format!(
51 "invalid rules file {}: {}",
52 rules_path.display(),
53 e
54 ))
55 })?;
56 let compiled = CompiledRules::compile(parsed)?;
57
58 let db = index::open_database(root)?;
59 let indexed = db.get_indexed_files()?;
60 compiled.validate(&indexed)?;
61
62 Ok(CheckContext {
63 compiled,
64 indexed,
65 db,
66 rules_path,
67 })
68}
69
70pub fn collect_violations(
75 root: &Path,
76 context: &CheckContext,
77 against: Option<&str>,
78) -> Result<Vec<Violation>> {
79 let changed = match against {
80 Some(reference) => Some(gitutil::changed_files_against_in(root, reference)?),
81 None => None,
82 };
83
84 let indexed_set: HashSet<String> = context.indexed.iter().cloned().collect();
85 let deps = build_deps(&context.db, &indexed_set)?;
86 let symbol_metrics = context.db.symbol_metrics()?;
87 let file_complexity = context.db.file_complexity()?;
88
89 Ok(rules::evaluate(
90 &context.compiled,
91 &deps,
92 &symbol_metrics,
93 &file_complexity,
94 changed.as_ref(),
95 ))
96}
97
98fn build_deps(db: &Database, indexed: &HashSet<String>) -> Result<Vec<FileDep>> {
105 let mut deps = Vec::new();
106
107 for edge in db.get_cross_file_edges()? {
109 deps.push(FileDep {
110 from: Endpoint::Symbol(symbol_ref(&edge.source)),
111 to: Endpoint::Symbol(symbol_ref(&edge.target)),
112 kind: edge.kind,
113 line: edge.line,
114 });
115 }
116
117 for (file, imports) in db.get_file_imports()? {
119 for import in imports {
120 if let Some(target) = resolve_import(indexed, &file, &import.from) {
121 if target != file {
122 deps.push(FileDep {
123 from: Endpoint::File { file: file.clone() },
124 to: Endpoint::File { file: target },
125 kind: "import".to_string(),
126 line: None,
127 });
128 }
129 }
130 }
131 }
132
133 for (source, target_path, line) in db.get_import_edges()? {
136 if !indexed.contains(&source) {
137 continue;
138 }
139 if let Some(target) = resolve_import(indexed, &source, &target_path) {
140 if target != source {
141 deps.push(FileDep {
142 from: Endpoint::File {
143 file: source.clone(),
144 },
145 to: Endpoint::File { file: target },
146 kind: "import".to_string(),
147 line,
148 });
149 }
150 }
151 }
152
153 Ok(deps)
154}
155
156fn symbol_ref(s: &EdgeSymbol) -> SymbolRef {
157 SymbolRef {
158 name: s.name.clone(),
159 qualified_name: s.qualified_name.clone(),
160 kind: s.kind.clone(),
161 file: s.file_path.clone(),
162 line_start: s.line_start,
163 line_end: s.line_end,
164 }
165}
166
167const JS_SUFFIXES: &[&str] = &[
172 "",
173 ".ts",
174 ".tsx",
175 ".js",
176 ".jsx",
177 "/index.ts",
178 "/index.tsx",
179 "/index.js",
180];
181const SOL_SUFFIXES: &[&str] = &["", ".sol"];
182
183fn resolve_import(indexed: &HashSet<String>, importing_file: &str, from: &str) -> Option<String> {
188 let ext = Path::new(importing_file)
189 .extension()
190 .and_then(|e| e.to_str())
191 .unwrap_or("");
192 match ext {
193 "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" => {
194 resolve_relative(indexed, importing_file, from, JS_SUFFIXES)
195 }
196 "sol" => resolve_relative(indexed, importing_file, from, SOL_SUFFIXES),
197 "rs" => resolve_rust(indexed, importing_file, from),
198 "py" => resolve_python(indexed, importing_file, from),
199 "go" => resolve_go(indexed, from),
200 _ => None,
201 }
202}
203
204fn parent_dir(path: &str) -> &str {
206 match path.rfind('/') {
207 Some(i) => &path[..i],
208 None => "",
209 }
210}
211
212fn normalize(path: &str) -> Option<String> {
215 let mut out: Vec<&str> = Vec::new();
216 for seg in path.split('/') {
217 match seg {
218 "" | "." => {}
219 ".." => {
220 out.pop()?;
221 }
222 s => out.push(s),
223 }
224 }
225 if out.is_empty() {
226 None
227 } else {
228 Some(out.join("/"))
229 }
230}
231
232fn resolve_relative(
235 indexed: &HashSet<String>,
236 importing_file: &str,
237 from: &str,
238 suffixes: &[&str],
239) -> Option<String> {
240 if !from.starts_with("./") && !from.starts_with("../") {
241 return None;
242 }
243 let base = normalize(&format!("{}/{}", parent_dir(importing_file), from))?;
244 for suffix in suffixes {
245 let candidate = format!("{}{}", base, suffix);
246 if indexed.contains(&candidate) {
247 return Some(candidate);
248 }
249 }
250 None
251}
252
253fn resolve_rust(indexed: &HashSet<String>, importing_file: &str, from: &str) -> Option<String> {
256 let segs: Vec<&str> = from.split("::").filter(|s| !s.is_empty()).collect();
257 let first = *segs.first()?;
258
259 let (base, rest): (String, &[&str]) = match first {
260 "crate" => ("src".to_string(), &segs[1..]),
261 "self" => (rust_self_dir(importing_file), &segs[1..]),
262 "super" => {
263 let mut dir = rust_self_dir(importing_file);
264 let mut i = 0;
265 while i < segs.len() && segs[i] == "super" {
266 dir = parent_dir(&dir).to_string();
267 i += 1;
268 }
269 (dir, &segs[i..])
270 }
271 _ => return None, };
273
274 for k in (1..=rest.len()).rev() {
277 let module_path = join_path(&base, &rest[..k].join("/"));
278 for candidate in [
279 format!("{}.rs", module_path),
280 format!("{}/mod.rs", module_path),
281 ] {
282 if indexed.contains(&candidate) {
283 return Some(candidate);
284 }
285 }
286 }
287
288 if !base.is_empty() {
291 for candidate in [format!("{}/mod.rs", base), format!("{}.rs", base)] {
292 if indexed.contains(&candidate) {
293 return Some(candidate);
294 }
295 }
296 if base == "src" {
297 for candidate in ["src/lib.rs".to_string(), "src/main.rs".to_string()] {
298 if indexed.contains(&candidate) {
299 return Some(candidate);
300 }
301 }
302 }
303 }
304 None
305}
306
307fn rust_self_dir(importing_file: &str) -> String {
311 let file_name = importing_file.rsplit('/').next().unwrap_or(importing_file);
312 if matches!(file_name, "mod.rs" | "lib.rs" | "main.rs") {
313 parent_dir(importing_file).to_string()
314 } else {
315 importing_file
316 .strip_suffix(".rs")
317 .unwrap_or(importing_file)
318 .to_string()
319 }
320}
321
322fn join_path(base: &str, rest: &str) -> String {
323 if base.is_empty() {
324 rest.to_string()
325 } else if rest.is_empty() {
326 base.to_string()
327 } else {
328 format!("{}/{}", base, rest)
329 }
330}
331
332fn resolve_python(indexed: &HashSet<String>, importing_file: &str, from: &str) -> Option<String> {
336 if let Some(mut rest) = from.strip_prefix('.') {
337 let mut dir = parent_dir(importing_file).to_string();
339 while let Some(r) = rest.strip_prefix('.') {
340 dir = parent_dir(&dir).to_string();
341 rest = r;
342 }
343 let segs: Vec<&str> = rest.split('.').filter(|s| !s.is_empty()).collect();
344 if segs.is_empty() {
345 if dir.is_empty() {
347 return None;
348 }
349 let candidate = format!("{}/__init__.py", dir);
350 return indexed.contains(&candidate).then_some(candidate);
351 }
352 probe_python(indexed, &join_path(&dir, &segs.join("/")))
353 } else {
354 let rel = from.replace('.', "/");
355 probe_python(indexed, &rel).or_else(|| probe_python(indexed, &format!("src/{}", rel)))
356 }
357}
358
359fn probe_python(indexed: &HashSet<String>, base: &str) -> Option<String> {
360 [format!("{}.py", base), format!("{}/__init__.py", base)]
361 .into_iter()
362 .find(|candidate| indexed.contains(candidate))
363}
364
365fn resolve_go(indexed: &HashSet<String>, from: &str) -> Option<String> {
370 let segs: Vec<&str> = from.split('/').filter(|s| !s.is_empty()).collect();
371 if segs.is_empty() {
372 return None;
373 }
374
375 let mut go_files: Vec<&String> = indexed.iter().filter(|f| f.ends_with(".go")).collect();
376 go_files.sort();
377
378 for k in (1..=segs.len()).rev() {
379 let suffix = segs[segs.len() - k..].join("/");
380 let hit = go_files.iter().find(|f| {
381 let dir = parent_dir(f);
382 dir == suffix || dir.ends_with(&format!("/{}", suffix))
383 });
384 if let Some(f) = hit {
385 return Some((*f).clone());
386 }
387 }
388 None
389}
390
391#[cfg(test)]
396mod tests {
397 use super::*;
398 use crate::db::{Edge, EdgeKind, FileRecord, Symbol, SymbolKind, Visibility};
399 use crate::rules::RuleKind;
400
401 fn indexed(files: &[&str]) -> HashSet<String> {
404 files.iter().map(|s| s.to_string()).collect()
405 }
406
407 #[test]
408 fn test_resolve_import_typescript() {
409 let idx = indexed(&[
410 "src/domain/order.ts",
411 "src/infra/db.ts",
412 "src/app/util/index.ts",
413 "src/legacy/api.js",
414 ]);
415 let cases = [
416 (
418 "src/domain/order.ts",
419 "../infra/db",
420 Some("src/infra/db.ts"),
421 ),
422 (
423 "src/domain/order.ts",
424 "../infra/db.ts",
425 Some("src/infra/db.ts"),
426 ),
427 (
429 "src/domain/order.ts",
430 "../app/util",
431 Some("src/app/util/index.ts"),
432 ),
433 (
435 "src/domain/order.ts",
436 "../legacy/api",
437 Some("src/legacy/api.js"),
438 ),
439 ("src/infra/db.ts", "./db", Some("src/infra/db.ts")),
441 ("src/domain/order.ts", "react", None),
443 ("src/domain/order.ts", "@scope/pkg", None),
444 ("src/domain/order.ts", "../../../outside", None),
446 ("src/domain/order.ts", "./nope", None),
448 ];
449 for (file, from, expected) in cases {
450 assert_eq!(
451 resolve_import(&idx, file, from).as_deref(),
452 expected,
453 "{} imports {:?}",
454 file,
455 from
456 );
457 }
458 }
459
460 #[test]
461 fn test_resolve_import_rust() {
462 let idx = indexed(&[
463 "src/lib.rs",
464 "src/main.rs",
465 "src/domain/order.rs",
466 "src/domain/mod.rs",
467 "src/infra/mod.rs",
468 "src/infra/db.rs",
469 ]);
470 let cases = [
471 ("src/main.rs", "crate::infra::db", Some("src/infra/db.rs")),
473 (
475 "src/main.rs",
476 "crate::infra::db::Pool",
477 Some("src/infra/db.rs"),
478 ),
479 ("src/main.rs", "crate::domain", Some("src/domain/mod.rs")),
481 ("src/domain/order.rs", "crate", Some("src/lib.rs")),
483 (
485 "src/infra/db.rs",
486 "super::super::domain::order",
487 Some("src/domain/order.rs"),
488 ),
489 (
491 "src/domain/mod.rs",
492 "self::order",
493 Some("src/domain/order.rs"),
494 ),
495 (
497 "src/domain/order.rs",
498 "super::Registry",
499 Some("src/domain/mod.rs"),
500 ),
501 ("src/main.rs", "std::collections::HashMap", None),
503 ("src/main.rs", "serde::Serialize", None),
504 ];
505 for (file, from, expected) in cases {
506 assert_eq!(
507 resolve_import(&idx, file, from).as_deref(),
508 expected,
509 "{} imports {:?}",
510 file,
511 from
512 );
513 }
514 }
515
516 #[test]
517 fn test_resolve_import_python() {
518 let idx = indexed(&[
519 "app/__init__.py",
520 "app/models.py",
521 "app/api/routes.py",
522 "src/pkg/util.py",
523 ]);
524 let cases = [
525 ("app/api/routes.py", "app.models", Some("app/models.py")),
527 ("app/api/routes.py", "app", Some("app/__init__.py")),
529 ("app/models.py", "pkg.util", Some("src/pkg/util.py")),
531 ("app/api/routes.py", ".routes", Some("app/api/routes.py")),
533 ("app/api/routes.py", "..models", Some("app/models.py")),
535 ("app/models.py", ".", Some("app/__init__.py")),
537 ("app/models.py", "django.db", None),
539 ];
540 for (file, from, expected) in cases {
541 assert_eq!(
542 resolve_import(&idx, file, from).as_deref(),
543 expected,
544 "{} imports {:?}",
545 file,
546 from
547 );
548 }
549 }
550
551 #[test]
552 fn test_resolve_import_go_and_solidity() {
553 let idx = indexed(&[
554 "cmd/server/main.go",
555 "pkg/util/strings.go",
556 "pkg/util/numbers.go",
557 "contracts/Token.sol",
558 "contracts/lib/Math.sol",
559 ]);
560 assert_eq!(
562 resolve_import(&idx, "cmd/server/main.go", "example.com/proj/pkg/util").as_deref(),
563 Some("pkg/util/numbers.go")
564 );
565 assert_eq!(
566 resolve_import(&idx, "cmd/server/main.go", "example.com/other/nowhere"),
567 None
568 );
569 assert_eq!(
571 resolve_import(&idx, "contracts/Token.sol", "./lib/Math.sol").as_deref(),
572 Some("contracts/lib/Math.sol")
573 );
574 assert_eq!(
575 resolve_import(
576 &idx,
577 "contracts/Token.sol",
578 "@openzeppelin/contracts/token.sol"
579 ),
580 None
581 );
582 }
583
584 #[test]
585 fn test_resolve_import_unknown_extension() {
586 let idx = indexed(&["a.yaml", "b.yaml"]);
587 assert_eq!(resolve_import(&idx, "a.yaml", "./b"), None);
588 }
589
590 fn make_symbol(id: &str, name: &str, file: &str, kind: SymbolKind) -> Symbol {
593 Symbol {
594 id: id.to_string(),
595 file_path: file.to_string(),
596 name: name.to_string(),
597 qualified_name: None,
598 kind,
599 visibility: Visibility::Public,
600 signature: None,
601 brief: None,
602 docstring: None,
603 line_start: 1,
604 line_end: 5,
605 col_start: 0,
606 col_end: 0,
607 parent_id: None,
608 source: None,
609 }
610 }
611
612 fn make_call(source: &str, target: &str) -> Edge {
613 Edge {
614 source_id: source.to_string(),
615 target_id: Some(target.to_string()),
616 target_name: target.rsplit("::").next().unwrap_or(target).to_string(),
617 kind: EdgeKind::Calls,
618 line: Some(3),
619 col: None,
620 context: None,
621 }
622 }
623
624 fn add_file(db: &Database, path: &str) {
625 db.upsert_file(
626 &FileRecord {
627 path: path.to_string(),
628 content_hash: format!("hash-{}", path),
629 size_bytes: 1,
630 language: Some("rust".to_string()),
631 last_indexed: 0,
632 },
633 None,
634 )
635 .unwrap();
636 }
637
638 #[test]
639 fn test_limit_rules_against_in_memory_db() {
640 let db = Database::open_in_memory().unwrap();
641 add_file(&db, "src/hub.rs");
642 add_file(&db, "src/a.rs");
643 add_file(&db, "src/b.rs");
644 add_file(&db, "src/c.rs");
645
646 db.insert_symbol(&make_symbol(
648 "src/hub.rs::hub",
649 "hub",
650 "src/hub.rs",
651 SymbolKind::Function,
652 ))
653 .unwrap();
654 for f in ["a", "b", "c"] {
655 let id = format!("src/{}.rs::{}", f, f);
656 db.insert_symbol(&make_symbol(
657 &id,
658 f,
659 &format!("src/{}.rs", f),
660 SymbolKind::Function,
661 ))
662 .unwrap();
663 db.insert_edge(&make_call(&id, "src/hub.rs::hub")).unwrap();
664 }
665
666 let rules_file: RulesFile = toml::from_str(
667 r#"
668 [[rules.limit]]
669 metric = "fan_in"
670 max = 2
671 "#,
672 )
673 .unwrap();
674 let compiled = CompiledRules::compile(rules_file).unwrap();
675 compiled.validate(&db.get_indexed_files().unwrap()).unwrap();
676
677 let metrics = db.symbol_metrics().unwrap();
678 let files = db.file_complexity().unwrap();
679 let violations = rules::evaluate(&compiled, &[], &metrics, &files, None);
680
681 assert_eq!(violations.len(), 1);
682 let v = &violations[0];
683 assert_eq!(v.rule, RuleKind::Limit);
684 assert_eq!(v.file, "src/hub.rs");
685 assert_eq!(v.value, Some(3));
686 assert_eq!(v.max, Some(2));
687 }
688
689 #[test]
690 fn test_build_deps_from_in_memory_db() {
691 let db = Database::open_in_memory().unwrap();
692 add_file(&db, "src/a.rs");
693 add_file(&db, "src/b.rs");
694
695 db.insert_symbol(&make_symbol(
696 "src/a.rs::a",
697 "a",
698 "src/a.rs",
699 SymbolKind::Function,
700 ))
701 .unwrap();
702 db.insert_symbol(&make_symbol(
703 "src/b.rs::b",
704 "b",
705 "src/b.rs",
706 SymbolKind::Function,
707 ))
708 .unwrap();
709 db.insert_edge(&make_call("src/a.rs::a", "src/b.rs::b"))
711 .unwrap();
712 db.insert_edge(&Edge {
714 source_id: "src/a.rs::a".to_string(),
715 target_id: None,
716 target_name: "external".to_string(),
717 kind: EdgeKind::Calls,
718 line: Some(4),
719 col: None,
720 context: None,
721 })
722 .unwrap();
723
724 db.upsert_module(&crate::db::ModuleInfo {
726 file_path: "src/b.rs".to_string(),
727 module_name: Some("b".to_string()),
728 exports: vec![],
729 imports: vec![crate::db::ImportInfo {
730 from: "crate::a".to_string(),
731 names: vec!["a".to_string()],
732 alias: None,
733 }],
734 })
735 .unwrap();
736
737 let indexed: HashSet<String> = db.get_indexed_files().unwrap().into_iter().collect();
738 let deps = build_deps(&db, &indexed).unwrap();
739
740 assert_eq!(deps.len(), 2);
741 assert_eq!(deps[0].kind, "calls");
743 assert_eq!(deps[0].from.file(), "src/a.rs");
744 assert_eq!(deps[0].to.file(), "src/b.rs");
745 assert!(matches!(deps[0].from, Endpoint::Symbol(_)));
746 assert_eq!(deps[1].kind, "import");
748 assert_eq!(deps[1].from.file(), "src/b.rs");
749 assert_eq!(deps[1].to.file(), "src/a.rs");
750 assert!(matches!(deps[1].from, Endpoint::File { .. }));
751 }
752}