Skip to main content

omni_dev/cli/config/
scopes.rs

1//! `config scopes` CLI commands — scope-taxonomy operations on
2//! `.omni-dev/scopes.yaml`.
3//!
4//! `usage` tallies declared commit scopes against `scopes.yaml`; `lint`
5//! validates `scopes.yaml` against the source tree it claims to describe
6//! (issue #1475). Lint is zero AI, zero network: both its assertions are
7//! pure globset matching against the tracked-file list, reusing
8//! `crate::git::commit::scope_matches_files` and `crate::git::resolve_scope`
9//! rather than a second matcher.
10
11use std::path::{Path, PathBuf};
12
13use anyhow::{Context, Result};
14use clap::{Parser, Subcommand};
15use globset::GlobMatcher;
16
17use crate::data::check::OutputFormat;
18use crate::data::context::ScopeDefinition;
19use crate::data::scopes_lint::{DeadPattern, ScopesLintReport};
20use crate::git::commit::{tally_scope_usage, ScopeUsageReport};
21
22/// Scopes operations.
23#[derive(Parser)]
24pub struct ScopesCommand {
25    /// Scopes subcommand to execute.
26    #[command(subcommand)]
27    pub command: ScopesSubcommands,
28
29    /// `-C/--repo`, inherited by every scopes subcommand.
30    #[command(flatten)]
31    pub repo: crate::cli::repo_arg::RepoArg,
32}
33
34/// Scopes subcommands.
35#[derive(Subcommand)]
36pub enum ScopesSubcommands {
37    /// Tallies declared commit scopes against `scopes.yaml`, reporting
38    /// unknown, unused, and scope-less commits.
39    Usage(UsageCommand),
40    /// Validates scopes.yaml against the source tree: every `file_patterns`
41    /// entry must match a tracked file, and every tracked file under
42    /// `--root` must be matched by some scope.
43    Lint(LintCommand),
44}
45
46impl ScopesCommand {
47    /// Executes the scopes command, resolving `-C/--repo` once
48    /// (`None` = current working directory) for its leaves.
49    pub fn execute(self) -> Result<()> {
50        let repo = self.repo.path();
51        match self.command {
52            ScopesSubcommands::Usage(usage_cmd) => usage_cmd.execute(repo),
53            ScopesSubcommands::Lint(cmd) => cmd.execute(repo),
54        }
55    }
56}
57
58/// Usage command options — tallies declared commit scopes against history.
59#[derive(Parser)]
60pub struct UsageCommand {
61    /// Commit range to analyze (e.g., HEAD~300..HEAD, abc123..def456).
62    /// Defaults to the entire history reachable from HEAD.
63    #[arg(value_name = "COMMIT_RANGE", conflicts_with = "max_count")]
64    pub commit_range: Option<String>,
65
66    /// Limits analysis to the newest N commits reachable from HEAD (ignored
67    /// when COMMIT_RANGE is given). Useful for comparing the answer at
68    /// different window sizes, e.g. `-n 150` vs `-n 400`.
69    #[arg(short = 'n', long = "max-count", value_name = "N")]
70    pub max_count: Option<usize>,
71
72    /// Path to custom context directory (defaults to .omni-dev/).
73    #[arg(long)]
74    pub context_dir: Option<PathBuf>,
75
76    /// Excludes ecosystem default scopes (cargo/core/lib/test, …) from the
77    /// known set, so they are reported as unknown rather than accepted.
78    #[arg(long)]
79    pub project_only: bool,
80
81    /// Output format.
82    #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Text)]
83    pub output: OutputFormat,
84}
85
86impl UsageCommand {
87    /// Executes the usage command: tallies declared scopes across the
88    /// resolved commit range and prints a report. Always exits 0 on a
89    /// successful tally (including an empty range) — this is a reporting
90    /// command, never a gate.
91    pub fn execute(self, repo: Option<&Path>) -> Result<()> {
92        let repo_root = match repo {
93            Some(p) => p.to_path_buf(),
94            None => std::env::current_dir().context("Failed to determine current directory")?,
95        };
96        let repo_root = repo_root.as_path();
97
98        let git_repo = crate::git::GitRepository::open_at(repo_root)
99            .context("Failed to open git repository at the given path")?;
100
101        let commits = match &self.commit_range {
102            Some(range) => git_repo.get_commits_in_range(range)?,
103            None => git_repo.get_commits_from_head(self.max_count)?,
104        };
105
106        let subjects: Vec<&str> = commits
107            .iter()
108            .map(|c| c.original_message.lines().next().unwrap_or("").trim())
109            .collect();
110
111        let context_dir =
112            crate::claude::context::resolve_context_dir_at(self.context_dir.as_deref(), repo_root);
113        let scopes_yaml_only = crate::claude::context::load_project_scopes_only(&context_dir);
114        let known_scopes = if self.project_only {
115            scopes_yaml_only.clone()
116        } else {
117            crate::claude::context::load_project_scopes(&context_dir, repo_root)
118        };
119
120        let report = tally_scope_usage(&subjects, &known_scopes, &scopes_yaml_only);
121
122        self.output_report(&report)
123    }
124
125    /// Renders `report` per `self.output`.
126    fn output_report(&self, report: &ScopeUsageReport) -> Result<()> {
127        match self.output {
128            OutputFormat::Text => print!("{}", format_text_report(report)),
129            OutputFormat::Json => {
130                let json = serde_json::to_string_pretty(report)
131                    .context("Failed to serialize report to JSON")?;
132                println!("{json}");
133            }
134            OutputFormat::Yaml => {
135                let yaml =
136                    crate::data::to_yaml(report).context("Failed to serialize report to YAML")?;
137                println!("{yaml}");
138            }
139        }
140        Ok(())
141    }
142}
143
144/// Formats a [`ScopeUsageReport`] as human-readable text.
145fn format_text_report(report: &ScopeUsageReport) -> String {
146    use std::fmt::Write;
147
148    let mut out = String::new();
149    let _ = writeln!(
150        out,
151        "📊 Scope usage: {} commit(s) analyzed",
152        report.total_commits
153    );
154    out.push('\n');
155
156    if report.declared.is_empty() {
157        out.push_str("Declared scopes: (none)\n");
158    } else {
159        out.push_str("Declared scopes:\n");
160        for sc in &report.declared {
161            let _ = writeln!(out, "  {:<20} {}", sc.name, sc.count);
162        }
163    }
164    out.push('\n');
165
166    if !report.unknown.is_empty() {
167        out.push_str("⚠️  Unknown (declared, not in scopes.yaml):\n");
168        for sc in &report.unknown {
169            let _ = writeln!(out, "  {:<20} {}", sc.name, sc.count);
170        }
171        out.push('\n');
172    }
173
174    if !report.unused.is_empty() {
175        out.push_str("💤 Unused (defined in scopes.yaml, never declared):\n");
176        for name in &report.unused {
177            let _ = writeln!(out, "  {name}");
178        }
179        out.push('\n');
180    }
181
182    let _ = writeln!(out, "Scope-less commits: {}", report.scope_less_count);
183
184    out
185}
186
187/// Lint command options.
188#[derive(Parser)]
189pub struct LintCommand {
190    /// Root path(s) to check for scope coverage (repeatable).
191    #[arg(long, default_value = "src")]
192    pub root: Vec<String>,
193
194    /// Disables `--project-only` (on by default): folds in the
195    /// ecosystem-detected default scopes (e.g. Rust's `lib` =
196    /// `["src/lib.rs", "src/**"]`) before checking coverage. Without
197    /// `--project-only`, a catch-all ecosystem scope can make the "every
198    /// file is scoped" check vacuously true.
199    #[arg(long)]
200    pub no_project_only: bool,
201
202    /// Additional glob(s) for paths that need no scope (repeatable).
203    /// Unioned with any `allow:` list in scopes.yaml.
204    #[arg(long)]
205    pub allow: Vec<String>,
206
207    /// Output format.
208    #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Text)]
209    pub output: OutputFormat,
210
211    /// Path to custom context directory (defaults to .omni-dev/).
212    #[arg(long)]
213    pub context_dir: Option<PathBuf>,
214}
215
216impl LintCommand {
217    /// Executes the lint command.
218    pub fn execute(self, repo: Option<&Path>) -> Result<()> {
219        let repo_root = match repo {
220            Some(p) => p.to_path_buf(),
221            None => std::env::current_dir().context("Failed to determine current directory")?,
222        };
223        let repo_root = repo_root.as_path();
224
225        let report = run_scopes_lint(repo_root, &self)?;
226        render_report(&report, self.output)?;
227        std::process::exit(report.exit_code());
228    }
229}
230
231/// Runs the lint against a real repository.
232///
233/// Opens it, loads `scopes.yaml` strictly, conditionally merges ecosystem
234/// defaults, then delegates to the pure [`lint_scopes`]. Separated from
235/// [`LintCommand::execute`] so tests can drive it directly (STYLE-0025)
236/// without spawning the binary.
237pub fn run_scopes_lint(repo_root: &Path, cmd: &LintCommand) -> Result<ScopesLintReport> {
238    let repo = crate::git::GitRepository::open_at(repo_root)
239        .context("Failed to open git repository at the given path")?;
240    let tracked_files = repo.tracked_files()?;
241
242    let context_dir =
243        crate::claude::context::resolve_context_dir_at(cmd.context_dir.as_deref(), repo_root);
244    let scopes_file = crate::claude::context::load_scopes_file_strict(&context_dir)
245        .context("Failed to load .omni-dev/scopes.yaml")?;
246
247    let project_only = !cmd.no_project_only;
248    // `merge_ecosystem_scopes` only skips a default whose *name* already
249    // exists in the vector it's given, so it must be seeded with the
250    // project's own scopes — not called against an empty vector — or a
251    // same-named ecosystem default (e.g. Rust's `test`) gets added
252    // alongside the project's version instead of yielding to it
253    // (docs/plan/config-internals.md's documented "user-defined scopes
254    // always win" contract). Diff back out the seed so this stays the
255    // *additional* scopes assertion 2 folds in, as `lint_scopes` expects.
256    let ecosystem_scopes: Vec<ScopeDefinition> = if project_only {
257        Vec::new()
258    } else {
259        let mut combined = scopes_file.scopes.clone();
260        crate::claude::context::discovery::merge_ecosystem_scopes(&mut combined, repo_root);
261        combined
262            .into_iter()
263            .filter(|s| !scopes_file.scopes.iter().any(|p| p.name == s.name))
264            .collect()
265    };
266
267    let mut allow_globs = cmd.allow.clone();
268    allow_globs.extend(scopes_file.allow.iter().cloned());
269
270    Ok(lint_scopes(
271        &tracked_files,
272        &scopes_file.scopes,
273        &ecosystem_scopes,
274        &cmd.root,
275        &allow_globs,
276        project_only,
277    ))
278}
279
280/// Pure core of `omni-dev config scopes lint` — no filesystem or git I/O, so
281/// this is what the unit tests below drive directly.
282///
283/// - `tracked_files`: the full tracked-file set.
284/// - `project_scopes`: scopes parsed directly from `scopes.yaml`. Assertion
285///   1 (dead patterns) always checks only these — ecosystem scopes are Rust
286///   constants a user cannot fix by editing `scopes.yaml`, so flagging their
287///   patterns dead would not be actionable.
288/// - `ecosystem_scopes`: the scopes `merge_ecosystem_scopes` would inject
289///   for this repo. Folded into the effective scope set assertion 2 checks
290///   coverage against only when `project_only` is `false` — this function
291///   enforces that itself, so passing a non-empty `ecosystem_scopes`
292///   alongside `project_only: true` still excludes it.
293/// - `roots`: repo-relative root paths (no trailing slash) assertion 2
294///   walks.
295/// - `allow_globs`: globs suppressing assertion-2 violations for paths that
296///   legitimately need no scope.
297pub fn lint_scopes(
298    tracked_files: &[String],
299    project_scopes: &[ScopeDefinition],
300    ecosystem_scopes: &[ScopeDefinition],
301    roots: &[String],
302    allow_globs: &[String],
303    project_only: bool,
304) -> ScopesLintReport {
305    let file_refs: Vec<&str> = tracked_files.iter().map(String::as_str).collect();
306
307    let mut dead_patterns = dead_patterns(&file_refs, project_scopes);
308    dead_patterns.sort_by(|a, b| (&a.scope, &a.pattern).cmp(&(&b.scope, &b.pattern)));
309
310    // The pure core enforces `project_only` itself, rather than trusting
311    // the caller to have already passed an empty `ecosystem_scopes` — this
312    // is the load-bearing invariant the flag exists for, so it must not be
313    // bypassable by a caller mistake.
314    let mut effective_scopes: Vec<ScopeDefinition> = project_scopes.to_vec();
315    if !project_only {
316        effective_scopes.extend(ecosystem_scopes.iter().cloned());
317    }
318
319    let allow_matchers = build_allow_matchers(allow_globs);
320    let files_in_scope: Vec<&str> = file_refs
321        .iter()
322        .copied()
323        .filter(|f| file_in_roots(f, roots))
324        .filter(|f| !is_allowed(f, &allow_matchers))
325        .collect();
326
327    let mut unscoped_files = unscoped_files(&files_in_scope, &effective_scopes);
328    unscoped_files.sort();
329
330    ScopesLintReport {
331        roots: roots.to_vec(),
332        project_only,
333        scopes_checked: effective_scopes.len(),
334        files_checked: files_in_scope.len(),
335        dead_patterns,
336        unscoped_files,
337    }
338}
339
340/// Assertion 1: every non-negative `file_patterns` entry must match at
341/// least one tracked file. `!`-prefixed negative patterns are never
342/// reported dead — they exist to exclude matches, not to independently
343/// match anything.
344fn dead_patterns(files: &[&str], scopes: &[ScopeDefinition]) -> Vec<DeadPattern> {
345    let mut dead = Vec::new();
346    for scope in scopes {
347        for pattern in &scope.file_patterns {
348            if pattern.starts_with('!') {
349                continue;
350            }
351            let single = std::slice::from_ref(pattern);
352            if crate::git::commit::scope_matches_files(files, single).is_none() {
353                dead.push(DeadPattern {
354                    scope: scope.name.clone(),
355                    pattern: pattern.clone(),
356                });
357            }
358        }
359    }
360    dead
361}
362
363/// Assertion 2: every file in `files` must resolve to at least one scope.
364/// Calls `resolve_scope` once per file (never batched) so a facade gap like
365/// `src/foo/**` not covering `src/foo.rs` is caught per-file rather than
366/// masked by a sibling file matching the same scope.
367fn unscoped_files(files: &[&str], scopes: &[ScopeDefinition]) -> Vec<String> {
368    files
369        .iter()
370        .filter(|f| crate::git::resolve_scope(std::slice::from_ref(*f), scopes).is_none())
371        .map(|f| (*f).to_string())
372        .collect()
373}
374
375/// Whether `file` falls under one of `roots`. A bare `starts_with(root)`
376/// would wrongly match a sibling directory (root `src` matching
377/// `srcfoo/bar.rs`), so this requires an exact match or a `/`-bounded
378/// prefix.
379fn file_in_roots(file: &str, roots: &[String]) -> bool {
380    roots
381        .iter()
382        .any(|root| file == root || file.starts_with(&format!("{root}/")))
383}
384
385fn build_allow_matchers(allow_globs: &[String]) -> Vec<GlobMatcher> {
386    allow_globs
387        .iter()
388        .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
389        .collect()
390}
391
392fn is_allowed(file: &str, matchers: &[GlobMatcher]) -> bool {
393    matchers.iter().any(|m| m.is_match(file))
394}
395
396/// Renders `report` to stdout in the requested `format` and returns.
397/// Exiting on the report's exit code is the caller's job.
398fn render_report(report: &ScopesLintReport, format: OutputFormat) -> Result<()> {
399    match format {
400        OutputFormat::Text => render_text_report(report),
401        OutputFormat::Json => {
402            println!("{}", serde_json::to_string_pretty(report)?);
403            Ok(())
404        }
405        OutputFormat::Yaml => {
406            println!("{}", crate::data::to_yaml(report)?);
407            Ok(())
408        }
409    }
410}
411
412/// The three ways to clear an unscoped-file violation, printed under the
413/// list so the failure says what to do about it (#1469), not just which
414/// files tripped it.
415const UNSCOPED_REMEDY: &str = "Fix each by adding a scope to scopes.yaml, extending an existing \
416scope's file_patterns, or — only for shared code with no subsystem owner — adding it to \
417`allow:` with a justification.";
418
419fn render_text_report(report: &ScopesLintReport) -> Result<()> {
420    let project_only_label = if report.project_only {
421        "project-only"
422    } else {
423        "project + ecosystem"
424    };
425    println!("🔍 Linting .omni-dev/scopes.yaml against the source tree...");
426    println!(
427        "   📂 Scopes checked: {} ({})",
428        report.scopes_checked, project_only_label
429    );
430    println!("   📁 Roots: {}", report.roots.join(", "));
431    println!("   📄 Files checked: {}", report.files_checked);
432    println!();
433
434    if report.dead_patterns.is_empty() {
435        println!("✅ No dead patterns.");
436    } else {
437        println!("❌ Dead patterns ({}):", report.dead_patterns.len());
438        for dead in &report.dead_patterns {
439            println!("   {} ({})", dead.pattern, dead.scope);
440        }
441    }
442
443    if report.unscoped_files.is_empty() {
444        println!("✅ No unscoped files.");
445    } else {
446        println!("❌ Unscoped files ({}):", report.unscoped_files.len());
447        for file in &report.unscoped_files {
448            println!("   {file}");
449        }
450        println!("   {UNSCOPED_REMEDY}");
451    }
452
453    let total = report.dead_patterns.len() + report.unscoped_files.len();
454    println!();
455    println!(
456        "Summary: {} scopes, {} files checked — {} violation{}",
457        report.scopes_checked,
458        report.files_checked,
459        total,
460        if total == 1 { "" } else { "s" }
461    );
462
463    Ok(())
464}
465
466#[cfg(test)]
467#[allow(clippy::unwrap_used, clippy::expect_used)]
468mod tests {
469    use super::*;
470    use crate::git::commit::ScopeCount;
471
472    // ── usage report ─────────────────────────────────────────────────
473
474    fn make_report() -> ScopeUsageReport {
475        ScopeUsageReport {
476            total_commits: 5,
477            declared: vec![
478                ScopeCount {
479                    name: "cli".to_string(),
480                    count: 3,
481                },
482                ScopeCount {
483                    name: "lib".to_string(),
484                    count: 1,
485                },
486            ],
487            unknown: vec![ScopeCount {
488                name: "lib".to_string(),
489                count: 1,
490            }],
491            unused: vec!["workflows".to_string()],
492            scope_less_count: 1,
493        }
494    }
495
496    #[test]
497    fn text_report_includes_all_sections() {
498        let report = make_report();
499        let text = format_text_report(&report);
500        assert!(text.contains("5 commit(s) analyzed"));
501        assert!(text.contains("cli"));
502        assert!(text.contains("Unknown"));
503        assert!(text.contains("lib"));
504        assert!(text.contains("Unused"));
505        assert!(text.contains("workflows"));
506        assert!(text.contains("Scope-less commits: 1"));
507    }
508
509    #[test]
510    fn text_report_empty_omits_unknown_and_unused_sections() {
511        let report = ScopeUsageReport {
512            total_commits: 0,
513            declared: vec![],
514            unknown: vec![],
515            unused: vec![],
516            scope_less_count: 0,
517        };
518        let text = format_text_report(&report);
519        assert!(text.contains("(none)"));
520        assert!(!text.contains("Unknown"));
521        assert!(!text.contains("Unused"));
522        assert!(text.contains("Scope-less commits: 0"));
523    }
524
525    #[test]
526    fn json_output_round_trips() {
527        let report = make_report();
528        let json = serde_json::to_string_pretty(&report).unwrap();
529        let parsed: ScopeUsageReport = serde_json::from_str(&json).unwrap();
530        assert_eq!(parsed, report);
531    }
532
533    // ── lint ────────────────────────────────────────────────────────
534
535    fn scope(name: &str, patterns: &[&str]) -> ScopeDefinition {
536        ScopeDefinition {
537            name: name.to_string(),
538            description: String::new(),
539            examples: Vec::new(),
540            file_patterns: patterns.iter().map(|p| (*p).to_string()).collect(),
541        }
542    }
543
544    fn files(paths: &[&str]) -> Vec<String> {
545        paths.iter().map(|p| (*p).to_string()).collect()
546    }
547
548    // ── dead patterns ────────────────────────────────────────────────
549
550    #[test]
551    fn dead_pattern_reported_when_no_file_matches() {
552        let scopes = vec![scope("cli", &["src/cli/**"])];
553        let report = lint_scopes(
554            &files(&["src/git/commit.rs"]),
555            &scopes,
556            &[],
557            &["src".to_string()],
558            &[],
559            true,
560        );
561        assert_eq!(report.dead_patterns.len(), 1);
562        assert_eq!(report.dead_patterns[0].pattern, "src/cli/**");
563        assert_eq!(report.dead_patterns[0].scope, "cli");
564    }
565
566    #[test]
567    fn dead_pattern_not_reported_when_one_file_matches() {
568        let scopes = vec![scope("cli", &["src/cli/**"])];
569        let report = lint_scopes(
570            &files(&["src/cli/mod.rs"]),
571            &scopes,
572            &[],
573            &["src".to_string()],
574            &[],
575            true,
576        );
577        assert!(report.dead_patterns.is_empty());
578    }
579
580    #[test]
581    fn negative_pattern_never_reported_dead() {
582        // Neither the positive nor the negative pattern matches anything —
583        // only the positive one is a candidate for "dead"; the negative one
584        // must never be reported regardless of match outcome.
585        let scopes = vec![scope("cli", &["src/cli/**", "!src/cli/generated/**"])];
586        let report = lint_scopes(
587            &files(&["src/other.rs"]),
588            &scopes,
589            &[],
590            &["src".to_string()],
591            &[],
592            true,
593        );
594        assert_eq!(report.dead_patterns.len(), 1);
595        assert_eq!(report.dead_patterns[0].pattern, "src/cli/**");
596    }
597
598    // ── unscoped files ───────────────────────────────────────────────
599
600    #[test]
601    fn unscoped_file_reported_under_root() {
602        let scopes = vec![scope("cli", &["src/cli/**"])];
603        let report = lint_scopes(
604            &files(&["src/newmod/foo.rs"]),
605            &scopes,
606            &[],
607            &["src".to_string()],
608            &[],
609            true,
610        );
611        assert_eq!(report.unscoped_files, vec!["src/newmod/foo.rs".to_string()]);
612    }
613
614    #[test]
615    fn unscoped_file_cleared_by_covering_scope() {
616        let scopes = vec![
617            scope("cli", &["src/cli/**"]),
618            scope("newmod", &["src/newmod/**"]),
619        ];
620        let report = lint_scopes(
621            &files(&["src/newmod/foo.rs"]),
622            &scopes,
623            &[],
624            &["src".to_string()],
625            &[],
626            true,
627        );
628        assert!(report.unscoped_files.is_empty());
629    }
630
631    #[test]
632    fn facade_pattern_does_not_cover_sibling_file() {
633        // src/foo/** does not match src/foo.rs — pins the facade-gap
634        // semantics of the underlying resolve_scope/scope_matches_files.
635        let scopes = vec![scope("foo", &["src/foo/**"])];
636        let report = lint_scopes(
637            &files(&["src/foo.rs"]),
638            &scopes,
639            &[],
640            &["src".to_string()],
641            &[],
642            true,
643        );
644        assert_eq!(report.unscoped_files, vec!["src/foo.rs".to_string()]);
645    }
646
647    #[test]
648    fn allow_glob_suppresses_only_matching_paths() {
649        let scopes = vec![scope("cli", &["src/cli/**"])];
650        let report = lint_scopes(
651            &files(&["src/lib.rs", "src/newmod/foo.rs"]),
652            &scopes,
653            &[],
654            &["src".to_string()],
655            &["src/lib.rs".to_string()],
656            true,
657        );
658        assert_eq!(report.unscoped_files, vec!["src/newmod/foo.rs".to_string()]);
659    }
660
661    #[test]
662    fn empty_scope_set_reports_every_file_under_root() {
663        // A scope set with no patterns must fail naming every file, never
664        // pass — the vacuous-truth failure mode this lint exists to avoid.
665        let scopes = vec![scope("empty", &[])];
666        let report = lint_scopes(
667            &files(&["src/a.rs", "src/b/c.rs", "docs/outside.md"]),
668            &scopes,
669            &[],
670            &["src".to_string()],
671            &[],
672            true,
673        );
674        assert_eq!(
675            report.unscoped_files,
676            vec!["src/a.rs".to_string(), "src/b/c.rs".to_string()]
677        );
678        assert_eq!(report.files_checked, 2);
679    }
680
681    #[test]
682    fn allowed_path_also_covered_by_a_scope_is_not_reported() {
683        let scopes = vec![scope("foo", &["src/foo/**"])];
684        let report = lint_scopes(
685            &files(&["src/foo/a.rs"]),
686            &scopes,
687            &[],
688            &["src".to_string()],
689            &["src/foo/**".to_string()],
690            true,
691        );
692        assert!(report.unscoped_files.is_empty());
693        assert!(report.dead_patterns.is_empty());
694    }
695
696    #[test]
697    fn allow_does_not_make_a_scope_pattern_dead() {
698        // `allow` only ever excuses a path from needing a scope; the
699        // dead-pattern check still sees the full tracked-file set, so a
700        // pattern whose only match is an allowed file stays live.
701        let scopes = vec![scope("foo", &["src/foo.rs"])];
702        let report = lint_scopes(
703            &files(&["src/foo.rs"]),
704            &scopes,
705            &[],
706            &["src".to_string()],
707            &["src/foo.rs".to_string()],
708            true,
709        );
710        assert!(report.dead_patterns.is_empty());
711    }
712
713    #[test]
714    fn allow_never_hides_a_path_outside_its_globs() {
715        let report = lint_scopes(
716            &files(&["src/utils/a.rs", "src/utils2/a.rs", "src/newmod/a.rs"]),
717            &[scope("cli", &["src/cli/**"])],
718            &[],
719            &["src".to_string()],
720            &["src/utils/**".to_string()],
721            true,
722        );
723        assert_eq!(
724            report.unscoped_files,
725            vec!["src/newmod/a.rs".to_string(), "src/utils2/a.rs".to_string()]
726        );
727    }
728
729    #[test]
730    fn unscoped_remedy_names_all_three_fixes() {
731        assert!(UNSCOPED_REMEDY.contains("adding a scope"));
732        assert!(UNSCOPED_REMEDY.contains("file_patterns"));
733        assert!(UNSCOPED_REMEDY.contains("`allow:` with a justification"));
734    }
735
736    // ── --project-only regression (the one that matters most) ─────────
737
738    #[test]
739    fn project_only_true_reports_ecosystem_gap() {
740        let ecosystem = vec![scope("lib", &["src/lib.rs", "src/**"])];
741        let report = lint_scopes(
742            &files(&["src/newmod/foo.rs"]),
743            &[], // no project scopes at all
744            &ecosystem,
745            &["src".to_string()],
746            &[],
747            true, // project_only: ecosystem scopes excluded from coverage
748        );
749        assert_eq!(report.unscoped_files, vec!["src/newmod/foo.rs".to_string()]);
750    }
751
752    #[test]
753    fn project_only_false_uses_ecosystem_catchall() {
754        let ecosystem = vec![scope("lib", &["src/lib.rs", "src/**"])];
755        let report = lint_scopes(
756            &files(&["src/newmod/foo.rs"]),
757            &[],
758            &ecosystem,
759            &["src".to_string()],
760            &[],
761            false, // ecosystem scopes folded into coverage
762        );
763        assert!(report.unscoped_files.is_empty());
764    }
765
766    // ── file_in_roots ────────────────────────────────────────────────
767
768    #[test]
769    fn file_in_roots_rejects_sibling_prefix() {
770        assert!(!file_in_roots("srcfoo/bar.rs", &["src".to_string()]));
771    }
772
773    #[test]
774    fn file_in_roots_matches_root_itself_and_subpath() {
775        assert!(file_in_roots("src", &["src".to_string()]));
776        assert!(file_in_roots("src/main.rs", &["src".to_string()]));
777    }
778
779    #[test]
780    fn roots_filter_excludes_files_outside_root() {
781        let scopes = vec![scope("cli", &["src/cli/**"])];
782        let report = lint_scopes(
783            &files(&["docs/newmod.md"]),
784            &scopes,
785            &[],
786            &["src".to_string()],
787            &[],
788            true,
789        );
790        assert!(report.unscoped_files.is_empty());
791        assert_eq!(report.files_checked, 0);
792    }
793
794    #[test]
795    fn multiple_roots_both_checked() {
796        let scopes = vec![scope("cli", &["src/cli/**"])];
797        let report = lint_scopes(
798            &files(&["src/newmod/foo.rs", "editors/newmod/bar.ts"]),
799            &scopes,
800            &[],
801            &["src".to_string(), "editors".to_string()],
802            &[],
803            true,
804        );
805        assert_eq!(report.unscoped_files.len(), 2);
806    }
807
808    // ── report exit code ────────────────────────────────────────────
809
810    #[test]
811    fn report_exit_code_zero_when_clean() {
812        let scopes = vec![scope("cli", &["src/cli/**"])];
813        let report = lint_scopes(
814            &files(&["src/cli/mod.rs"]),
815            &scopes,
816            &[],
817            &["src".to_string()],
818            &[],
819            true,
820        );
821        assert_eq!(report.exit_code(), 0);
822    }
823
824    #[test]
825    fn report_exit_code_one_when_dead_pattern_only() {
826        let scopes = vec![scope("cli", &["src/cli/**"])];
827        let report = lint_scopes(&files(&[]), &scopes, &[], &["src".to_string()], &[], true);
828        assert_eq!(report.exit_code(), 1);
829    }
830
831    // ── CLI-level ────────────────────────────────────────────────────
832
833    #[test]
834    fn no_project_only_flag_parses() {
835        let cmd = LintCommand::try_parse_from(["lint", "--no-project-only"]).unwrap();
836        assert!(cmd.no_project_only);
837
838        let cmd = LintCommand::try_parse_from(["lint"]).unwrap();
839        assert!(!cmd.no_project_only);
840    }
841
842    #[test]
843    fn root_defaults_to_src() {
844        let cmd = LintCommand::try_parse_from(["lint"]).unwrap();
845        assert_eq!(cmd.root, vec!["src".to_string()]);
846    }
847
848    // ── render_report / render_text_report ──────────────────────────
849    //
850    // Exercised via direct calls rather than only end-to-end, because the
851    // "fully clean" render_text_report branches (both "No dead patterns"
852    // and "No unscoped files") are unreachable against this repo's own
853    // scopes.yaml: dead_patterns ignores `--root`/`--allow` entirely, and
854    // this repo currently carries one pre-existing dead pattern (scope
855    // "resources") that fixing is out of scope for #1475 — so no CLI
856    // invocation against the real tree can produce a clean report.
857
858    fn report(
859        project_only: bool,
860        dead_patterns: &[(&str, &str)],
861        unscoped_files: &[&str],
862    ) -> ScopesLintReport {
863        ScopesLintReport {
864            roots: vec!["src".to_string()],
865            project_only,
866            scopes_checked: 1,
867            files_checked: 1,
868            dead_patterns: dead_patterns
869                .iter()
870                .map(|(scope, pattern)| DeadPattern {
871                    scope: (*scope).to_string(),
872                    pattern: (*pattern).to_string(),
873                })
874                .collect(),
875            unscoped_files: unscoped_files.iter().map(|f| (*f).to_string()).collect(),
876        }
877    }
878
879    #[test]
880    fn render_report_dispatches_on_format() {
881        let clean = report(true, &[], &[]);
882        assert!(render_report(&clean, OutputFormat::Text).is_ok());
883        assert!(render_report(&clean, OutputFormat::Json).is_ok());
884        assert!(render_report(&clean, OutputFormat::Yaml).is_ok());
885    }
886
887    #[test]
888    fn render_text_report_project_only_with_violations_of_both_kinds() {
889        // project_only: true label, non-empty dead patterns (1 entry) and
890        // non-empty unscoped files (2 entries) loops, plural "violations".
891        let r = report(
892            true,
893            &[("cli", "src/cli/nonexistent/**")],
894            &["src/newmod/a.rs", "src/newmod/b.rs"],
895        );
896        assert!(render_text_report(&r).is_ok());
897    }
898
899    #[test]
900    fn render_text_report_ecosystem_label_with_single_violation() {
901        // project_only: false label, empty dead patterns checkmark, a
902        // single unscoped file (singular "violation").
903        let r = report(false, &[], &["src/newmod/a.rs"]);
904        assert!(render_text_report(&r).is_ok());
905    }
906
907    #[test]
908    fn render_text_report_fully_clean() {
909        // Both checkmark branches: "No dead patterns" and "No unscoped
910        // files" — unreachable end-to-end against this repo (see comment
911        // above), so only reachable via a synthetic report.
912        let r = report(true, &[], &[]);
913        assert!(render_text_report(&r).is_ok());
914    }
915
916    /// The `scopes` command dispatches to `lint`; a nonexistent repo path
917    /// makes the leaf command error before it ever reaches
918    /// `std::process::exit`, which exercises the dispatch path end-to-end
919    /// without needing a real repository (mirrors `coverage.rs`'s
920    /// `dispatches_to_diff`).
921    #[test]
922    fn dispatches_to_lint() {
923        let cmd = ScopesCommand {
924            command: ScopesSubcommands::Lint(LintCommand {
925                root: vec!["src".to_string()],
926                no_project_only: false,
927                allow: Vec::new(),
928                output: OutputFormat::Text,
929                context_dir: None,
930            }),
931            repo: crate::cli::repo_arg::RepoArg {
932                repo: Some("/nonexistent/repo/path".into()),
933            },
934        };
935        let result = cmd.execute();
936        assert!(result.is_err());
937    }
938
939    // ── run_scopes_lint ecosystem-merge regression (issue #1475) ──────
940
941    /// Creates an empty git-inited tempdir under `$CARGO_MANIFEST_DIR/tmp`,
942    /// mirroring `git::repository`'s `init_tmp_repo` test helper.
943    fn init_tmp_git_repo() -> tempfile::TempDir {
944        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
945        std::fs::create_dir_all(&tmp_root).unwrap();
946        let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
947        git2::Repository::init(temp_dir.path()).unwrap();
948        temp_dir
949    }
950
951    /// Stages every file in `dir` so `GitRepository::tracked_files` sees them.
952    fn git_add_all(dir: &Path) {
953        let status = std::process::Command::new("git")
954            .current_dir(dir)
955            .args(["add", "."])
956            .status()
957            .unwrap();
958        assert!(status.success());
959    }
960
961    /// Regression for the bug where `run_scopes_lint` seeded
962    /// `merge_ecosystem_scopes` with an empty vector instead of the
963    /// project's own scopes: a same-named ecosystem default (npm's `test`
964    /// = `["test/**", "tests/**", "**/*.test.js"]`) was added *alongside* a
965    /// narrower project-defined `test` scope instead of yielding to it,
966    /// silently widening coverage under `--no-project-only` beyond what
967    /// real `load_project_scopes` resolution grants —
968    /// `docs/plan/config-internals.md`'s documented "user-defined scopes
969    /// always win, matched by name" contract.
970    #[test]
971    fn no_project_only_does_not_duplicate_a_same_named_ecosystem_scope() {
972        let repo = init_tmp_git_repo();
973        let p = repo.path();
974        // package.json (not Cargo.toml) selects the npm ecosystem defaults,
975        // which — unlike Rust's `lib` = `src/**` — has no catch-all pattern
976        // that would mask the regression this test is pinning.
977        std::fs::write(p.join("package.json"), "{}").unwrap();
978        std::fs::create_dir_all(p.join("src/foo")).unwrap();
979        std::fs::write(p.join("src/foo/helper.test.js"), "// test helper").unwrap();
980        let context_dir = p.join(".omni-dev");
981        std::fs::create_dir_all(&context_dir).unwrap();
982        std::fs::write(
983            context_dir.join("scopes.yaml"),
984            r#"
985scopes:
986  - name: test
987    description: Narrow project test scope
988    examples: []
989    file_patterns:
990      - "tests/only/**"
991"#,
992        )
993        .unwrap();
994        git_add_all(p);
995
996        let cmd = LintCommand {
997            root: vec!["src".to_string()],
998            no_project_only: true, // --no-project-only: folds in ecosystem defaults
999            allow: Vec::new(),
1000            output: OutputFormat::Text,
1001            context_dir: Some(context_dir),
1002        };
1003        let report = run_scopes_lint(p, &cmd).expect("lint should run against the temp repo");
1004
1005        // npm's `test` = ["test/**", "tests/**", "**/*.test.js"] must be
1006        // skipped by name — the project already defines `test` narrowly —
1007        // so src/foo/helper.test.js (matched only by the ecosystem pattern)
1008        // stays unscoped, exactly as real `load_project_scopes` resolution
1009        // would leave it.
1010        assert_eq!(
1011            report.unscoped_files,
1012            vec!["src/foo/helper.test.js".to_string()]
1013        );
1014        // Only the genuinely-new ecosystem scopes (deps, config, build,
1015        // docs — 4 of npm's 5 defaults) are folded in alongside the
1016        // project's own `test`: 1 + 4 = 5, never a duplicate `test` entry.
1017        assert_eq!(report.scopes_checked, 5);
1018    }
1019}