1use 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#[derive(Parser)]
24pub struct ScopesCommand {
25 #[command(subcommand)]
27 pub command: ScopesSubcommands,
28
29 #[command(flatten)]
31 pub repo: crate::cli::repo_arg::RepoArg,
32}
33
34#[derive(Subcommand)]
36pub enum ScopesSubcommands {
37 Usage(UsageCommand),
40 Lint(LintCommand),
44}
45
46impl ScopesCommand {
47 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#[derive(Parser)]
60pub struct UsageCommand {
61 #[arg(value_name = "COMMIT_RANGE", conflicts_with = "max_count")]
64 pub commit_range: Option<String>,
65
66 #[arg(short = 'n', long = "max-count", value_name = "N")]
70 pub max_count: Option<usize>,
71
72 #[arg(long)]
74 pub context_dir: Option<PathBuf>,
75
76 #[arg(long)]
79 pub project_only: bool,
80
81 #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Text)]
83 pub output: OutputFormat,
84}
85
86impl UsageCommand {
87 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 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
148fn 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#[derive(Parser)]
193pub struct LintCommand {
194 #[arg(long, default_value = "src")]
196 pub root: Vec<String>,
197
198 #[arg(long)]
204 pub no_project_only: bool,
205
206 #[arg(long)]
209 pub allow: Vec<String>,
210
211 #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Text)]
213 pub output: OutputFormat,
214
215 #[arg(long)]
217 pub context_dir: Option<PathBuf>,
218}
219
220impl LintCommand {
221 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
235pub 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 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
284pub 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 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
344fn 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
367fn 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
379fn 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
400fn 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 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 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 #[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 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 #[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 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 #[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 &[], &ecosystem,
666 &["src".to_string()],
667 &[],
668 true, );
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, );
684 assert!(report.unscoped_files.is_empty());
685 }
686
687 #[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 #[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 #[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 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 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 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 let r = report(true, &[], &[]);
834 assert!(render_text_report(&r).is_ok());
835 }
836
837 #[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 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 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 #[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 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, 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 assert_eq!(
932 report.unscoped_files,
933 vec!["src/foo/helper.test.js".to_string()]
934 );
935 assert_eq!(report.scopes_checked, 5);
939 }
940}