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
30#[derive(Subcommand)]
32pub enum ScopesSubcommands {
33 Usage(UsageCommand),
36 Lint(LintCommand),
40}
41
42impl ScopesCommand {
43 pub fn execute(self, repo: Option<&Path>) -> Result<()> {
45 match self.command {
46 ScopesSubcommands::Usage(usage_cmd) => usage_cmd.execute(repo),
47 ScopesSubcommands::Lint(cmd) => cmd.execute(repo),
48 }
49 }
50}
51
52#[derive(Parser)]
54pub struct UsageCommand {
55 #[arg(value_name = "COMMIT_RANGE", conflicts_with = "max_count")]
58 pub commit_range: Option<String>,
59
60 #[arg(short = 'n', long = "max-count", value_name = "N")]
64 pub max_count: Option<usize>,
65
66 #[arg(long)]
68 pub context_dir: Option<PathBuf>,
69
70 #[arg(long)]
73 pub project_only: bool,
74
75 #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Text)]
77 pub output: OutputFormat,
78}
79
80impl UsageCommand {
81 pub fn execute(self, repo: Option<&Path>) -> Result<()> {
86 let repo_root = match repo {
87 Some(p) => p.to_path_buf(),
88 None => std::env::current_dir().context("Failed to determine current directory")?,
89 };
90 let repo_root = repo_root.as_path();
91
92 let git_repo = crate::git::GitRepository::open_at(repo_root)
93 .context("Failed to open git repository at the given path")?;
94
95 let commits = match &self.commit_range {
96 Some(range) => git_repo.get_commits_in_range(range)?,
97 None => git_repo.get_commits_from_head(self.max_count)?,
98 };
99
100 let subjects: Vec<&str> = commits
101 .iter()
102 .map(|c| c.original_message.lines().next().unwrap_or("").trim())
103 .collect();
104
105 let context_dir =
106 crate::claude::context::resolve_context_dir_at(self.context_dir.as_deref(), repo_root);
107 let scopes_yaml_only = crate::claude::context::load_project_scopes_only(&context_dir);
108 let known_scopes = if self.project_only {
109 scopes_yaml_only.clone()
110 } else {
111 crate::claude::context::load_project_scopes(&context_dir, repo_root)
112 };
113
114 let report = tally_scope_usage(&subjects, &known_scopes, &scopes_yaml_only);
115
116 self.output_report(&report)
117 }
118
119 fn output_report(&self, report: &ScopeUsageReport) -> Result<()> {
121 match self.output {
122 OutputFormat::Text => {
123 print!("{}", format_text_report(report));
124 Ok(())
125 }
126 OutputFormat::Json => {
127 let json = serde_json::to_string_pretty(report)
128 .context("Failed to serialize report to JSON")?;
129 println!("{json}");
130 Ok(())
131 }
132 OutputFormat::Yaml => {
133 let yaml =
134 crate::data::to_yaml(report).context("Failed to serialize report to YAML")?;
135 println!("{yaml}");
136 Ok(())
137 }
138 }
139 }
140}
141
142fn format_text_report(report: &ScopeUsageReport) -> String {
144 use std::fmt::Write;
145
146 let mut out = String::new();
147 let _ = writeln!(
148 out,
149 "📊 Scope usage: {} commit(s) analyzed",
150 report.total_commits
151 );
152 out.push('\n');
153
154 if report.declared.is_empty() {
155 out.push_str("Declared scopes: (none)\n");
156 } else {
157 out.push_str("Declared scopes:\n");
158 for sc in &report.declared {
159 let _ = writeln!(out, " {:<20} {}", sc.name, sc.count);
160 }
161 }
162 out.push('\n');
163
164 if !report.unknown.is_empty() {
165 out.push_str("⚠️ Unknown (declared, not in scopes.yaml):\n");
166 for sc in &report.unknown {
167 let _ = writeln!(out, " {:<20} {}", sc.name, sc.count);
168 }
169 out.push('\n');
170 }
171
172 if !report.unused.is_empty() {
173 out.push_str("💤 Unused (defined in scopes.yaml, never declared):\n");
174 for name in &report.unused {
175 let _ = writeln!(out, " {name}");
176 }
177 out.push('\n');
178 }
179
180 let _ = writeln!(out, "Scope-less commits: {}", report.scope_less_count);
181
182 out
183}
184
185#[derive(Parser)]
187pub struct LintCommand {
188 #[arg(long, default_value = "src")]
190 pub root: Vec<String>,
191
192 #[arg(long)]
198 pub no_project_only: bool,
199
200 #[arg(long)]
203 pub allow: Vec<String>,
204
205 #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Text)]
207 pub output: OutputFormat,
208
209 #[arg(long)]
211 pub context_dir: Option<PathBuf>,
212}
213
214impl LintCommand {
215 pub fn execute(self, repo: Option<&Path>) -> Result<()> {
217 let repo_root = match repo {
218 Some(p) => p.to_path_buf(),
219 None => std::env::current_dir().context("Failed to determine current directory")?,
220 };
221 let repo_root = repo_root.as_path();
222
223 let report = run_scopes_lint(repo_root, &self)?;
224 render_report(&report, self.output)?;
225 std::process::exit(report.exit_code());
226 }
227}
228
229pub fn run_scopes_lint(repo_root: &Path, cmd: &LintCommand) -> Result<ScopesLintReport> {
236 let repo = crate::git::GitRepository::open_at(repo_root)
237 .context("Failed to open git repository at the given path")?;
238 let tracked_files = repo.tracked_files()?;
239
240 let context_dir =
241 crate::claude::context::resolve_context_dir_at(cmd.context_dir.as_deref(), repo_root);
242 let scopes_file = crate::claude::context::load_scopes_file_strict(&context_dir)
243 .context("Failed to load .omni-dev/scopes.yaml")?;
244
245 let project_only = !cmd.no_project_only;
246 let ecosystem_scopes: Vec<ScopeDefinition> = if project_only {
255 Vec::new()
256 } else {
257 let mut combined = scopes_file.scopes.clone();
258 crate::claude::context::discovery::merge_ecosystem_scopes(&mut combined, repo_root);
259 combined
260 .into_iter()
261 .filter(|s| !scopes_file.scopes.iter().any(|p| p.name == s.name))
262 .collect()
263 };
264
265 let mut allow_globs = cmd.allow.clone();
266 allow_globs.extend(scopes_file.allow.iter().cloned());
267
268 Ok(lint_scopes(
269 &tracked_files,
270 &scopes_file.scopes,
271 &ecosystem_scopes,
272 &cmd.root,
273 &allow_globs,
274 project_only,
275 ))
276}
277
278pub fn lint_scopes(
296 tracked_files: &[String],
297 project_scopes: &[ScopeDefinition],
298 ecosystem_scopes: &[ScopeDefinition],
299 roots: &[String],
300 allow_globs: &[String],
301 project_only: bool,
302) -> ScopesLintReport {
303 let file_refs: Vec<&str> = tracked_files.iter().map(String::as_str).collect();
304
305 let mut dead_patterns = dead_patterns(&file_refs, project_scopes);
306 dead_patterns.sort_by(|a, b| (&a.scope, &a.pattern).cmp(&(&b.scope, &b.pattern)));
307
308 let mut effective_scopes: Vec<ScopeDefinition> = project_scopes.to_vec();
313 if !project_only {
314 effective_scopes.extend(ecosystem_scopes.iter().cloned());
315 }
316
317 let allow_matchers = build_allow_matchers(allow_globs);
318 let files_in_scope: Vec<&str> = file_refs
319 .iter()
320 .copied()
321 .filter(|f| file_in_roots(f, roots))
322 .filter(|f| !is_allowed(f, &allow_matchers))
323 .collect();
324
325 let mut unscoped_files = unscoped_files(&files_in_scope, &effective_scopes);
326 unscoped_files.sort();
327
328 ScopesLintReport {
329 roots: roots.to_vec(),
330 project_only,
331 scopes_checked: effective_scopes.len(),
332 files_checked: files_in_scope.len(),
333 dead_patterns,
334 unscoped_files,
335 }
336}
337
338fn dead_patterns(files: &[&str], scopes: &[ScopeDefinition]) -> Vec<DeadPattern> {
343 let mut dead = Vec::new();
344 for scope in scopes {
345 for pattern in &scope.file_patterns {
346 if pattern.starts_with('!') {
347 continue;
348 }
349 let single = std::slice::from_ref(pattern);
350 if crate::git::commit::scope_matches_files(files, single).is_none() {
351 dead.push(DeadPattern {
352 scope: scope.name.clone(),
353 pattern: pattern.clone(),
354 });
355 }
356 }
357 }
358 dead
359}
360
361fn unscoped_files(files: &[&str], scopes: &[ScopeDefinition]) -> Vec<String> {
366 files
367 .iter()
368 .filter(|f| crate::git::resolve_scope(std::slice::from_ref(*f), scopes).is_none())
369 .map(|f| (*f).to_string())
370 .collect()
371}
372
373fn file_in_roots(file: &str, roots: &[String]) -> bool {
378 roots
379 .iter()
380 .any(|root| file == root || file.starts_with(&format!("{root}/")))
381}
382
383fn build_allow_matchers(allow_globs: &[String]) -> Vec<GlobMatcher> {
384 allow_globs
385 .iter()
386 .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
387 .collect()
388}
389
390fn is_allowed(file: &str, matchers: &[GlobMatcher]) -> bool {
391 matchers.iter().any(|m| m.is_match(file))
392}
393
394fn render_report(report: &ScopesLintReport, format: OutputFormat) -> Result<()> {
397 match format {
398 OutputFormat::Text => render_text_report(report),
399 OutputFormat::Json => {
400 println!("{}", serde_json::to_string_pretty(report)?);
401 Ok(())
402 }
403 OutputFormat::Yaml => {
404 println!("{}", crate::data::to_yaml(report)?);
405 Ok(())
406 }
407 }
408}
409
410fn render_text_report(report: &ScopesLintReport) -> Result<()> {
411 let project_only_label = if report.project_only {
412 "project-only"
413 } else {
414 "project + ecosystem"
415 };
416 println!("🔍 Linting .omni-dev/scopes.yaml against the source tree...");
417 println!(
418 " 📂 Scopes checked: {} ({})",
419 report.scopes_checked, project_only_label
420 );
421 println!(" 📁 Roots: {}", report.roots.join(", "));
422 println!(" 📄 Files checked: {}", report.files_checked);
423 println!();
424
425 if report.dead_patterns.is_empty() {
426 println!("✅ No dead patterns.");
427 } else {
428 println!("❌ Dead patterns ({}):", report.dead_patterns.len());
429 for dead in &report.dead_patterns {
430 println!(" {} ({})", dead.pattern, dead.scope);
431 }
432 }
433
434 if report.unscoped_files.is_empty() {
435 println!("✅ No unscoped files.");
436 } else {
437 println!("❌ Unscoped files ({}):", report.unscoped_files.len());
438 for file in &report.unscoped_files {
439 println!(" {file}");
440 }
441 }
442
443 let total = report.dead_patterns.len() + report.unscoped_files.len();
444 println!();
445 println!(
446 "Summary: {} scopes, {} files checked — {} violation{}",
447 report.scopes_checked,
448 report.files_checked,
449 total,
450 if total == 1 { "" } else { "s" }
451 );
452
453 Ok(())
454}
455
456#[cfg(test)]
457#[allow(clippy::unwrap_used, clippy::expect_used)]
458mod tests {
459 use super::*;
460 use crate::git::commit::ScopeCount;
461
462 fn make_report() -> ScopeUsageReport {
465 ScopeUsageReport {
466 total_commits: 5,
467 declared: vec![
468 ScopeCount {
469 name: "cli".to_string(),
470 count: 3,
471 },
472 ScopeCount {
473 name: "lib".to_string(),
474 count: 1,
475 },
476 ],
477 unknown: vec![ScopeCount {
478 name: "lib".to_string(),
479 count: 1,
480 }],
481 unused: vec!["workflows".to_string()],
482 scope_less_count: 1,
483 }
484 }
485
486 #[test]
487 fn text_report_includes_all_sections() {
488 let report = make_report();
489 let text = format_text_report(&report);
490 assert!(text.contains("5 commit(s) analyzed"));
491 assert!(text.contains("cli"));
492 assert!(text.contains("Unknown"));
493 assert!(text.contains("lib"));
494 assert!(text.contains("Unused"));
495 assert!(text.contains("workflows"));
496 assert!(text.contains("Scope-less commits: 1"));
497 }
498
499 #[test]
500 fn text_report_empty_omits_unknown_and_unused_sections() {
501 let report = ScopeUsageReport {
502 total_commits: 0,
503 declared: vec![],
504 unknown: vec![],
505 unused: vec![],
506 scope_less_count: 0,
507 };
508 let text = format_text_report(&report);
509 assert!(text.contains("(none)"));
510 assert!(!text.contains("Unknown"));
511 assert!(!text.contains("Unused"));
512 assert!(text.contains("Scope-less commits: 0"));
513 }
514
515 #[test]
516 fn json_output_round_trips() {
517 let report = make_report();
518 let json = serde_json::to_string_pretty(&report).unwrap();
519 let parsed: ScopeUsageReport = serde_json::from_str(&json).unwrap();
520 assert_eq!(parsed, report);
521 }
522
523 fn scope(name: &str, patterns: &[&str]) -> ScopeDefinition {
526 ScopeDefinition {
527 name: name.to_string(),
528 description: String::new(),
529 examples: Vec::new(),
530 file_patterns: patterns.iter().map(|p| (*p).to_string()).collect(),
531 }
532 }
533
534 fn files(paths: &[&str]) -> Vec<String> {
535 paths.iter().map(|p| (*p).to_string()).collect()
536 }
537
538 #[test]
541 fn dead_pattern_reported_when_no_file_matches() {
542 let scopes = vec![scope("cli", &["src/cli/**"])];
543 let report = lint_scopes(
544 &files(&["src/git/commit.rs"]),
545 &scopes,
546 &[],
547 &["src".to_string()],
548 &[],
549 true,
550 );
551 assert_eq!(report.dead_patterns.len(), 1);
552 assert_eq!(report.dead_patterns[0].pattern, "src/cli/**");
553 assert_eq!(report.dead_patterns[0].scope, "cli");
554 }
555
556 #[test]
557 fn dead_pattern_not_reported_when_one_file_matches() {
558 let scopes = vec![scope("cli", &["src/cli/**"])];
559 let report = lint_scopes(
560 &files(&["src/cli/mod.rs"]),
561 &scopes,
562 &[],
563 &["src".to_string()],
564 &[],
565 true,
566 );
567 assert!(report.dead_patterns.is_empty());
568 }
569
570 #[test]
571 fn negative_pattern_never_reported_dead() {
572 let scopes = vec![scope("cli", &["src/cli/**", "!src/cli/generated/**"])];
576 let report = lint_scopes(
577 &files(&["src/other.rs"]),
578 &scopes,
579 &[],
580 &["src".to_string()],
581 &[],
582 true,
583 );
584 assert_eq!(report.dead_patterns.len(), 1);
585 assert_eq!(report.dead_patterns[0].pattern, "src/cli/**");
586 }
587
588 #[test]
591 fn unscoped_file_reported_under_root() {
592 let scopes = vec![scope("cli", &["src/cli/**"])];
593 let report = lint_scopes(
594 &files(&["src/newmod/foo.rs"]),
595 &scopes,
596 &[],
597 &["src".to_string()],
598 &[],
599 true,
600 );
601 assert_eq!(report.unscoped_files, vec!["src/newmod/foo.rs".to_string()]);
602 }
603
604 #[test]
605 fn unscoped_file_cleared_by_covering_scope() {
606 let scopes = vec![
607 scope("cli", &["src/cli/**"]),
608 scope("newmod", &["src/newmod/**"]),
609 ];
610 let report = lint_scopes(
611 &files(&["src/newmod/foo.rs"]),
612 &scopes,
613 &[],
614 &["src".to_string()],
615 &[],
616 true,
617 );
618 assert!(report.unscoped_files.is_empty());
619 }
620
621 #[test]
622 fn facade_pattern_does_not_cover_sibling_file() {
623 let scopes = vec![scope("foo", &["src/foo/**"])];
626 let report = lint_scopes(
627 &files(&["src/foo.rs"]),
628 &scopes,
629 &[],
630 &["src".to_string()],
631 &[],
632 true,
633 );
634 assert_eq!(report.unscoped_files, vec!["src/foo.rs".to_string()]);
635 }
636
637 #[test]
638 fn allow_glob_suppresses_only_matching_paths() {
639 let scopes = vec![scope("cli", &["src/cli/**"])];
640 let report = lint_scopes(
641 &files(&["src/lib.rs", "src/newmod/foo.rs"]),
642 &scopes,
643 &[],
644 &["src".to_string()],
645 &["src/lib.rs".to_string()],
646 true,
647 );
648 assert_eq!(report.unscoped_files, vec!["src/newmod/foo.rs".to_string()]);
649 }
650
651 #[test]
654 fn project_only_true_reports_ecosystem_gap() {
655 let ecosystem = vec![scope("lib", &["src/lib.rs", "src/**"])];
656 let report = lint_scopes(
657 &files(&["src/newmod/foo.rs"]),
658 &[], &ecosystem,
660 &["src".to_string()],
661 &[],
662 true, );
664 assert_eq!(report.unscoped_files, vec!["src/newmod/foo.rs".to_string()]);
665 }
666
667 #[test]
668 fn project_only_false_uses_ecosystem_catchall() {
669 let ecosystem = vec![scope("lib", &["src/lib.rs", "src/**"])];
670 let report = lint_scopes(
671 &files(&["src/newmod/foo.rs"]),
672 &[],
673 &ecosystem,
674 &["src".to_string()],
675 &[],
676 false, );
678 assert!(report.unscoped_files.is_empty());
679 }
680
681 #[test]
684 fn file_in_roots_rejects_sibling_prefix() {
685 assert!(!file_in_roots("srcfoo/bar.rs", &["src".to_string()]));
686 }
687
688 #[test]
689 fn file_in_roots_matches_root_itself_and_subpath() {
690 assert!(file_in_roots("src", &["src".to_string()]));
691 assert!(file_in_roots("src/main.rs", &["src".to_string()]));
692 }
693
694 #[test]
695 fn roots_filter_excludes_files_outside_root() {
696 let scopes = vec![scope("cli", &["src/cli/**"])];
697 let report = lint_scopes(
698 &files(&["docs/newmod.md"]),
699 &scopes,
700 &[],
701 &["src".to_string()],
702 &[],
703 true,
704 );
705 assert!(report.unscoped_files.is_empty());
706 assert_eq!(report.files_checked, 0);
707 }
708
709 #[test]
710 fn multiple_roots_both_checked() {
711 let scopes = vec![scope("cli", &["src/cli/**"])];
712 let report = lint_scopes(
713 &files(&["src/newmod/foo.rs", "editors/newmod/bar.ts"]),
714 &scopes,
715 &[],
716 &["src".to_string(), "editors".to_string()],
717 &[],
718 true,
719 );
720 assert_eq!(report.unscoped_files.len(), 2);
721 }
722
723 #[test]
726 fn report_exit_code_zero_when_clean() {
727 let scopes = vec![scope("cli", &["src/cli/**"])];
728 let report = lint_scopes(
729 &files(&["src/cli/mod.rs"]),
730 &scopes,
731 &[],
732 &["src".to_string()],
733 &[],
734 true,
735 );
736 assert_eq!(report.exit_code(), 0);
737 }
738
739 #[test]
740 fn report_exit_code_one_when_dead_pattern_only() {
741 let scopes = vec![scope("cli", &["src/cli/**"])];
742 let report = lint_scopes(&files(&[]), &scopes, &[], &["src".to_string()], &[], true);
743 assert_eq!(report.exit_code(), 1);
744 }
745
746 #[test]
749 fn no_project_only_flag_parses() {
750 let cmd = LintCommand::try_parse_from(["lint", "--no-project-only"]).unwrap();
751 assert!(cmd.no_project_only);
752
753 let cmd = LintCommand::try_parse_from(["lint"]).unwrap();
754 assert!(!cmd.no_project_only);
755 }
756
757 #[test]
758 fn root_defaults_to_src() {
759 let cmd = LintCommand::try_parse_from(["lint"]).unwrap();
760 assert_eq!(cmd.root, vec!["src".to_string()]);
761 }
762
763 fn report(
774 project_only: bool,
775 dead_patterns: &[(&str, &str)],
776 unscoped_files: &[&str],
777 ) -> ScopesLintReport {
778 ScopesLintReport {
779 roots: vec!["src".to_string()],
780 project_only,
781 scopes_checked: 1,
782 files_checked: 1,
783 dead_patterns: dead_patterns
784 .iter()
785 .map(|(scope, pattern)| DeadPattern {
786 scope: (*scope).to_string(),
787 pattern: (*pattern).to_string(),
788 })
789 .collect(),
790 unscoped_files: unscoped_files.iter().map(|f| (*f).to_string()).collect(),
791 }
792 }
793
794 #[test]
795 fn render_report_dispatches_on_format() {
796 let clean = report(true, &[], &[]);
797 assert!(render_report(&clean, OutputFormat::Text).is_ok());
798 assert!(render_report(&clean, OutputFormat::Json).is_ok());
799 assert!(render_report(&clean, OutputFormat::Yaml).is_ok());
800 }
801
802 #[test]
803 fn render_text_report_project_only_with_violations_of_both_kinds() {
804 let r = report(
807 true,
808 &[("cli", "src/cli/nonexistent/**")],
809 &["src/newmod/a.rs", "src/newmod/b.rs"],
810 );
811 assert!(render_text_report(&r).is_ok());
812 }
813
814 #[test]
815 fn render_text_report_ecosystem_label_with_single_violation() {
816 let r = report(false, &[], &["src/newmod/a.rs"]);
819 assert!(render_text_report(&r).is_ok());
820 }
821
822 #[test]
823 fn render_text_report_fully_clean() {
824 let r = report(true, &[], &[]);
828 assert!(render_text_report(&r).is_ok());
829 }
830
831 #[test]
837 fn dispatches_to_lint() {
838 let cmd = ScopesCommand {
839 command: ScopesSubcommands::Lint(LintCommand {
840 root: vec!["src".to_string()],
841 no_project_only: false,
842 allow: Vec::new(),
843 output: OutputFormat::Text,
844 context_dir: None,
845 }),
846 };
847 let result = cmd.execute(Some(Path::new("/nonexistent/repo/path")));
848 assert!(result.is_err());
849 }
850
851 fn init_tmp_git_repo() -> tempfile::TempDir {
856 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
857 std::fs::create_dir_all(&tmp_root).unwrap();
858 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
859 git2::Repository::init(temp_dir.path()).unwrap();
860 temp_dir
861 }
862
863 fn git_add_all(dir: &Path) {
865 let status = std::process::Command::new("git")
866 .current_dir(dir)
867 .args(["add", "."])
868 .status()
869 .unwrap();
870 assert!(status.success());
871 }
872
873 #[test]
883 fn no_project_only_does_not_duplicate_a_same_named_ecosystem_scope() {
884 let repo = init_tmp_git_repo();
885 let p = repo.path();
886 std::fs::write(p.join("package.json"), "{}").unwrap();
890 std::fs::create_dir_all(p.join("src/foo")).unwrap();
891 std::fs::write(p.join("src/foo/helper.test.js"), "// test helper").unwrap();
892 let context_dir = p.join(".omni-dev");
893 std::fs::create_dir_all(&context_dir).unwrap();
894 std::fs::write(
895 context_dir.join("scopes.yaml"),
896 r#"
897scopes:
898 - name: test
899 description: Narrow project test scope
900 examples: []
901 file_patterns:
902 - "tests/only/**"
903"#,
904 )
905 .unwrap();
906 git_add_all(p);
907
908 let cmd = LintCommand {
909 root: vec!["src".to_string()],
910 no_project_only: true, allow: Vec::new(),
912 output: OutputFormat::Text,
913 context_dir: Some(context_dir),
914 };
915 let report = run_scopes_lint(p, &cmd).expect("lint should run against the temp repo");
916
917 assert_eq!(
923 report.unscoped_files,
924 vec!["src/foo/helper.test.js".to_string()]
925 );
926 assert_eq!(report.scopes_checked, 5);
930 }
931}