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