Skip to main content

ctx/
check.rs

1//! Architecture-rules check engine (backs `ctx check` and the
2//! `check_violations` metric of `ctx score`).
3//!
4//! Loads `.ctx/rules.toml`, builds a file-level dependency set from the code
5//! intelligence index (resolved call/implements/extends/uses edges plus
6//! resolved imports), evaluates the rules, and returns violations. The CLI
7//! wrapper in `src/commands/check.rs` handles all printing.
8
9use 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
20/// Everything loaded and validated, ready for evaluation.
21pub struct CheckContext {
22    pub compiled: CompiledRules,
23    pub indexed: Vec<String>,
24    pub db: Database,
25    pub rules_path: PathBuf,
26}
27
28/// Load and compile the rules file, open the index, and validate.
29///
30/// `rules_path` defaults to [`rules::DEFAULT_RULES_PATH`] under `root`.
31/// Errors (missing or invalid rules file, unknown/overlapping layers,
32/// missing index) map to exit code 2 in the CLI.
33pub 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
70/// Build the dependency set and evaluate all rules.
71///
72/// With `against`, only violations touching files changed relative to that
73/// git reference are reported.
74pub 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
98// ============================================================================
99// Dependency set
100// ============================================================================
101
102/// Build the cross-file dependency set: resolved symbol edges plus imports
103/// resolved against the set of indexed files.
104fn build_deps(db: &Database, indexed: &HashSet<String>) -> Result<Vec<FileDep>> {
105    let mut deps = Vec::new();
106
107    // Resolved symbol-to-symbol edges (calls/implements/extends/uses).
108    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    // Imports recorded in module metadata (TS/JS, Rust, Python, Solidity).
118    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    // File-level import edges (Go records imports in the edges table with the
134    // importing file path as the source).
135    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
167// ============================================================================
168// Import resolution
169// ============================================================================
170
171const 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
183/// Resolve an import specifier to an indexed file, or `None` when the target
184/// is external / unresolvable (unresolvable imports are silently skipped).
185///
186/// Heuristics are keyed off the importing file's extension.
187fn 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
204/// Directory part of a slash-separated path (`""` for root-level files).
205fn parent_dir(path: &str) -> &str {
206    match path.rfind('/') {
207        Some(i) => &path[..i],
208        None => "",
209    }
210}
211
212/// Resolve `.`/`..` segments. Returns `None` when the path escapes the root
213/// or normalizes to nothing.
214fn 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
232/// Relative-path resolution for TS/JS and Solidity. Non-relative specifiers
233/// are third-party packages and resolve to `None`.
234fn 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
253/// Rust `use` path resolution: `crate::` from `src/`, `self::`/`super::`
254/// relative to the importing file's module, bare paths are external crates.
255fn 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, // external crate (or 2015-style bare module)
272    };
273
274    // Drop trailing segments one by one: the last segment(s) may be items
275    // (types, functions) rather than modules.
276    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    // `use crate::Item` / `use super::Item`: the target is the parent module
289    // file itself.
290    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
307/// The directory that holds child modules of the importing Rust file:
308/// `src/a/b.rs` (module `a::b`) -> `src/a/b`; `src/a/mod.rs`, `src/lib.rs`,
309/// and `src/main.rs` -> their own directory.
310fn 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
332/// Python import resolution: dotted absolute modules (also probed under
333/// `src/`), and `from .`-style relative imports resolved from the importing
334/// file's directory.
335fn resolve_python(indexed: &HashSet<String>, importing_file: &str, from: &str) -> Option<String> {
336    if let Some(mut rest) = from.strip_prefix('.') {
337        // One leading dot = current package; each extra dot pops a level.
338        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            // `from . import x`: the target is the package itself.
346            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
365/// Go import resolution: import paths name a package directory
366/// (e.g. `example.com/proj/pkg/util`). Match the longest trailing-directory
367/// suffix against indexed directories and return the first `.go` file in the
368/// matched directory.
369fn 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// ============================================================================
392// Tests
393// ============================================================================
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use crate::db::{Edge, EdgeKind, FileRecord, Symbol, SymbolKind, Visibility};
399    use crate::rules::RuleKind;
400
401    // ---------- import resolver ----------
402
403    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            // relative sibling with probing
417            (
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            // directory import -> index file
428            (
429                "src/domain/order.ts",
430                "../app/util",
431                Some("src/app/util/index.ts"),
432            ),
433            // .js target
434            (
435                "src/domain/order.ts",
436                "../legacy/api",
437                Some("src/legacy/api.js"),
438            ),
439            // same-dir
440            ("src/infra/db.ts", "./db", Some("src/infra/db.ts")),
441            // third-party
442            ("src/domain/order.ts", "react", None),
443            ("src/domain/order.ts", "@scope/pkg", None),
444            // escapes the root
445            ("src/domain/order.ts", "../../../outside", None),
446            // missing
447            ("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            // crate:: module path
472            ("src/main.rs", "crate::infra::db", Some("src/infra/db.rs")),
473            // trailing item segment dropped
474            (
475                "src/main.rs",
476                "crate::infra::db::Pool",
477                Some("src/infra/db.rs"),
478            ),
479            // module dir with mod.rs
480            ("src/main.rs", "crate::domain", Some("src/domain/mod.rs")),
481            // bare crate item -> lib.rs
482            ("src/domain/order.rs", "crate", Some("src/lib.rs")),
483            // super:: from a nested module
484            (
485                "src/infra/db.rs",
486                "super::super::domain::order",
487                Some("src/domain/order.rs"),
488            ),
489            // self:: children (src/domain/order.rs would own src/domain/order/)
490            (
491                "src/domain/mod.rs",
492                "self::order",
493                Some("src/domain/order.rs"),
494            ),
495            // super item falls back to the parent module file
496            (
497                "src/domain/order.rs",
498                "super::Registry",
499                Some("src/domain/mod.rs"),
500            ),
501            // external crates
502            ("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            // absolute dotted path
526            ("app/api/routes.py", "app.models", Some("app/models.py")),
527            // package __init__
528            ("app/api/routes.py", "app", Some("app/__init__.py")),
529            // src/ prefix probing
530            ("app/models.py", "pkg.util", Some("src/pkg/util.py")),
531            // relative: one dot = current package
532            ("app/api/routes.py", ".routes", Some("app/api/routes.py")),
533            // relative: two dots pop a level
534            ("app/api/routes.py", "..models", Some("app/models.py")),
535            // `from . import x` -> package itself
536            ("app/models.py", ".", Some("app/__init__.py")),
537            // third-party
538            ("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        // Go: tail-dir suffix match, first file in the matched dir.
561        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        // Solidity: relative like TS.
570        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    // ---------- limit rules against a hand-built database ----------
591
592    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        // hub() is called by a(), b(), c() -> fan_in 3.
647        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        // Cross-file resolved call.
710        db.insert_edge(&make_call("src/a.rs::a", "src/b.rs::b"))
711            .unwrap();
712        // Unresolved edge and same-file edge must be ignored.
713        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        // Module-level import (Rust style).
725        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        // The resolved call edge, with symbol endpoints.
742        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        // The resolved import, with file endpoints.
747        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}