diff --git a/CHANGELOG.md b/CHANGELOG.md index e3c6ea7..f9c8186 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- `ctx check`: architecture rules engine driven by `.ctx/rules.toml` -- declare layers as glob patterns over indexed files, then enforce `forbidden` layer dependencies, `allowed_dependents` whitelists, `limit` metric thresholds (fan-in / fan-out / complexity / file symbols), and `no_new_dependents` frozen paths; supports `--against REF` to scope violations to changed files, `--list` to inspect parsed rules, and `--json`; exits 1 when violations are found (see `ctx check --help` for a full example) - Global `--json` flag: `search`, `semantic`, `query find/callers/deps/graph/impact/stats/files`, and `explain` emit a single machine-readable JSON document wrapped in a stable envelope (`ctx_version`, `command`, `generated_at`, `data`); see `docs/json-output.md` - Index schema versioning via SQLite `PRAGMA user_version`; opening an index built with an incompatible schema now fails with a clear "run `ctx index --force`" message (pre-existing indexes are stamped silently) - Shared complexity metrics (fan-in / fan-out / complexity) available directly from the SQLite index, mirroring the DuckDB formula diff --git a/docs/json-output.md b/docs/json-output.md index d6a6855..ed02a6e 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -262,6 +262,71 @@ Disambiguation: when several symbols match the name and no `--file` filter is gi When the symbol is not found, `symbol` is `null` and the counts are `0`; when several symbols match without filters, `ambiguous` lists the candidates. +### `check` + +`ctx check [--rules PATH] [--against REF] --json` + +```json +{ + "rules_path": ".ctx/rules.toml", + "against": "main", + "summary": { "violations": 2, "rules_violated": 2 }, + "violations": [ + { + "rule": "forbidden", + "rule_id": "forbidden: domain -> infrastructure", + "reason": "Domain layer must stay persistence-agnostic", + "message": "src/domain/order.ts:2 -> src/infra/db.ts [calls query]", + "file": "src/domain/order.ts", + "line": 2, + "from": { "name": "order", "qualified_name": null, "kind": "function", "file": "src/domain/order.ts", "line_start": 2, "line_end": 2 }, + "to": { "name": "query", "qualified_name": null, "kind": "function", "file": "src/infra/db.ts", "line_start": 1, "line_end": 1 } + }, + { + "rule": "limit", + "rule_id": "limit: fan_in <= 25 (symbol)", + "reason": "fan_in 30 exceeds max 25", + "message": "src/app/hub.ts:10 (handle): fan_in 30 exceeds max 25", + "file": "src/app/hub.ts", + "line": 10, + "subject": { "name": "handle", "qualified_name": null, "kind": "function", "file": "src/app/hub.ts", "line_start": 10, "line_end": 42 }, + "metric": "fan_in", + "scope": "symbol", + "value": 30, + "max": 25 + } + ] +} +``` + +One entry per violation. `rule` is one of `forbidden`, `allowed_dependents`, `limit`, or `no_new_dependents`; `rule_id` identifies the specific rule instance (violations with the same `rule_id` belong to the same rule). + +Dependency violations (`forbidden`, `allowed_dependents`, `no_new_dependents`) carry `from`/`to` endpoints. Symbol-level endpoints (resolved call/implements/extends/uses edges) are full SymbolRefs; file-level endpoints (resolved imports) are `{"file": ...}` objects. `limit` violations carry a `subject` endpoint plus `metric`, `scope`, `value`, and `max`. Absent optional fields (`line`, `from`, `to`, `subject`, the metric fields) are omitted rather than `null`. `against` is `null` when `--against` was not given. + +Exit codes follow the suite convention: 0 = no violations, 1 = at least one violation, 2 = operational error (missing/invalid rules file, unknown or overlapping layers, missing index, bad git ref). + +### `check.list` + +`ctx check --list --json` + +```json +{ + "rules_path": ".ctx/rules.toml", + "version": 1, + "layers": [ + { "name": "domain", "patterns": ["src/domain/**"], "files": 12 } + ], + "rules": { + "forbidden": [ { "from": "domain", "to": "infrastructure", "reason": "Domain layer must stay persistence-agnostic" } ], + "allowed_dependents": [ { "layer": "infrastructure", "only": ["application"], "reason": null } ], + "limit": [ { "metric": "fan_in", "scope": "symbol", "max": 25, "exclude": ["src/core/**"] } ], + "no_new_dependents": [ { "paths": ["src/legacy/**"], "reason": "Legacy module is frozen; do not add new callers" } ] + } +} +``` + +`files` is the number of indexed files matching the layer's globs. `--list` always exits 0. + ## Legacy shapes `ctx complexity --output json`, `ctx graph --output json`, and `ctx audit --output json` still emit their old, ad-hoc JSON shapes. They will be migrated to the envelope in a future release. The old ad-hoc shapes of `search --output json` and `semantic --output json` have already been **replaced** by the envelope described above. diff --git a/src/cli.rs b/src/cli.rs index 387b87e..77d6348 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -416,6 +416,59 @@ pub enum Command { incremental: bool, }, + /// Check architecture rules from .ctx/rules.toml against the index + /// + /// Exit codes: 0 = no violations, 1 = at least one violation, + /// 2 = operational error (missing/invalid rules file, unknown or + /// overlapping layers, missing index, bad git ref). + #[command(after_help = r#"RULES FILE (.ctx/rules.toml): + version = 1 + + [layers] # layer name -> globs over indexed files + domain = ["src/domain/**"] + application = ["src/app/**"] + infrastructure = ["src/infra/**", "src/db/**"] + + [[rules.forbidden]] # `from` must not depend on `to` + from = "domain" + to = "infrastructure" + reason = "Domain layer must stay persistence-agnostic" + + [[rules.allowed_dependents]] # only `only` may depend on `layer` + layer = "infrastructure" # (files in no layer are exempt) + only = ["application"] + + [[rules.limit]] # metric thresholds + metric = "fan_in" # fan_in | fan_out | complexity | file_symbols + scope = "symbol" # symbol | file + max = 25 + exclude = ["src/core/**"] + + [[rules.no_new_dependents]] # frozen paths + paths = ["src/legacy/**"] + reason = "Legacy module is frozen; do not add new callers" + +EXAMPLES: + ctx check # check all rules + ctx check --against main # only violations touching files changed since main + ctx check --list # show parsed rules and layer sizes + ctx check --json # machine-readable output (see docs/json-output.md) +"#)] + Check { + /// Path to the rules file (default: .ctx/rules.toml) + #[arg(long)] + rules: Option, + + /// Only report violations where at least one endpoint's file changed + /// since REF (for no_new_dependents: where the new dependent changed) + #[arg(long, value_name = "REF")] + against: Option, + + /// Print the parsed rules and layer membership counts, then exit 0 + #[arg(long)] + list: bool, + }, + /// Interactive shell for exploring codebase Shell { /// History file location diff --git a/src/commands/check.rs b/src/commands/check.rs new file mode 100644 index 0000000..d9c50db --- /dev/null +++ b/src/commands/check.rs @@ -0,0 +1,1227 @@ +//! `ctx check` -- architecture rules engine. +//! +//! Loads `.ctx/rules.toml`, builds a file-level dependency set from the code +//! intelligence index (resolved call/implements/extends/uses edges plus +//! resolved imports), evaluates the rules, and reports violations. +//! +//! Exit codes follow the ctx convention: 0 = no violations, 1 = at least one +//! violation, 2 = operational error (missing/invalid rules file, unknown +//! layer names, overlapping layers, missing index, bad git ref). + +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use ctx::db::{Database, EdgeSymbol}; +use ctx::error::{CtxError, Result}; +use ctx::exit::Outcome; +use ctx::gitutil; +use ctx::index; +use ctx::json::SymbolRef; +use ctx::rules::{self, CompiledRules, Endpoint, FileDep, RulesFile, Violation}; + +/// Run `ctx check` in the current directory. +pub fn run_check( + rules_path: Option, + against: Option, + list: bool, + json: bool, +) -> Result { + let root = std::env::current_dir()?; + run_check_in(&root, rules_path, against, list, json) +} + +/// Everything loaded and validated, ready for evaluation. +struct CheckContext { + compiled: CompiledRules, + indexed: Vec, + db: Database, + rules_path: PathBuf, +} + +/// Dir-explicit implementation (used directly by tests). +fn run_check_in( + root: &Path, + rules_path: Option, + against: Option, + list: bool, + json_mode: bool, +) -> Result { + let context = load_context(root, rules_path)?; + + if list { + return print_list(&context, json_mode); + } + + let violations = collect_violations(root, &context, against.as_deref())?; + let rules_violated = count_rule_groups(&violations); + + if json_mode { + ctx::json::emit( + "check", + check_data(&context.rules_path, against.as_deref(), &violations), + )?; + } else { + print_human(&violations, rules_violated); + } + + Ok(if violations.is_empty() { + Outcome::Clean + } else { + Outcome::Findings + }) +} + +/// Load and compile the rules file, open the index, and validate. +fn load_context(root: &Path, rules_path: Option) -> Result { + let rules_path = match rules_path { + Some(p) if p.is_absolute() => p, + Some(p) => root.join(p), + None => root.join(rules::DEFAULT_RULES_PATH), + }; + + if !rules_path.exists() { + return Err(CtxError::Other(format!( + "rules file not found: {}\n\ + Create it with a [layers] table and [[rules.*]] entries; \ + run 'ctx check --help' for a full example.", + rules_path.display() + ))); + } + let content = fs::read_to_string(&rules_path)?; + let parsed: RulesFile = toml::from_str(&content).map_err(|e| { + CtxError::Other(format!( + "invalid rules file {}: {}", + rules_path.display(), + e + )) + })?; + let compiled = CompiledRules::compile(parsed)?; + + let db = index::open_database(root)?; + let indexed = db.get_indexed_files()?; + compiled.validate(&indexed)?; + + Ok(CheckContext { + compiled, + indexed, + db, + rules_path, + }) +} + +/// Build the dependency set and evaluate all rules. +fn collect_violations( + root: &Path, + context: &CheckContext, + against: Option<&str>, +) -> Result> { + let changed = match against { + Some(reference) => Some(gitutil::changed_files_against_in(root, reference)?), + None => None, + }; + + let indexed_set: HashSet = context.indexed.iter().cloned().collect(); + let deps = build_deps(&context.db, &indexed_set)?; + let symbol_metrics = context.db.symbol_metrics()?; + let file_complexity = context.db.file_complexity()?; + + Ok(rules::evaluate( + &context.compiled, + &deps, + &symbol_metrics, + &file_complexity, + changed.as_ref(), + )) +} + +// ============================================================================ +// Dependency set +// ============================================================================ + +/// Build the cross-file dependency set: resolved symbol edges plus imports +/// resolved against the set of indexed files. +fn build_deps(db: &Database, indexed: &HashSet) -> Result> { + let mut deps = Vec::new(); + + // Resolved symbol-to-symbol edges (calls/implements/extends/uses). + for edge in db.get_cross_file_edges()? { + deps.push(FileDep { + from: Endpoint::Symbol(symbol_ref(&edge.source)), + to: Endpoint::Symbol(symbol_ref(&edge.target)), + kind: edge.kind, + line: edge.line, + }); + } + + // Imports recorded in module metadata (TS/JS, Rust, Python, Solidity). + for (file, imports) in db.get_file_imports()? { + for import in imports { + if let Some(target) = resolve_import(indexed, &file, &import.from) { + if target != file { + deps.push(FileDep { + from: Endpoint::File { file: file.clone() }, + to: Endpoint::File { file: target }, + kind: "import".to_string(), + line: None, + }); + } + } + } + } + + // File-level import edges (Go records imports in the edges table with the + // importing file path as the source). + for (source, target_path, line) in db.get_import_edges()? { + if !indexed.contains(&source) { + continue; + } + if let Some(target) = resolve_import(indexed, &source, &target_path) { + if target != source { + deps.push(FileDep { + from: Endpoint::File { + file: source.clone(), + }, + to: Endpoint::File { file: target }, + kind: "import".to_string(), + line, + }); + } + } + } + + Ok(deps) +} + +fn symbol_ref(s: &EdgeSymbol) -> SymbolRef { + SymbolRef { + name: s.name.clone(), + qualified_name: s.qualified_name.clone(), + kind: s.kind.clone(), + file: s.file_path.clone(), + line_start: s.line_start, + line_end: s.line_end, + } +} + +// ============================================================================ +// Import resolution +// ============================================================================ + +const JS_SUFFIXES: &[&str] = &[ + "", + ".ts", + ".tsx", + ".js", + ".jsx", + "/index.ts", + "/index.tsx", + "/index.js", +]; +const SOL_SUFFIXES: &[&str] = &["", ".sol"]; + +/// Resolve an import specifier to an indexed file, or `None` when the target +/// is external / unresolvable (unresolvable imports are silently skipped). +/// +/// Heuristics are keyed off the importing file's extension. +fn resolve_import(indexed: &HashSet, importing_file: &str, from: &str) -> Option { + let ext = Path::new(importing_file) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + match ext { + "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" => { + resolve_relative(indexed, importing_file, from, JS_SUFFIXES) + } + "sol" => resolve_relative(indexed, importing_file, from, SOL_SUFFIXES), + "rs" => resolve_rust(indexed, importing_file, from), + "py" => resolve_python(indexed, importing_file, from), + "go" => resolve_go(indexed, from), + _ => None, + } +} + +/// Directory part of a slash-separated path (`""` for root-level files). +fn parent_dir(path: &str) -> &str { + match path.rfind('/') { + Some(i) => &path[..i], + None => "", + } +} + +/// Resolve `.`/`..` segments. Returns `None` when the path escapes the root +/// or normalizes to nothing. +fn normalize(path: &str) -> Option { + let mut out: Vec<&str> = Vec::new(); + for seg in path.split('/') { + match seg { + "" | "." => {} + ".." => { + out.pop()?; + } + s => out.push(s), + } + } + if out.is_empty() { + None + } else { + Some(out.join("/")) + } +} + +/// Relative-path resolution for TS/JS and Solidity. Non-relative specifiers +/// are third-party packages and resolve to `None`. +fn resolve_relative( + indexed: &HashSet, + importing_file: &str, + from: &str, + suffixes: &[&str], +) -> Option { + if !from.starts_with("./") && !from.starts_with("../") { + return None; + } + let base = normalize(&format!("{}/{}", parent_dir(importing_file), from))?; + for suffix in suffixes { + let candidate = format!("{}{}", base, suffix); + if indexed.contains(&candidate) { + return Some(candidate); + } + } + None +} + +/// Rust `use` path resolution: `crate::` from `src/`, `self::`/`super::` +/// relative to the importing file's module, bare paths are external crates. +fn resolve_rust(indexed: &HashSet, importing_file: &str, from: &str) -> Option { + let segs: Vec<&str> = from.split("::").filter(|s| !s.is_empty()).collect(); + let first = *segs.first()?; + + let (base, rest): (String, &[&str]) = match first { + "crate" => ("src".to_string(), &segs[1..]), + "self" => (rust_self_dir(importing_file), &segs[1..]), + "super" => { + let mut dir = rust_self_dir(importing_file); + let mut i = 0; + while i < segs.len() && segs[i] == "super" { + dir = parent_dir(&dir).to_string(); + i += 1; + } + (dir, &segs[i..]) + } + _ => return None, // external crate (or 2015-style bare module) + }; + + // Drop trailing segments one by one: the last segment(s) may be items + // (types, functions) rather than modules. + for k in (1..=rest.len()).rev() { + let module_path = join_path(&base, &rest[..k].join("/")); + for candidate in [ + format!("{}.rs", module_path), + format!("{}/mod.rs", module_path), + ] { + if indexed.contains(&candidate) { + return Some(candidate); + } + } + } + + // `use crate::Item` / `use super::Item`: the target is the parent module + // file itself. + if !base.is_empty() { + for candidate in [format!("{}/mod.rs", base), format!("{}.rs", base)] { + if indexed.contains(&candidate) { + return Some(candidate); + } + } + if base == "src" { + for candidate in ["src/lib.rs".to_string(), "src/main.rs".to_string()] { + if indexed.contains(&candidate) { + return Some(candidate); + } + } + } + } + None +} + +/// The directory that holds child modules of the importing Rust file: +/// `src/a/b.rs` (module `a::b`) -> `src/a/b`; `src/a/mod.rs`, `src/lib.rs`, +/// and `src/main.rs` -> their own directory. +fn rust_self_dir(importing_file: &str) -> String { + let file_name = importing_file.rsplit('/').next().unwrap_or(importing_file); + if matches!(file_name, "mod.rs" | "lib.rs" | "main.rs") { + parent_dir(importing_file).to_string() + } else { + importing_file + .strip_suffix(".rs") + .unwrap_or(importing_file) + .to_string() + } +} + +fn join_path(base: &str, rest: &str) -> String { + if base.is_empty() { + rest.to_string() + } else if rest.is_empty() { + base.to_string() + } else { + format!("{}/{}", base, rest) + } +} + +/// Python import resolution: dotted absolute modules (also probed under +/// `src/`), and `from .`-style relative imports resolved from the importing +/// file's directory. +fn resolve_python(indexed: &HashSet, importing_file: &str, from: &str) -> Option { + if let Some(mut rest) = from.strip_prefix('.') { + // One leading dot = current package; each extra dot pops a level. + let mut dir = parent_dir(importing_file).to_string(); + while let Some(r) = rest.strip_prefix('.') { + dir = parent_dir(&dir).to_string(); + rest = r; + } + let segs: Vec<&str> = rest.split('.').filter(|s| !s.is_empty()).collect(); + if segs.is_empty() { + // `from . import x`: the target is the package itself. + if dir.is_empty() { + return None; + } + let candidate = format!("{}/__init__.py", dir); + return indexed.contains(&candidate).then_some(candidate); + } + probe_python(indexed, &join_path(&dir, &segs.join("/"))) + } else { + let rel = from.replace('.', "/"); + probe_python(indexed, &rel).or_else(|| probe_python(indexed, &format!("src/{}", rel))) + } +} + +fn probe_python(indexed: &HashSet, base: &str) -> Option { + [format!("{}.py", base), format!("{}/__init__.py", base)] + .into_iter() + .find(|candidate| indexed.contains(candidate)) +} + +/// Go import resolution: import paths name a package directory +/// (e.g. `example.com/proj/pkg/util`). Match the longest trailing-directory +/// suffix against indexed directories and return the first `.go` file in the +/// matched directory. +fn resolve_go(indexed: &HashSet, from: &str) -> Option { + let segs: Vec<&str> = from.split('/').filter(|s| !s.is_empty()).collect(); + if segs.is_empty() { + return None; + } + + let mut go_files: Vec<&String> = indexed.iter().filter(|f| f.ends_with(".go")).collect(); + go_files.sort(); + + for k in (1..=segs.len()).rev() { + let suffix = segs[segs.len() - k..].join("/"); + let hit = go_files.iter().find(|f| { + let dir = parent_dir(f); + dir == suffix || dir.ends_with(&format!("/{}", suffix)) + }); + if let Some(f) = hit { + return Some((*f).clone()); + } + } + None +} + +// ============================================================================ +// Output +// ============================================================================ + +/// The `data` payload for `--json` mode (see docs/json-output.md, `check`). +fn check_data( + rules_path: &Path, + against: Option<&str>, + violations: &[Violation], +) -> serde_json::Value { + serde_json::json!({ + "rules_path": rules_path.display().to_string(), + "against": against, + "summary": { + "violations": violations.len(), + "rules_violated": count_rule_groups(violations), + }, + "violations": violations, + }) +} + +fn count_rule_groups(violations: &[Violation]) -> usize { + let ids: HashSet<&str> = violations.iter().map(|v| v.rule_id.as_str()).collect(); + ids.len() +} + +/// Human output: violations grouped by rule, one `file:line` + reason per +/// line, and a `N violations across M rules` summary. +fn print_human(violations: &[Violation], rules_violated: usize) { + if violations.is_empty() { + println!("No architecture violations found."); + return; + } + + let mut order: Vec<&str> = Vec::new(); + for v in violations { + if !order.contains(&v.rule_id.as_str()) { + order.push(&v.rule_id); + } + } + + for rule_id in &order { + println!("{}", rule_id); + for v in violations.iter().filter(|v| v.rule_id == *rule_id) { + if v.message.contains(&v.reason) { + println!(" {}", v.message); + } else { + println!(" {} ({})", v.message, v.reason); + } + } + println!(); + } + + println!( + "{} violation{} across {} rule{}", + violations.len(), + if violations.len() == 1 { "" } else { "s" }, + rules_violated, + if rules_violated == 1 { "" } else { "s" } + ); +} + +/// `--list`: print the parsed rules and layer membership counts, exit 0. +fn print_list(context: &CheckContext, json_mode: bool) -> Result { + let counts = context.compiled.layer_file_counts(&context.indexed); + let groups = &context.compiled.file.rules; + + if json_mode { + ctx::json::emit("check.list", list_data(context))?; + return Ok(Outcome::Clean); + } + + println!( + "Rules file: {} (version {})", + context.rules_path.display(), + context.compiled.file.version + ); + println!(); + + println!("Layers:"); + if counts.is_empty() { + println!(" (none)"); + } + for (name, n) in &counts { + println!( + " {:<16} {} ({} file{})", + name, + context.compiled.file.layers[name].join(", "), + n, + if *n == 1 { "" } else { "s" } + ); + } + println!(); + + println!("Rules:"); + let total = groups.forbidden.len() + + groups.allowed_dependents.len() + + groups.limit.len() + + groups.no_new_dependents.len(); + if total == 0 { + println!(" (none)"); + } + for r in &groups.forbidden { + let reason = r + .reason + .as_deref() + .map(|s| format!(" ({})", s)) + .unwrap_or_default(); + println!(" forbidden: {} -> {}{}", r.from, r.to, reason); + } + for r in &groups.allowed_dependents { + println!( + " allowed_dependents: only [{}] may depend on {}", + r.only.join(", "), + r.layer + ); + } + for r in &groups.limit { + let exclude = if r.exclude.is_empty() { + String::new() + } else { + format!(" (exclude [{}])", r.exclude.join(", ")) + }; + println!( + " limit: {} <= {} ({}){}", + r.metric.as_str(), + r.max, + r.scope.as_str(), + exclude + ); + } + for r in &groups.no_new_dependents { + let reason = r + .reason + .as_deref() + .map(|s| format!(" ({})", s)) + .unwrap_or_default(); + println!(" no_new_dependents: [{}]{}", r.paths.join(", "), reason); + } + + Ok(Outcome::Clean) +} + +/// The `data` payload for `--list --json`. +fn list_data(context: &CheckContext) -> serde_json::Value { + let counts = context.compiled.layer_file_counts(&context.indexed); + let layers: Vec = counts + .iter() + .map(|(name, n)| { + serde_json::json!({ + "name": name, + "patterns": context.compiled.file.layers[name], + "files": n, + }) + }) + .collect(); + let groups = &context.compiled.file.rules; + serde_json::json!({ + "rules_path": context.rules_path.display().to_string(), + "version": context.compiled.file.version, + "layers": layers, + "rules": { + "forbidden": groups.forbidden, + "allowed_dependents": groups.allowed_dependents, + "limit": groups.limit, + "no_new_dependents": groups.no_new_dependents, + }, + }) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use ctx::db::{Edge, EdgeKind, FileRecord, Symbol, SymbolKind, Visibility}; + use ctx::index::Indexer; + use ctx::rules::RuleKind; + use ctx::testutil::GitRepo; + use ctx::walker::WalkerConfig; + use tempfile::TempDir; + + // ---------- import resolver ---------- + + fn indexed(files: &[&str]) -> HashSet { + files.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn test_resolve_import_typescript() { + let idx = indexed(&[ + "src/domain/order.ts", + "src/infra/db.ts", + "src/app/util/index.ts", + "src/legacy/api.js", + ]); + let cases = [ + // relative sibling with probing + ( + "src/domain/order.ts", + "../infra/db", + Some("src/infra/db.ts"), + ), + ( + "src/domain/order.ts", + "../infra/db.ts", + Some("src/infra/db.ts"), + ), + // directory import -> index file + ( + "src/domain/order.ts", + "../app/util", + Some("src/app/util/index.ts"), + ), + // .js target + ( + "src/domain/order.ts", + "../legacy/api", + Some("src/legacy/api.js"), + ), + // same-dir + ("src/infra/db.ts", "./db", Some("src/infra/db.ts")), + // third-party + ("src/domain/order.ts", "react", None), + ("src/domain/order.ts", "@scope/pkg", None), + // escapes the root + ("src/domain/order.ts", "../../../outside", None), + // missing + ("src/domain/order.ts", "./nope", None), + ]; + for (file, from, expected) in cases { + assert_eq!( + resolve_import(&idx, file, from).as_deref(), + expected, + "{} imports {:?}", + file, + from + ); + } + } + + #[test] + fn test_resolve_import_rust() { + let idx = indexed(&[ + "src/lib.rs", + "src/main.rs", + "src/domain/order.rs", + "src/domain/mod.rs", + "src/infra/mod.rs", + "src/infra/db.rs", + ]); + let cases = [ + // crate:: module path + ("src/main.rs", "crate::infra::db", Some("src/infra/db.rs")), + // trailing item segment dropped + ( + "src/main.rs", + "crate::infra::db::Pool", + Some("src/infra/db.rs"), + ), + // module dir with mod.rs + ("src/main.rs", "crate::domain", Some("src/domain/mod.rs")), + // bare crate item -> lib.rs + ("src/domain/order.rs", "crate", Some("src/lib.rs")), + // super:: from a nested module + ( + "src/infra/db.rs", + "super::super::domain::order", + Some("src/domain/order.rs"), + ), + // self:: children (src/domain/order.rs would own src/domain/order/) + ( + "src/domain/mod.rs", + "self::order", + Some("src/domain/order.rs"), + ), + // super item falls back to the parent module file + ( + "src/domain/order.rs", + "super::Registry", + Some("src/domain/mod.rs"), + ), + // external crates + ("src/main.rs", "std::collections::HashMap", None), + ("src/main.rs", "serde::Serialize", None), + ]; + for (file, from, expected) in cases { + assert_eq!( + resolve_import(&idx, file, from).as_deref(), + expected, + "{} imports {:?}", + file, + from + ); + } + } + + #[test] + fn test_resolve_import_python() { + let idx = indexed(&[ + "app/__init__.py", + "app/models.py", + "app/api/routes.py", + "src/pkg/util.py", + ]); + let cases = [ + // absolute dotted path + ("app/api/routes.py", "app.models", Some("app/models.py")), + // package __init__ + ("app/api/routes.py", "app", Some("app/__init__.py")), + // src/ prefix probing + ("app/models.py", "pkg.util", Some("src/pkg/util.py")), + // relative: one dot = current package + ("app/api/routes.py", ".routes", Some("app/api/routes.py")), + // relative: two dots pop a level + ("app/api/routes.py", "..models", Some("app/models.py")), + // `from . import x` -> package itself + ("app/models.py", ".", Some("app/__init__.py")), + // third-party + ("app/models.py", "django.db", None), + ]; + for (file, from, expected) in cases { + assert_eq!( + resolve_import(&idx, file, from).as_deref(), + expected, + "{} imports {:?}", + file, + from + ); + } + } + + #[test] + fn test_resolve_import_go_and_solidity() { + let idx = indexed(&[ + "cmd/server/main.go", + "pkg/util/strings.go", + "pkg/util/numbers.go", + "contracts/Token.sol", + "contracts/lib/Math.sol", + ]); + // Go: tail-dir suffix match, first file in the matched dir. + assert_eq!( + resolve_import(&idx, "cmd/server/main.go", "example.com/proj/pkg/util").as_deref(), + Some("pkg/util/numbers.go") + ); + assert_eq!( + resolve_import(&idx, "cmd/server/main.go", "example.com/other/nowhere"), + None + ); + // Solidity: relative like TS. + assert_eq!( + resolve_import(&idx, "contracts/Token.sol", "./lib/Math.sol").as_deref(), + Some("contracts/lib/Math.sol") + ); + assert_eq!( + resolve_import( + &idx, + "contracts/Token.sol", + "@openzeppelin/contracts/token.sol" + ), + None + ); + } + + #[test] + fn test_resolve_import_unknown_extension() { + let idx = indexed(&["a.yaml", "b.yaml"]); + assert_eq!(resolve_import(&idx, "a.yaml", "./b"), None); + } + + // ---------- limit rules against a hand-built database ---------- + + fn make_symbol(id: &str, name: &str, file: &str, kind: SymbolKind) -> Symbol { + Symbol { + id: id.to_string(), + file_path: file.to_string(), + name: name.to_string(), + qualified_name: None, + kind, + visibility: Visibility::Public, + signature: None, + brief: None, + docstring: None, + line_start: 1, + line_end: 5, + col_start: 0, + col_end: 0, + parent_id: None, + source: None, + } + } + + fn make_call(source: &str, target: &str) -> Edge { + Edge { + source_id: source.to_string(), + target_id: Some(target.to_string()), + target_name: target.rsplit("::").next().unwrap_or(target).to_string(), + kind: EdgeKind::Calls, + line: Some(3), + col: None, + context: None, + } + } + + fn add_file(db: &Database, path: &str) { + db.upsert_file( + &FileRecord { + path: path.to_string(), + content_hash: format!("hash-{}", path), + size_bytes: 1, + language: Some("rust".to_string()), + last_indexed: 0, + }, + None, + ) + .unwrap(); + } + + #[test] + fn test_limit_rules_against_in_memory_db() { + let db = Database::open_in_memory().unwrap(); + add_file(&db, "src/hub.rs"); + add_file(&db, "src/a.rs"); + add_file(&db, "src/b.rs"); + add_file(&db, "src/c.rs"); + + // hub() is called by a(), b(), c() -> fan_in 3. + db.insert_symbol(&make_symbol( + "src/hub.rs::hub", + "hub", + "src/hub.rs", + SymbolKind::Function, + )) + .unwrap(); + for f in ["a", "b", "c"] { + let id = format!("src/{}.rs::{}", f, f); + db.insert_symbol(&make_symbol( + &id, + f, + &format!("src/{}.rs", f), + SymbolKind::Function, + )) + .unwrap(); + db.insert_edge(&make_call(&id, "src/hub.rs::hub")).unwrap(); + } + + let rules_file: RulesFile = toml::from_str( + r#" + [[rules.limit]] + metric = "fan_in" + max = 2 + "#, + ) + .unwrap(); + let compiled = CompiledRules::compile(rules_file).unwrap(); + compiled.validate(&db.get_indexed_files().unwrap()).unwrap(); + + let metrics = db.symbol_metrics().unwrap(); + let files = db.file_complexity().unwrap(); + let violations = rules::evaluate(&compiled, &[], &metrics, &files, None); + + assert_eq!(violations.len(), 1); + let v = &violations[0]; + assert_eq!(v.rule, RuleKind::Limit); + assert_eq!(v.file, "src/hub.rs"); + assert_eq!(v.value, Some(3)); + assert_eq!(v.max, Some(2)); + } + + #[test] + fn test_build_deps_from_in_memory_db() { + let db = Database::open_in_memory().unwrap(); + add_file(&db, "src/a.rs"); + add_file(&db, "src/b.rs"); + + db.insert_symbol(&make_symbol( + "src/a.rs::a", + "a", + "src/a.rs", + SymbolKind::Function, + )) + .unwrap(); + db.insert_symbol(&make_symbol( + "src/b.rs::b", + "b", + "src/b.rs", + SymbolKind::Function, + )) + .unwrap(); + // Cross-file resolved call. + db.insert_edge(&make_call("src/a.rs::a", "src/b.rs::b")) + .unwrap(); + // Unresolved edge and same-file edge must be ignored. + db.insert_edge(&Edge { + source_id: "src/a.rs::a".to_string(), + target_id: None, + target_name: "external".to_string(), + kind: EdgeKind::Calls, + line: Some(4), + col: None, + context: None, + }) + .unwrap(); + + // Module-level import (Rust style). + db.upsert_module(&ctx::db::ModuleInfo { + file_path: "src/b.rs".to_string(), + module_name: Some("b".to_string()), + exports: vec![], + imports: vec![ctx::db::ImportInfo { + from: "crate::a".to_string(), + names: vec!["a".to_string()], + alias: None, + }], + }) + .unwrap(); + + let indexed: HashSet = db.get_indexed_files().unwrap().into_iter().collect(); + let deps = build_deps(&db, &indexed).unwrap(); + + assert_eq!(deps.len(), 2); + // The resolved call edge, with symbol endpoints. + assert_eq!(deps[0].kind, "calls"); + assert_eq!(deps[0].from.file(), "src/a.rs"); + assert_eq!(deps[0].to.file(), "src/b.rs"); + assert!(matches!(deps[0].from, Endpoint::Symbol(_))); + // The resolved import, with file endpoints. + assert_eq!(deps[1].kind, "import"); + assert_eq!(deps[1].from.file(), "src/b.rs"); + assert_eq!(deps[1].to.file(), "src/a.rs"); + assert!(matches!(deps[1].from, Endpoint::File { .. })); + } + + // ---------- end-to-end acceptance tests ---------- + + const LAYER_RULES: &str = r#" +version = 1 + +[layers] +domain = ["src/domain/**"] +infrastructure = ["src/infra/**"] + +[[rules.forbidden]] +from = "domain" +to = "infrastructure" +reason = "Domain layer must stay persistence-agnostic" +"#; + + /// Index `root` on disk (creates .ctx/codebase.sqlite). + fn index_project(root: &Path) { + let mut indexer = Indexer::with_config(root, false, WalkerConfig::default()).unwrap(); + indexer.index().unwrap(); + } + + fn write(root: &Path, rel: &str, content: &str) { + let path = root.join(rel); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, content).unwrap(); + } + + #[test] + fn test_check_forbidden_import_end_to_end() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + write( + root, + "src/domain/order.ts", + "import { query } from \"../infra/db\";\nexport function order() { return query(); }\n", + ); + write( + root, + "src/infra/db.ts", + "export function query() { return 42; }\n", + ); + index_project(root); + write(root, ".ctx/rules.toml", LAYER_RULES); + + // The deliberate domain -> infrastructure import is a finding. + let context = load_context(root, None).unwrap(); + let violations = collect_violations(root, &context, None).unwrap(); + assert!(!violations.is_empty(), "expected at least one violation"); + let v = violations + .iter() + .find(|v| v.rule == RuleKind::Forbidden) + .expect("expected a forbidden violation"); + // Names the exact edge: both endpoints of the offending import. + assert_eq!(v.from.as_ref().unwrap().file(), "src/domain/order.ts"); + assert_eq!(v.to.as_ref().unwrap().file(), "src/infra/db.ts"); + assert_eq!(v.reason, "Domain layer must stay persistence-agnostic"); + + let outcome = run_check_in(root, None, None, false, false).unwrap(); + assert_eq!(outcome, Outcome::Findings); + + // Remove the import and re-index: clean. + write( + root, + "src/domain/order.ts", + "export function order() { return 1; }\n", + ); + index_project(root); + let outcome = run_check_in(root, None, None, false, false).unwrap(); + assert_eq!(outcome, Outcome::Clean); + } + + #[test] + fn test_check_against_reports_only_new_violations() { + let temp = TempDir::new().unwrap(); + let repo = GitRepo::init(temp.path()); + let root = &repo.root; + + // Commit v1 with pre-existing violation A (a.ts -> infra). + repo.write( + "src/domain/a.ts", + "import { query } from \"../infra/db\";\nexport function a() { return query(); }\n", + ); + repo.write( + "src/infra/db.ts", + "export function query() { return 42; }\n", + ); + repo.write(".ctx/rules.toml", LAYER_RULES); + repo.commit_all("v1 with violation A"); + + // Commit v2 introduces violation B (b.ts -> infra). + repo.commit_file( + "src/domain/b.ts", + "import { query } from \"../infra/db\";\nexport function b() { return query(); }\n", + "add violation B", + ); + + index_project(root); + let context = load_context(root, None).unwrap(); + + // Without --against: both violations. + let all = collect_violations(root, &context, None).unwrap(); + let files: Vec<&str> = all.iter().map(|v| v.file.as_str()).collect(); + assert!(files.contains(&"src/domain/a.ts"), "files: {:?}", files); + assert!(files.contains(&"src/domain/b.ts"), "files: {:?}", files); + + // --against HEAD~1: only the newly introduced violation B. + let new_only = collect_violations(root, &context, Some("HEAD~1")).unwrap(); + assert!(!new_only.is_empty()); + assert!( + new_only.iter().all(|v| v.file == "src/domain/b.ts"), + "violations: {:?}", + new_only.iter().map(|v| &v.message).collect::>() + ); + + let outcome = run_check_in(root, None, Some("HEAD~1".to_string()), false, false).unwrap(); + assert_eq!(outcome, Outcome::Findings); + + // Bad ref -> operational error. + let err = collect_violations(root, &context, Some("no-such-ref")).unwrap_err(); + assert!(matches!(err, CtxError::InvalidRevision(_)), "err: {}", err); + } + + #[test] + fn test_check_invalid_toml_and_overlap_are_errors() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + write(root, "src/domain/a.ts", "export const a = 1;\n"); + index_project(root); + + // Invalid TOML. + write(root, ".ctx/rules.toml", "[layers\ndomain = ["); + let err = run_check_in(root, None, None, false, false).unwrap_err(); + assert!( + err.to_string().contains("invalid rules file"), + "err: {}", + err + ); + + // Overlapping layer membership. + write( + root, + ".ctx/rules.toml", + r#" + [layers] + domain = ["src/domain/**"] + everything = ["src/**"] + "#, + ); + let err = run_check_in(root, None, None, false, false).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("src/domain/a.ts"), "err: {}", msg); + assert!( + msg.contains("domain") && msg.contains("everything"), + "err: {}", + msg + ); + + // Missing rules file mentions the expected path. + fs::remove_file(root.join(".ctx/rules.toml")).unwrap(); + let err = run_check_in(root, None, None, false, false).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("rules file not found"), "err: {}", msg); + assert!(msg.contains("rules.toml"), "err: {}", msg); + } + + #[test] + fn test_check_missing_index_is_error() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + write(root, ".ctx/rules.toml", LAYER_RULES); + let err = run_check_in(root, None, None, false, false).unwrap_err(); + assert!(matches!(err, CtxError::IndexNotFound(_)), "err: {}", err); + } + + #[test] + fn test_check_json_data_one_entry_per_violation() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + write( + root, + "src/domain/order.ts", + "import { query } from \"../infra/db\";\nexport function order() { return query(); }\n", + ); + write( + root, + "src/infra/db.ts", + "export function query() { return 42; }\n", + ); + index_project(root); + write(root, ".ctx/rules.toml", LAYER_RULES); + + let context = load_context(root, None).unwrap(); + let violations = collect_violations(root, &context, None).unwrap(); + let data = check_data(&context.rules_path, None, &violations); + + let entries = data["violations"].as_array().unwrap(); + assert_eq!(entries.len(), violations.len()); + assert_eq!(data["summary"]["violations"], violations.len()); + assert!(data["summary"]["rules_violated"].as_u64().unwrap() >= 1); + assert!(data["against"].is_null()); + + // Every entry carries the rule type and reason. + for entry in entries { + assert_eq!(entry["rule"], "forbidden"); + assert_eq!( + entry["reason"], + "Domain layer must stay persistence-agnostic" + ); + } + + // File-level endpoints (imports) are {"file": ...} objects. + let import_entry = entries + .iter() + .find(|e| e["from"] == serde_json::json!({"file": "src/domain/order.ts"})) + .expect("expected an import violation with file-level endpoints"); + assert_eq!( + import_entry["to"], + serde_json::json!({"file": "src/infra/db.ts"}) + ); + + // Symbol-level endpoints are full SymbolRefs (the resolved call + // order() -> query() crosses the same layer boundary). + if let Some(sym_entry) = entries.iter().find(|e| e["from"].get("name").is_some()) { + assert_eq!(sym_entry["from"]["file"], "src/domain/order.ts"); + assert_eq!(sym_entry["from"]["kind"], "function"); + assert_eq!(sym_entry["to"]["name"], "query"); + assert_eq!(sym_entry["to"]["file"], "src/infra/db.ts"); + } + } + + #[test] + fn test_check_list_mode() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + write(root, "src/domain/a.ts", "export const a = 1;\n"); + write(root, "src/infra/db.ts", "export const b = 2;\n"); + index_project(root); + write(root, ".ctx/rules.toml", LAYER_RULES); + + // --list always exits clean, even though a check would find nothing + // here anyway; also verify the JSON payload shape. + let outcome = run_check_in(root, None, None, true, false).unwrap(); + assert_eq!(outcome, Outcome::Clean); + + let context = load_context(root, None).unwrap(); + let data = list_data(&context); + assert_eq!(data["version"], 1); + let layers = data["layers"].as_array().unwrap(); + assert_eq!(layers.len(), 2); + assert_eq!(layers[0]["name"], "domain"); + assert_eq!(layers[0]["files"], 1); + assert_eq!(layers[1]["name"], "infrastructure"); + assert_eq!(layers[1]["files"], 1); + assert_eq!(data["rules"]["forbidden"][0]["from"], "domain"); + assert_eq!( + data["rules"]["forbidden"][0]["reason"], + "Domain layer must stay persistence-agnostic" + ); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 0541f93..db6f731 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -4,6 +4,7 @@ //! extracted from main.rs for better organization. pub mod analysis; +pub mod check; pub mod context; pub mod diff_cmd; pub mod embed; @@ -16,6 +17,7 @@ pub mod smart_cmd; pub mod symbol; pub use analysis::{run_audit, run_complexity, run_duplicates}; +pub use check::run_check; pub use context::run_context; pub use diff_cmd::{run_diff, run_review}; pub use embed::{run_embed, run_embed_watch, run_semantic}; diff --git a/src/db/mod.rs b/src/db/mod.rs index f1e9df4..f455f99 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -10,4 +10,6 @@ pub mod models; pub mod schema; pub use models::*; -pub use schema::{Database, FileComplexity, SymbolMetrics, SCHEMA_VERSION}; +pub use schema::{ + CrossFileEdge, Database, EdgeSymbol, FileComplexity, SymbolMetrics, SCHEMA_VERSION, +}; diff --git a/src/db/schema.rs b/src/db/schema.rs index 00a34f2..1e3d1a4 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -884,6 +884,95 @@ impl Database { rows.collect() } + /// All resolved relationship edges whose endpoints live in different files. + /// + /// Used by `ctx check` to build the file-level dependency graph. Only + /// `calls`/`implements`/`extends`/`uses` edges are included (`imports` + /// edges are file-level and handled separately; `contains` and friends + /// are structural, not dependencies). + pub fn get_cross_file_edges(&self) -> Result> { + let mut stmt = self.conn.prepare( + r#" + SELECT + s1.name, s1.qualified_name, s1.kind, s1.file_path, s1.line_start, s1.line_end, + s2.name, s2.qualified_name, s2.kind, s2.file_path, s2.line_start, s2.line_end, + e.kind, e.line + FROM edges e + JOIN symbols s1 ON e.source_id = s1.id + JOIN symbols s2 ON e.target_id = s2.id + WHERE e.target_id IS NOT NULL + AND s1.file_path <> s2.file_path + AND e.kind IN ('calls', 'implements', 'extends', 'uses') + ORDER BY s1.file_path, e.line, s2.file_path + "#, + )?; + + let rows = stmt.query_map([], |row| { + Ok(CrossFileEdge { + source: EdgeSymbol { + name: row.get(0)?, + qualified_name: row.get(1)?, + kind: row.get(2)?, + file_path: row.get(3)?, + line_start: row.get::<_, Option>(4)?.unwrap_or(0), + line_end: row.get::<_, Option>(5)?.unwrap_or(0), + }, + target: EdgeSymbol { + name: row.get(6)?, + qualified_name: row.get(7)?, + kind: row.get(8)?, + file_path: row.get(9)?, + line_start: row.get::<_, Option>(10)?.unwrap_or(0), + line_end: row.get::<_, Option>(11)?.unwrap_or(0), + }, + kind: row.get(12)?, + line: row.get(13)?, + }) + })?; + + rows.collect() + } + + /// Per-file imports recorded in the `modules` table. + /// + /// The `imports` column stores a JSON array of [`ImportInfo`]; rows whose + /// JSON fails to parse are skipped. + pub fn get_file_imports(&self) -> Result)>> { + let mut stmt = self.conn.prepare( + "SELECT file_path, imports FROM modules \ + WHERE imports IS NOT NULL AND imports <> '' \ + ORDER BY file_path", + )?; + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })?; + + let mut result = Vec::new(); + for row in rows { + let (file_path, json) = row?; + if let Ok(imports) = serde_json::from_str::>(&json) { + if !imports.is_empty() { + result.push((file_path, imports)); + } + } + } + Ok(result) + } + + /// File-level `imports` edges from the `edges` table. + /// + /// Some parsers (Go) record imports as edges whose `source_id` is the + /// importing *file path* and whose `target_name` is the import path. + /// Returns `(source, target_name, line)` tuples. + pub fn get_import_edges(&self) -> Result)>> { + let mut stmt = self.conn.prepare( + "SELECT source_id, target_name, line FROM edges \ + WHERE kind = 'imports' ORDER BY source_id, line", + )?; + let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?; + rows.collect() + } + /// Normalize an FTS5 bm25 score into a 0-1 relevance value. fn bm25_relevance(rank: f64) -> f64 { if rank.is_finite() { @@ -1776,6 +1865,29 @@ pub struct SymbolMetrics { pub complexity: i64, } +/// A resolved relationship edge whose endpoints live in different files +/// (see [`Database::get_cross_file_edges`]). +#[derive(Debug, Clone)] +pub struct CrossFileEdge { + pub source: EdgeSymbol, + pub target: EdgeSymbol, + /// Edge kind (`calls`, `implements`, `extends`, `uses`). + pub kind: String, + /// Line in the source file where the reference occurs. + pub line: Option, +} + +/// Lightweight symbol info attached to a [`CrossFileEdge`]. +#[derive(Debug, Clone)] +pub struct EdgeSymbol { + pub name: String, + pub qualified_name: Option, + pub kind: String, + pub file_path: String, + pub line_start: i64, + pub line_end: i64, +} + /// Per-file aggregated complexity (see [`Database::file_complexity`]). #[derive(Debug, Clone, serde::Serialize)] pub struct FileComplexity { diff --git a/src/gitutil.rs b/src/gitutil.rs index c4b8ac0..d126f51 100644 --- a/src/gitutil.rs +++ b/src/gitutil.rs @@ -71,7 +71,9 @@ fn repo_prefix_in(dir: &Path) -> Result { Ok(stdout.trim_end_matches(['\n', '\r']).to_string()) } -fn changed_files_against_in(dir: &Path, reference: &str) -> Result> { +/// Dir-explicit variant of [`changed_files_against`], for commands and tests +/// that operate on an explicit project root instead of the process cwd. +pub fn changed_files_against_in(dir: &Path, reference: &str) -> Result> { if !is_git_repo_in(dir) { return Err(CtxError::NotGitRepo); } diff --git a/src/lib.rs b/src/lib.rs index 140e3e0..3593511 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -149,6 +149,9 @@ pub mod formatter; pub mod output; pub mod tree; +// Architecture rules (ctx check) +pub mod rules; + // Utilities pub mod audit; pub mod gitutil; diff --git a/src/main.rs b/src/main.rs index c318090..0ff18ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -164,6 +164,14 @@ fn run(args: Args) -> Result { show_sizes, no_tree, ), + Some(Command::Check { + rules, + against, + list, + }) => { + // Quality command: returns Outcome natively (0 clean / 1 findings). + return commands::run_check(rules, against, list, json); + } Some(Command::Audit { output_format, min_score, diff --git a/src/rules.rs b/src/rules.rs new file mode 100644 index 0000000..cf84d47 --- /dev/null +++ b/src/rules.rs @@ -0,0 +1,1209 @@ +//! Architecture rules for `ctx check`. +//! +//! Rules live in `.ctx/rules.toml` and are checked against the code +//! intelligence index. The file declares named *layers* (glob patterns over +//! indexed file paths) plus four kinds of rules: +//! +//! - `[[rules.forbidden]]` -- layer A must not depend on layer B +//! - `[[rules.allowed_dependents]]` -- only the listed layers may depend on a layer +//! - `[[rules.limit]]` -- metric thresholds (fan-in/fan-out/complexity/file symbols) +//! - `[[rules.no_new_dependents]]` -- frozen paths that must not gain callers +//! +//! This module contains the serde model, glob compilation/validation, and +//! pure evaluation functions over a pre-built list of [`FileDep`]s, so the +//! rule engine is unit-testable without a database. + +use std::collections::{BTreeMap, HashSet}; + +use globset::{Glob, GlobSet, GlobSetBuilder}; +use serde::{Deserialize, Serialize}; + +use crate::db::{FileComplexity, SymbolMetrics}; +use crate::error::{CtxError, Result}; +use crate::json::SymbolRef; + +/// Default location of the rules file, relative to the project root. +pub const DEFAULT_RULES_PATH: &str = ".ctx/rules.toml"; + +/// The rules file format version this build understands. +pub const SUPPORTED_VERSION: u32 = 1; + +// ============================================================================ +// Serde model +// ============================================================================ + +/// Parsed `.ctx/rules.toml`. +#[derive(Debug, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct RulesFile { + /// Format version (currently always `1`). + #[serde(default = "default_version")] + pub version: u32, + + /// Layer name -> glob patterns over indexed file paths. + #[serde(default)] + pub layers: BTreeMap>, + + /// The rule groups. + #[serde(default)] + pub rules: RuleGroups, +} + +fn default_version() -> u32 { + SUPPORTED_VERSION +} + +/// The `[rules]` table. +#[derive(Debug, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct RuleGroups { + #[serde(default)] + pub forbidden: Vec, + + #[serde(default)] + pub allowed_dependents: Vec, + + #[serde(default)] + pub limit: Vec, + + #[serde(default)] + pub no_new_dependents: Vec, +} + +/// `[[rules.forbidden]]`: dependencies from `from` to `to` are violations. +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ForbiddenRule { + pub from: String, + pub to: String, + #[serde(default)] + pub reason: Option, +} + +/// `[[rules.allowed_dependents]]`: only layers in `only` may depend on `layer`. +/// +/// Files that belong to no layer are exempt: per the layer model, unlayered +/// files are unconstrained, which keeps incremental rollout sane (you can +/// start with a couple of layers without instantly flagging the rest of the +/// codebase). +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AllowedDependentsRule { + pub layer: String, + pub only: Vec, + #[serde(default)] + pub reason: Option, +} + +/// `[[rules.limit]]`: a metric threshold over symbols or files. +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct LimitRule { + pub metric: Metric, + #[serde(default)] + pub scope: Scope, + pub max: i64, + #[serde(default)] + pub exclude: Vec, +} + +/// `[[rules.no_new_dependents]]`: frozen paths that must not gain dependents. +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct NoNewDependentsRule { + pub paths: Vec, + #[serde(default)] + pub reason: Option, +} + +/// Metric checked by a `[[rules.limit]]` rule. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Metric { + FanIn, + FanOut, + Complexity, + FileSymbols, +} + +impl Metric { + pub fn as_str(self) -> &'static str { + match self { + Metric::FanIn => "fan_in", + Metric::FanOut => "fan_out", + Metric::Complexity => "complexity", + Metric::FileSymbols => "file_symbols", + } + } +} + +/// Scope of a `[[rules.limit]]` rule. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Scope { + #[default] + Symbol, + File, +} + +impl Scope { + pub fn as_str(self) -> &'static str { + match self { + Scope::Symbol => "symbol", + Scope::File => "file", + } + } +} + +// ============================================================================ +// Compilation and validation +// ============================================================================ + +/// A [`RulesFile`] with all glob patterns compiled. +#[derive(Debug)] +pub struct CompiledRules { + pub file: RulesFile, + /// Layer name + compiled globs, in declaration (BTreeMap) order. + layer_globs: Vec<(String, GlobSet)>, + /// Compiled `exclude` globs, parallel to `file.rules.limit`. + limit_excludes: Vec, + /// Compiled `paths` globs, parallel to `file.rules.no_new_dependents`. + frozen_paths: Vec, +} + +impl CompiledRules { + /// Compile all glob patterns in `file`. + /// + /// Fails on unsupported versions and invalid glob patterns. + pub fn compile(file: RulesFile) -> Result { + if file.version != SUPPORTED_VERSION { + return Err(CtxError::Other(format!( + "unsupported rules version {} (this build supports version {})", + file.version, SUPPORTED_VERSION + ))); + } + + let mut layer_globs = Vec::new(); + for (name, patterns) in &file.layers { + let set = build_globset(patterns, &format!("layer '{}'", name))?; + layer_globs.push((name.clone(), set)); + } + + let mut limit_excludes = Vec::new(); + for (i, rule) in file.rules.limit.iter().enumerate() { + limit_excludes.push(build_globset( + &rule.exclude, + &format!("rules.limit[{}].exclude", i), + )?); + } + + let mut frozen_paths = Vec::new(); + for (i, rule) in file.rules.no_new_dependents.iter().enumerate() { + frozen_paths.push(build_globset( + &rule.paths, + &format!("rules.no_new_dependents[{}].paths", i), + )?); + } + + Ok(CompiledRules { + file, + layer_globs, + limit_excludes, + frozen_paths, + }) + } + + /// The layer `path` belongs to, or `None` if it matches no layer. + /// + /// [`Self::validate`] guarantees indexed files match at most one layer. + pub fn layer_of(&self, path: &str) -> Option<&str> { + self.layer_globs + .iter() + .find(|(_, set)| set.is_match(path)) + .map(|(name, _)| name.as_str()) + } + + /// All layers `path` belongs to (used for overlap detection). + pub fn layers_of(&self, path: &str) -> Vec<&str> { + self.layer_globs + .iter() + .filter(|(_, set)| set.is_match(path)) + .map(|(name, _)| name.as_str()) + .collect() + } + + /// Validate the rules against the set of indexed files. + /// + /// Errors on: + /// - rules referencing undeclared layer names + /// - an indexed file matching two or more layers + /// - `metric = "file_symbols"` combined with `scope = "symbol"` + pub fn validate(&self, indexed_files: &[String]) -> Result<()> { + // Unknown layer references. + for rule in &self.file.rules.forbidden { + self.require_layer(&rule.from, "rules.forbidden.from")?; + self.require_layer(&rule.to, "rules.forbidden.to")?; + } + for rule in &self.file.rules.allowed_dependents { + self.require_layer(&rule.layer, "rules.allowed_dependents.layer")?; + for only in &rule.only { + self.require_layer(only, "rules.allowed_dependents.only")?; + } + } + + // file_symbols is a per-file metric; symbol scope makes no sense. + for rule in &self.file.rules.limit { + if rule.metric == Metric::FileSymbols && rule.scope == Scope::Symbol { + return Err(CtxError::Other( + "rules.limit: metric \"file_symbols\" requires scope = \"file\" \ + (it counts symbols per file and has no per-symbol value)" + .to_string(), + )); + } + } + + // Overlapping layers: every indexed file must match at most one layer. + for path in indexed_files { + let layers = self.layers_of(path); + if layers.len() >= 2 { + return Err(CtxError::Other(format!( + "layer overlap: file '{}' matches layers [{}]; \ + each file may belong to at most one layer", + path, + layers.join(", ") + ))); + } + } + + Ok(()) + } + + fn require_layer(&self, name: &str, context: &str) -> Result<()> { + if self.file.layers.contains_key(name) { + Ok(()) + } else { + let known: Vec<&str> = self.file.layers.keys().map(|k| k.as_str()).collect(); + Err(CtxError::Other(format!( + "{}: unknown layer '{}' (declared layers: [{}])", + context, + name, + known.join(", ") + ))) + } + } + + /// Compiled `exclude` globset for `file.rules.limit[i]`. + pub fn limit_exclude(&self, i: usize) -> &GlobSet { + &self.limit_excludes[i] + } + + /// Compiled `paths` globset for `file.rules.no_new_dependents[i]`. + pub fn frozen_path(&self, i: usize) -> &GlobSet { + &self.frozen_paths[i] + } + + /// Count of indexed files per layer, in declaration order. + pub fn layer_file_counts(&self, indexed_files: &[String]) -> Vec<(String, usize)> { + self.layer_globs + .iter() + .map(|(name, set)| { + let n = indexed_files.iter().filter(|f| set.is_match(f)).count(); + (name.clone(), n) + }) + .collect() + } +} + +fn build_globset(patterns: &[String], what: &str) -> Result { + let mut builder = GlobSetBuilder::new(); + for pattern in patterns { + let glob = Glob::new(pattern).map_err(|e| { + CtxError::Other(format!("invalid glob '{}' in {}: {}", pattern, what, e)) + })?; + builder.add(glob); + } + builder + .build() + .map_err(|e| CtxError::Other(format!("failed to build globs for {}: {}", what, e))) +} + +// ============================================================================ +// Dependency and violation model +// ============================================================================ + +/// One endpoint of a dependency: either a resolved symbol or a whole file +/// (file-level endpoints come from import resolution). +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] +pub enum Endpoint { + Symbol(SymbolRef), + File { file: String }, +} + +impl Endpoint { + /// The file this endpoint lives in. + pub fn file(&self) -> &str { + match self { + Endpoint::Symbol(s) => &s.file, + Endpoint::File { file } => file, + } + } +} + +/// A cross-file dependency (call/implements/extends/uses edge or resolved import). +#[derive(Debug, Clone)] +pub struct FileDep { + pub from: Endpoint, + pub to: Endpoint, + /// Edge kind (`calls`, `implements`, `extends`, `uses`) or `import`. + pub kind: String, + /// Line in the `from` file where the dependency occurs (unknown for + /// imports read from module metadata). + pub line: Option, +} + +/// Which rule group a violation belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RuleKind { + Forbidden, + AllowedDependents, + Limit, + NoNewDependents, +} + +impl RuleKind { + pub fn as_str(self) -> &'static str { + match self { + RuleKind::Forbidden => "forbidden", + RuleKind::AllowedDependents => "allowed_dependents", + RuleKind::Limit => "limit", + RuleKind::NoNewDependents => "no_new_dependents", + } + } +} + +/// One rule violation. +/// +/// Dependency violations (`forbidden`, `allowed_dependents`, +/// `no_new_dependents`) carry `from`/`to` endpoints; `limit` violations carry +/// a `subject` endpoint plus the metric fields. +#[derive(Debug, Clone, Serialize)] +pub struct Violation { + pub rule: RuleKind, + /// Stable identifier of the specific rule instance, used for grouping + /// (e.g. `forbidden: domain -> infrastructure`). + pub rule_id: String, + pub reason: String, + /// One-line human description of the violating dependency or metric. + pub message: String, + /// Primary file (`from` side for dependency rules, subject file for limits). + pub file: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub line: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub from: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metric: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max: Option, +} + +impl Violation { + fn dep(rule: RuleKind, rule_id: String, reason: String, dep: &FileDep) -> Violation { + let location = match dep.line { + Some(line) => format!("{}:{}", dep.from.file(), line), + None => dep.from.file().to_string(), + }; + let target = match &dep.to { + Endpoint::Symbol(s) => format!("{} [{} {}]", s.file, dep.kind, s.name), + Endpoint::File { file } => format!("{} [{}]", file, dep.kind), + }; + let message = format!("{} -> {}", location, target); + Violation { + rule, + rule_id, + reason, + message, + file: dep.from.file().to_string(), + line: dep.line, + from: Some(dep.from.clone()), + to: Some(dep.to.clone()), + subject: None, + metric: None, + scope: None, + value: None, + max: None, + } + } + + /// Files referenced by this violation (for `--against` filtering). + fn endpoint_files(&self) -> Vec<&str> { + let mut files = vec![self.file.as_str()]; + for endpoint in [&self.from, &self.to, &self.subject].into_iter().flatten() { + files.push(endpoint.file()); + } + files + } +} + +// ============================================================================ +// Evaluation +// ============================================================================ + +/// Evaluate all rules and return the violations. +/// +/// `changed` is the `--against REF` change set. When present it acts as a +/// filter (documented v1 approximation): +/// - `no_new_dependents`: only inbound deps whose *source* file changed count +/// (that is what "new dependent" means); without `--against` **all** inbound +/// deps are reported so users can see the current state. +/// - all other rules: a violation is kept when at least one endpoint's file +/// is in the change set. +pub fn evaluate( + compiled: &CompiledRules, + deps: &[FileDep], + symbol_metrics: &[SymbolMetrics], + file_complexity: &[FileComplexity], + changed: Option<&HashSet>, +) -> Vec { + let mut violations = Vec::new(); + + violations.extend(eval_forbidden(compiled, deps)); + violations.extend(eval_allowed_dependents(compiled, deps)); + violations.extend(eval_limits(compiled, symbol_metrics, file_complexity)); + violations.extend(eval_no_new_dependents(compiled, deps, changed)); + + if let Some(changed) = changed { + violations.retain(|v| { + // no_new_dependents already applied its own (source-side) filter. + v.rule == RuleKind::NoNewDependents + || v.endpoint_files().iter().any(|f| changed.contains(*f)) + }); + } + + violations +} + +/// C2: dependency edges from `from`-layer files into `to`-layer files. +fn eval_forbidden(compiled: &CompiledRules, deps: &[FileDep]) -> Vec { + let mut violations = Vec::new(); + for rule in &compiled.file.rules.forbidden { + let rule_id = format!("forbidden: {} -> {}", rule.from, rule.to); + let reason = rule + .reason + .clone() + .unwrap_or_else(|| format!("layer '{}' must not depend on '{}'", rule.from, rule.to)); + for dep in deps { + if compiled.layer_of(dep.from.file()) == Some(rule.from.as_str()) + && compiled.layer_of(dep.to.file()) == Some(rule.to.as_str()) + { + violations.push(Violation::dep( + RuleKind::Forbidden, + rule_id.clone(), + reason.clone(), + dep, + )); + } + } + } + violations +} + +/// C3: inbound deps into `layer` from a file whose layer is not in `only`. +/// +/// Files in no layer are exempt (unlayered files are unconstrained, matching +/// the C1 layer model), and a layer may always depend on itself. +fn eval_allowed_dependents(compiled: &CompiledRules, deps: &[FileDep]) -> Vec { + let mut violations = Vec::new(); + for rule in &compiled.file.rules.allowed_dependents { + let rule_id = format!( + "allowed_dependents: {} only [{}]", + rule.layer, + rule.only.join(", ") + ); + let reason = rule.reason.clone().unwrap_or_else(|| { + format!( + "only [{}] may depend on layer '{}'", + rule.only.join(", "), + rule.layer + ) + }); + for dep in deps { + if compiled.layer_of(dep.to.file()) != Some(rule.layer.as_str()) { + continue; + } + let Some(from_layer) = compiled.layer_of(dep.from.file()) else { + continue; // unlayered files are unconstrained + }; + if from_layer == rule.layer || rule.only.iter().any(|o| o == from_layer) { + continue; + } + violations.push(Violation::dep( + RuleKind::AllowedDependents, + rule_id.clone(), + format!("{} (found dependent in layer '{}')", reason, from_layer), + dep, + )); + } + } + violations +} + +/// C4: metric thresholds over symbols or files. +fn eval_limits( + compiled: &CompiledRules, + symbol_metrics: &[SymbolMetrics], + file_complexity: &[FileComplexity], +) -> Vec { + let mut violations = Vec::new(); + + for (i, rule) in compiled.file.rules.limit.iter().enumerate() { + let exclude = compiled.limit_exclude(i); + let rule_id = format!( + "limit: {} <= {} ({})", + rule.metric.as_str(), + rule.max, + rule.scope.as_str() + ); + + match rule.scope { + Scope::Symbol => { + // metric != file_symbols is guaranteed by validate(). + for m in symbol_metrics { + if exclude.is_match(&m.file_path) { + continue; + } + let value = symbol_metric_value(rule.metric, m); + if value > rule.max { + violations.push(limit_violation( + rule, + rule_id.clone(), + Endpoint::Symbol(symbol_ref_of(m)), + format!("{}:{}", m.file_path, m.line_start), + m.file_path.clone(), + Some(m.line_start), + Some(&m.name), + value, + )); + } + } + } + Scope::File => { + // file_symbols comes straight from per-file symbol counts; + // other metrics are summed over the file's functions/methods. + let per_file: BTreeMap = if rule.metric == Metric::FileSymbols { + file_complexity + .iter() + .map(|f| (f.file_path.clone(), f.symbol_count)) + .collect() + } else { + let mut sums: BTreeMap = BTreeMap::new(); + for m in symbol_metrics { + *sums.entry(m.file_path.clone()).or_insert(0) += + symbol_metric_value(rule.metric, m); + } + sums + }; + + for (file, value) in per_file { + if exclude.is_match(&file) || value <= rule.max { + continue; + } + violations.push(limit_violation( + rule, + rule_id.clone(), + Endpoint::File { file: file.clone() }, + file.clone(), + file, + None, + None, + value, + )); + } + } + } + } + + violations +} + +fn symbol_metric_value(metric: Metric, m: &SymbolMetrics) -> i64 { + match metric { + Metric::FanIn => m.fan_in, + Metric::FanOut => m.fan_out, + Metric::Complexity => m.complexity, + // Unreachable for symbol scope (rejected by validate()); a file-level + // sum of file_symbols never consults symbol metrics. + Metric::FileSymbols => 0, + } +} + +#[allow(clippy::too_many_arguments)] +fn limit_violation( + rule: &LimitRule, + rule_id: String, + subject: Endpoint, + location: String, + file: String, + line: Option, + name: Option<&str>, + value: i64, +) -> Violation { + let what = match name { + Some(name) => format!("{} ({})", location, name), + None => location, + }; + Violation { + rule: RuleKind::Limit, + rule_id, + reason: format!( + "{} {} exceeds max {}", + rule.metric.as_str(), + value, + rule.max + ), + message: format!( + "{}: {} {} exceeds max {}", + what, + rule.metric.as_str(), + value, + rule.max + ), + file, + line, + from: None, + to: None, + subject: Some(subject), + metric: Some(rule.metric), + scope: Some(rule.scope), + value: Some(value), + max: Some(rule.max), + } +} + +fn symbol_ref_of(m: &SymbolMetrics) -> SymbolRef { + SymbolRef { + name: m.name.clone(), + qualified_name: m.qualified_name.clone(), + kind: m.kind.clone(), + file: m.file_path.clone(), + line_start: m.line_start, + line_end: m.line_end, + } +} + +/// C5: inbound deps into frozen `paths` from outside those paths. +/// +/// With `--against` (`changed` is `Some`), only deps whose source file +/// changed are violations ("new dependents"). Without it, **all** inbound +/// deps are reported so users can see the current state. +fn eval_no_new_dependents( + compiled: &CompiledRules, + deps: &[FileDep], + changed: Option<&HashSet>, +) -> Vec { + let mut violations = Vec::new(); + for (i, rule) in compiled.file.rules.no_new_dependents.iter().enumerate() { + let paths = compiled.frozen_path(i); + let rule_id = format!("no_new_dependents: [{}]", rule.paths.join(", ")); + let reason = rule + .reason + .clone() + .unwrap_or_else(|| format!("[{}] must not gain new dependents", rule.paths.join(", "))); + for dep in deps { + if !paths.is_match(dep.to.file()) || paths.is_match(dep.from.file()) { + continue; + } + if let Some(changed) = changed { + if !changed.contains(dep.from.file()) { + continue; + } + } + violations.push(Violation::dep( + RuleKind::NoNewDependents, + rule_id.clone(), + reason.clone(), + dep, + )); + } + } + violations +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + const FULL_RULES: &str = r#" +version = 1 + +[layers] +domain = ["src/domain/**"] +application = ["src/app/**"] +infrastructure = ["src/infra/**", "src/db/**"] + +[[rules.forbidden]] +from = "domain" +to = "infrastructure" +reason = "Domain layer must stay persistence-agnostic" + +[[rules.allowed_dependents]] +layer = "infrastructure" +only = ["application"] + +[[rules.limit]] +metric = "fan_in" +scope = "symbol" +max = 25 +exclude = ["src/core/**"] + +[[rules.limit]] +metric = "file_symbols" +scope = "file" +max = 50 + +[[rules.no_new_dependents]] +paths = ["src/legacy/**"] +reason = "Legacy module is frozen; do not add new callers" +"#; + + fn compile(toml_src: &str) -> CompiledRules { + let file: RulesFile = toml::from_str(toml_src).unwrap(); + CompiledRules::compile(file).unwrap() + } + + fn file_dep(from: &str, to: &str) -> FileDep { + FileDep { + from: Endpoint::File { + file: from.to_string(), + }, + to: Endpoint::File { + file: to.to_string(), + }, + kind: "import".to_string(), + line: None, + } + } + + fn symbol_dep(from_file: &str, from_name: &str, to_file: &str, to_name: &str) -> FileDep { + let sym = |file: &str, name: &str| { + Endpoint::Symbol(SymbolRef { + name: name.to_string(), + qualified_name: None, + kind: "function".to_string(), + file: file.to_string(), + line_start: 1, + line_end: 2, + }) + }; + FileDep { + from: sym(from_file, from_name), + to: sym(to_file, to_name), + kind: "calls".to_string(), + line: Some(1), + } + } + + fn metrics( + name: &str, + file: &str, + fan_in: i64, + fan_out: i64, + complexity: i64, + ) -> SymbolMetrics { + SymbolMetrics { + id: format!("{}::{}", file, name), + name: name.to_string(), + qualified_name: None, + kind: "function".to_string(), + file_path: file.to_string(), + line_start: 10, + line_end: 20, + fan_in, + fan_out, + complexity, + } + } + + // ---------- parsing ---------- + + #[test] + fn test_parse_full_rules_file() { + let file: RulesFile = toml::from_str(FULL_RULES).unwrap(); + assert_eq!(file.version, 1); + assert_eq!(file.layers.len(), 3); + assert_eq!(file.layers["infrastructure"].len(), 2); + assert_eq!(file.rules.forbidden.len(), 1); + assert_eq!(file.rules.forbidden[0].from, "domain"); + assert_eq!(file.rules.allowed_dependents.len(), 1); + assert_eq!(file.rules.limit.len(), 2); + assert_eq!(file.rules.limit[0].metric, Metric::FanIn); + assert_eq!(file.rules.limit[0].scope, Scope::Symbol); + assert_eq!(file.rules.limit[0].max, 25); + assert_eq!(file.rules.no_new_dependents.len(), 1); + } + + #[test] + fn test_parse_defaults() { + // Empty file: everything defaults. + let file: RulesFile = toml::from_str("").unwrap(); + assert_eq!(file.version, 1); + assert!(file.layers.is_empty()); + assert!(file.rules.forbidden.is_empty()); + + // Scope defaults to symbol; exclude defaults to empty. + let file: RulesFile = toml::from_str( + r#" + [[rules.limit]] + metric = "fan_out" + max = 10 + "#, + ) + .unwrap(); + assert_eq!(file.rules.limit[0].scope, Scope::Symbol); + assert!(file.rules.limit[0].exclude.is_empty()); + } + + #[test] + fn test_parse_rejects_unknown_fields_and_bad_values() { + // Typo in a rule field. + let err = toml::from_str::( + r#" + [[rules.forbidden]] + form = "domain" + to = "infrastructure" + "#, + ) + .unwrap_err(); + assert!(err.to_string().contains("form"), "err: {}", err); + + // Unknown rule group. + assert!(toml::from_str::("[[rules.forbid]]\nx = 1").is_err()); + + // Invalid metric value. + let err = toml::from_str::( + r#" + [[rules.limit]] + metric = "fan_inn" + max = 5 + "#, + ) + .unwrap_err(); + assert!(err.to_string().contains("fan_inn"), "err: {}", err); + + // Invalid TOML syntax. + assert!(toml::from_str::("[layers\ndomain = 1").is_err()); + } + + #[test] + fn test_compile_rejects_unsupported_version() { + let file: RulesFile = toml::from_str("version = 2").unwrap(); + let err = CompiledRules::compile(file).unwrap_err(); + assert!(err.to_string().contains("version 2"), "err: {}", err); + } + + #[test] + fn test_compile_rejects_invalid_glob() { + let file: RulesFile = toml::from_str( + r#" + [layers] + domain = ["src/[oops"] + "#, + ) + .unwrap(); + let err = CompiledRules::compile(file).unwrap_err(); + assert!(err.to_string().contains("src/[oops"), "err: {}", err); + } + + // ---------- validation ---------- + + #[test] + fn test_validate_ok() { + let compiled = compile(FULL_RULES); + let files = vec![ + "src/domain/order.ts".to_string(), + "src/app/checkout.ts".to_string(), + "src/infra/db.ts".to_string(), + "src/unlayered.ts".to_string(), + ]; + compiled.validate(&files).unwrap(); + } + + #[test] + fn test_validate_unknown_layer() { + let compiled = compile( + r#" + [layers] + domain = ["src/domain/**"] + + [[rules.forbidden]] + from = "domain" + to = "infrastruture" + "#, + ); + let err = compiled.validate(&[]).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("unknown layer 'infrastruture'"), + "err: {}", + msg + ); + assert!(msg.contains("domain"), "err: {}", msg); + } + + #[test] + fn test_validate_layer_overlap_names_file_and_layers() { + let compiled = compile( + r#" + [layers] + domain = ["src/domain/**"] + everything = ["src/**"] + "#, + ); + let files = vec!["src/domain/order.ts".to_string()]; + let err = compiled.validate(&files).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("src/domain/order.ts"), "err: {}", msg); + assert!(msg.contains("domain"), "err: {}", msg); + assert!(msg.contains("everything"), "err: {}", msg); + } + + #[test] + fn test_validate_file_symbols_symbol_scope_is_error() { + let compiled = compile( + r#" + [[rules.limit]] + metric = "file_symbols" + scope = "symbol" + max = 50 + "#, + ); + let err = compiled.validate(&[]).unwrap_err(); + assert!(err.to_string().contains("file_symbols"), "err: {}", err); + } + + #[test] + fn test_layer_of() { + let compiled = compile(FULL_RULES); + assert_eq!(compiled.layer_of("src/domain/order.ts"), Some("domain")); + assert_eq!(compiled.layer_of("src/db/pool.ts"), Some("infrastructure")); + assert_eq!(compiled.layer_of("src/other/x.ts"), None); + } + + // ---------- evaluation ---------- + + #[test] + fn test_eval_forbidden() { + let compiled = compile(FULL_RULES); + let deps = vec![ + file_dep("src/domain/order.ts", "src/infra/db.ts"), // violation + file_dep("src/app/checkout.ts", "src/infra/db.ts"), // fine + file_dep("src/domain/order.ts", "src/domain/item.ts"), // fine + symbol_dep("src/domain/pay.ts", "pay", "src/db/pool.ts", "query"), // violation + ]; + let violations = eval_forbidden(&compiled, &deps); + assert_eq!(violations.len(), 2); + assert_eq!(violations[0].rule, RuleKind::Forbidden); + assert_eq!(violations[0].file, "src/domain/order.ts"); + assert_eq!( + violations[0].reason, + "Domain layer must stay persistence-agnostic" + ); + assert_eq!(violations[1].file, "src/domain/pay.ts"); + assert_eq!(violations[1].line, Some(1)); + } + + #[test] + fn test_eval_allowed_dependents_exempts_unlayered_and_self() { + let compiled = compile(FULL_RULES); + let deps = vec![ + file_dep("src/app/checkout.ts", "src/infra/db.ts"), // allowed + file_dep("src/domain/order.ts", "src/infra/db.ts"), // violation + file_dep("src/scripts/tool.ts", "src/infra/db.ts"), // unlayered -> exempt + file_dep("src/infra/cache.ts", "src/infra/db.ts"), // same layer -> exempt + ]; + let violations = eval_allowed_dependents(&compiled, &deps); + assert_eq!(violations.len(), 1); + assert_eq!(violations[0].rule, RuleKind::AllowedDependents); + assert_eq!(violations[0].file, "src/domain/order.ts"); + assert!( + violations[0].reason.contains("domain"), + "{}", + violations[0].reason + ); + } + + #[test] + fn test_eval_limit_symbol_scope_with_exclude() { + let compiled = compile(FULL_RULES); // fan_in <= 25 (symbol), exclude src/core/** + let symbol_metrics = vec![ + metrics("hot", "src/app/hub.ts", 30, 0, 30), // violation + metrics("ok", "src/app/x.ts", 25, 0, 25), // at the limit: fine + metrics("core", "src/core/bus.ts", 99, 0, 99), // excluded + ]; + let violations = eval_limits(&compiled, &symbol_metrics, &[]); + assert_eq!(violations.len(), 1); + let v = &violations[0]; + assert_eq!(v.rule, RuleKind::Limit); + assert_eq!(v.metric, Some(Metric::FanIn)); + assert_eq!(v.value, Some(30)); + assert_eq!(v.max, Some(25)); + assert_eq!(v.file, "src/app/hub.ts"); + match v.subject.as_ref().unwrap() { + Endpoint::Symbol(s) => assert_eq!(s.name, "hot"), + other => panic!("expected symbol subject, got {:?}", other), + } + } + + #[test] + fn test_eval_limit_file_scope_sums_and_counts() { + let compiled = compile( + r#" + [[rules.limit]] + metric = "fan_out" + scope = "file" + max = 10 + + [[rules.limit]] + metric = "file_symbols" + scope = "file" + max = 2 + "#, + ); + let symbol_metrics = vec![ + metrics("a", "src/big.ts", 0, 7, 14), + metrics("b", "src/big.ts", 0, 6, 12), // sum fan_out = 13 > 10 + metrics("c", "src/small.ts", 0, 3, 6), + ]; + let file_complexity = vec![ + FileComplexity { + file_path: "src/big.ts".to_string(), + complexity: 26, + fan_out: 13, + symbol_count: 3, // > 2 -> violation + }, + FileComplexity { + file_path: "src/small.ts".to_string(), + complexity: 6, + fan_out: 3, + symbol_count: 1, + }, + ]; + let violations = eval_limits(&compiled, &symbol_metrics, &file_complexity); + assert_eq!(violations.len(), 2); + assert_eq!(violations[0].metric, Some(Metric::FanOut)); + assert_eq!(violations[0].value, Some(13)); + assert_eq!(violations[0].file, "src/big.ts"); + assert_eq!(violations[1].metric, Some(Metric::FileSymbols)); + assert_eq!(violations[1].value, Some(3)); + } + + #[test] + fn test_eval_no_new_dependents_without_against_reports_all_inbound() { + let compiled = compile(FULL_RULES); + let deps = vec![ + file_dep("src/app/checkout.ts", "src/legacy/billing.ts"), // inbound + file_dep("src/legacy/a.ts", "src/legacy/billing.ts"), // internal: fine + file_dep("src/app/checkout.ts", "src/app/cart.ts"), // unrelated + ]; + let violations = eval_no_new_dependents(&compiled, &deps, None); + assert_eq!(violations.len(), 1); + assert_eq!(violations[0].rule, RuleKind::NoNewDependents); + assert_eq!(violations[0].file, "src/app/checkout.ts"); + assert_eq!( + violations[0].reason, + "Legacy module is frozen; do not add new callers" + ); + } + + #[test] + fn test_eval_no_new_dependents_with_against_requires_changed_source() { + let compiled = compile(FULL_RULES); + let deps = vec![ + file_dep("src/app/old.ts", "src/legacy/billing.ts"), // pre-existing + file_dep("src/app/new.ts", "src/legacy/billing.ts"), // new dependent + ]; + let changed: HashSet = ["src/app/new.ts".to_string()].into(); + let violations = eval_no_new_dependents(&compiled, &deps, Some(&changed)); + assert_eq!(violations.len(), 1); + assert_eq!(violations[0].file, "src/app/new.ts"); + } + + #[test] + fn test_evaluate_against_filters_by_endpoint() { + let compiled = compile(FULL_RULES); + let deps = vec![ + file_dep("src/domain/old.ts", "src/infra/db.ts"), // untouched + file_dep("src/domain/new.ts", "src/infra/db.ts"), // changed source + ]; + let symbol_metrics = vec![ + metrics("hot", "src/app/hub.ts", 30, 0, 30), // untouched + metrics("warm", "src/app/new2.ts", 40, 0, 40), // changed file + ]; + + // Without --against: everything is reported. + let all = evaluate(&compiled, &deps, &symbol_metrics, &[], None); + // 2 forbidden + 2 allowed_dependents + 2 limit = 6 + assert_eq!(all.len(), 6); + + let changed: HashSet = [ + "src/domain/new.ts".to_string(), + "src/app/new2.ts".to_string(), + ] + .into(); + let filtered = evaluate(&compiled, &deps, &symbol_metrics, &[], Some(&changed)); + assert_eq!(filtered.len(), 3); + assert!(filtered + .iter() + .all(|v| v.endpoint_files().iter().any(|f| changed.contains(*f)))); + } + + #[test] + fn test_violation_json_shape() { + let compiled = compile(FULL_RULES); + let deps = vec![symbol_dep( + "src/domain/pay.ts", + "pay", + "src/infra/db.ts", + "query", + )]; + let violations = eval_forbidden(&compiled, &deps); + let value = serde_json::to_value(&violations[0]).unwrap(); + assert_eq!(value["rule"], "forbidden"); + assert_eq!( + value["reason"], + "Domain layer must stay persistence-agnostic" + ); + // Symbol endpoints serialize as full SymbolRefs. + assert_eq!(value["from"]["name"], "pay"); + assert_eq!(value["from"]["file"], "src/domain/pay.ts"); + assert_eq!(value["to"]["kind"], "function"); + // Absent fields are omitted, not null. + assert!(value.get("subject").is_none()); + assert!(value.get("metric").is_none()); + + // File endpoints serialize as {"file": ...} objects. + let file_violations = eval_forbidden( + &compiled, + &[file_dep("src/domain/order.ts", "src/infra/db.ts")], + ); + let value = serde_json::to_value(&file_violations[0]).unwrap(); + assert_eq!( + value["from"], + serde_json::json!({"file": "src/domain/order.ts"}) + ); + } +}