1pub mod agents;
2pub mod changelog;
3pub mod co_change;
4pub mod colocated_test;
5pub mod config;
6pub mod coverage;
7pub mod e2e;
8pub mod entrypoint;
9mod git_diff;
10mod git_path;
11mod integration_lint;
12pub mod isolation;
13pub mod lint;
14pub mod mutation;
15pub mod one_function;
16pub mod packaging;
17pub mod patch_coverage;
18mod rust_patch_coverage;
19mod subcommand_walk;
20mod subprocess_seam;
21pub mod tiers;
22pub mod ts;
23mod unit_coverage;
24mod unit_lint;
25mod unit_mutation;
26mod unit_one_function;
27pub mod violation;
28mod waivers;
29mod walk;
30pub mod workflow;
31pub mod workflow_lint;
32
33use std::path::{Path, PathBuf};
34
35use clap::{CommandFactory, Parser, Subcommand};
36
37#[derive(Parser, Debug)]
38#[command(
39 name = "testing-conventions",
40 version,
41 about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
42 long_about = None,
43)]
44pub struct Cli {
45 #[command(subcommand)]
46 command: Option<Command>,
47}
48
49#[derive(Subcommand, Debug)]
50enum Command {
51 Install {
56 #[arg(default_value = "AGENTS.md")]
58 path: PathBuf,
59 },
60 Unit {
62 #[command(subcommand)]
63 rule: UnitRule,
64 },
65 Integration {
67 #[command(subcommand)]
68 rule: IntegrationRule,
69 },
70 Packaging {
72 path: PathBuf,
75 #[arg(long, value_enum)]
78 language: Option<colocated_test::Language>,
79 },
80 #[command(hide = true)]
85 Workflow {
86 path: PathBuf,
88 },
89 E2e {
91 #[command(subcommand)]
92 command: E2eCommand,
93 },
94 WorkflowLint {
98 #[arg(default_value = ".github")]
100 path: PathBuf,
101 },
102 Changelog {
105 #[arg(long)]
107 base: String,
108 #[arg(default_value = ".")]
110 path: PathBuf,
111 },
112}
113
114#[derive(Subcommand, Debug)]
115enum UnitRule {
116 ColocatedTest {
122 path: PathBuf,
124 #[arg(long, value_enum)]
126 language: colocated_test::Language,
127 #[arg(long)]
133 base: Option<String>,
134 #[arg(long, default_value = "testing-conventions.toml")]
137 config: PathBuf,
138 },
139 Coverage {
144 path: PathBuf,
146 #[arg(long, value_enum)]
148 language: colocated_test::Language,
149 #[arg(long)]
155 base: Option<String>,
156 #[arg(long, default_value = "testing-conventions.toml")]
161 config: PathBuf,
162 },
163 OneFunctionPerFile {
167 path: PathBuf,
169 #[arg(long, value_enum)]
171 language: colocated_test::Language,
172 #[arg(long, default_value = "testing-conventions.toml")]
177 config: PathBuf,
178 },
179 Lint {
181 path: PathBuf,
183 #[arg(long, value_enum)]
185 language: isolation::Language,
186 #[arg(long, default_value = "testing-conventions.toml")]
189 config: PathBuf,
190 },
191 Mutation {
197 path: PathBuf,
199 #[arg(long, value_enum)]
201 language: colocated_test::Language,
202 #[arg(long)]
206 base: Option<String>,
207 #[arg(long, default_value = "testing-conventions.toml")]
210 config: PathBuf,
211 #[arg(long = "ts-mutation-adapter", hide = true)]
215 ts_adapter: Option<PathBuf>,
216 },
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
221pub enum IntegrationLintLanguage {
222 #[value(name = "python")]
224 Python,
225 #[value(name = "typescript")]
227 TypeScript,
228 #[value(name = "rust")]
230 Rust,
231}
232
233#[derive(Subcommand, Debug)]
234enum IntegrationRule {
235 Lint {
237 path: PathBuf,
239 #[arg(long, value_enum)]
241 language: IntegrationLintLanguage,
242 #[arg(long, default_value = "testing-conventions.toml")]
245 config: PathBuf,
246 },
247}
248
249#[derive(Subcommand, Debug)]
250enum E2eCommand {
251 Attest {
255 command: String,
257 },
258 Verify {
260 #[arg(default_value = ".")]
263 path: PathBuf,
264 #[arg(long)]
267 scope: Option<PathBuf>,
268 #[arg(long)]
275 base: Option<String>,
276 #[arg(long = "extra-scope")]
281 extra_scope: Vec<PathBuf>,
282 #[arg(long = "exclude")]
286 exclude: Vec<PathBuf>,
287 #[arg(long)]
290 branch: Option<String>,
291 },
292 Slug {
295 branch: Option<String>,
297 },
298}
299
300pub fn run<I, T>(args: I) -> anyhow::Result<i32>
301where
302 I: IntoIterator<Item = T>,
303 T: Into<std::ffi::OsString> + Clone,
304{
305 eprintln!("testing-conventions {}", env!("CARGO_PKG_VERSION"));
308 let cli = Cli::try_parse_from(args)?;
309 match cli.command {
310 None => Ok(0),
311 Some(Command::Unit { rule }) => match rule {
312 UnitRule::ColocatedTest {
313 path,
314 language,
315 base,
316 config,
317 } => run_unit_colocated_test(&path, language, base.as_deref(), &config),
318 UnitRule::Coverage {
319 path,
320 language,
321 base,
322 config,
323 } => unit_coverage::run(&path, language, base.as_deref(), &config),
324 UnitRule::OneFunctionPerFile {
325 path,
326 language,
327 config,
328 } => unit_one_function::run(&path, language, &config),
329 UnitRule::Lint {
330 path,
331 language,
332 config,
333 } => unit_lint::run(&path, language, &config),
334 UnitRule::Mutation {
335 path,
336 language,
337 base,
338 config,
339 ts_adapter,
340 } => unit_mutation::run(
341 &path,
342 language,
343 base.as_deref(),
344 &config,
345 ts_adapter.as_deref(),
346 ),
347 },
348 Some(Command::Integration { rule }) => match rule {
349 IntegrationRule::Lint {
350 path,
351 language,
352 config,
353 } => integration_lint::run(&path, language, &config),
354 },
355 Some(Command::Packaging { path, language }) => run_packaging(&path, language),
356 Some(Command::Changelog { base, path }) => changelog::run(&path, &base),
357 Some(Command::Workflow { path }) => run_workflow(&path),
358 Some(Command::WorkflowLint { path }) => run_workflow_lint(&path),
359 Some(Command::E2e { command }) => match command {
360 E2eCommand::Attest { command } => run_e2e_attest(&command),
361 E2eCommand::Verify {
362 path,
363 scope,
364 base,
365 extra_scope,
366 exclude,
367 branch,
368 } => run_e2e_verify(
369 &path,
370 scope.as_deref(),
371 base.as_deref(),
372 &extra_scope,
373 &exclude,
374 branch.as_deref(),
375 ),
376 E2eCommand::Slug { branch } => run_e2e_slug(branch.as_deref()),
377 },
378 Some(Command::Install { path }) => {
379 agents::install(&path)?;
380 Ok(0)
381 }
382 }
383}
384
385pub fn command() -> clap::Command {
387 Cli::command()
388}
389
390fn run_unit_colocated_test(
393 root: &Path,
394 language: colocated_test::Language,
395 base: Option<&str>,
396 config_path: &Path,
397) -> anyhow::Result<i32> {
398 if base.is_some() && language == colocated_test::Language::Rust {
399 anyhow::bail!(
400 "`unit colocated-test --base` supports `--language python` / `typescript`; Rust \
401 units are inline `#[cfg(test)]` in the same file, so a sibling test can't go stale"
402 );
403 }
404 let presence_clean = report_colocated_presence(root, language, config_path)?;
405 let co_change_clean = match base {
406 Some(base) => report_co_change(root, base, language, config_path)?,
407 None => true,
408 };
409 Ok(if presence_clean && co_change_clean {
410 0
411 } else {
412 1
413 })
414}
415
416fn report_colocated_presence(
419 root: &Path,
420 language: colocated_test::Language,
421 config_path: &Path,
422) -> anyhow::Result<bool> {
423 let exempt = colocated_test_exemptions(root, language, config_path)?;
424 let orphans = match language {
425 colocated_test::Language::Rust => colocated_test::missing_inline_tests(root, &exempt)?,
426 _ => colocated_test::missing_unit_tests(root, language, &exempt)?,
427 };
428 if orphans.is_empty() {
429 return Ok(true);
430 }
431 let (label, summary) = orphan_wording(language);
432 for orphan in &orphans {
433 eprintln!("{label}: {}", orphan.display());
434 }
435 eprintln!("error: {} {summary}", orphans.len());
436 Ok(false)
437}
438
439fn orphan_wording(language: colocated_test::Language) -> (&'static str, &'static str) {
443 match language {
444 colocated_test::Language::Rust => (
445 "missing inline `#[cfg(test)]` tests",
446 "source file(s) with testable code but no inline `#[cfg(test)]` module \
447 (add an inline test module, or an `exempt` entry with a reason)",
448 ),
449 _ => (
450 "missing colocated unit test",
451 "source file(s) missing a colocated unit test \
452 (add a colocated test, or an `exempt` entry with a reason)",
453 ),
454 }
455}
456
457fn colocated_test_exemptions(
459 root: &Path,
460 language: colocated_test::Language,
461 config_path: &Path,
462) -> anyhow::Result<std::collections::BTreeSet<String>> {
463 if !config_path.exists() {
464 return Ok(std::collections::BTreeSet::new());
465 }
466 let config = config::load_config(config_path)?;
467 config::resolve_exempt(
468 root,
469 config.exemptions(language),
470 config::Rule::ColocatedTest,
471 )
472}
473
474fn report_co_change(
477 root: &Path,
478 base: &str,
479 language: colocated_test::Language,
480 config_path: &Path,
481) -> anyhow::Result<bool> {
482 let exempt = co_change_exemptions(root, language, config_path)?;
483 let stale = co_change::stale_sources(root, base, language, &exempt)?;
484 if stale.is_empty() {
485 return Ok(true);
486 }
487 for source in &stale {
488 eprintln!(
489 "source changed without its colocated test: {}",
490 source.display()
491 );
492 }
493 eprintln!(
494 "error: {} source file(s) changed without their colocated test co-changing \
495 (update the test, or add an `exempt` entry with a reason)",
496 stale.len()
497 );
498 Ok(false)
499}
500
501fn co_change_exemptions(
503 root: &Path,
504 language: colocated_test::Language,
505 config_path: &Path,
506) -> anyhow::Result<std::collections::BTreeSet<String>> {
507 if !config_path.exists() {
508 return Ok(std::collections::BTreeSet::new());
509 }
510 let config = config::load_config(config_path)?;
511 config::resolve_exempt(root, config.exemptions(language), config::Rule::CoChange)
512}
513
514fn split_scopes(
516 scopes: std::collections::BTreeMap<String, config::LineScope>,
517) -> (
518 Vec<String>,
519 std::collections::BTreeMap<String, std::collections::BTreeSet<u32>>,
520) {
521 let mut whole_file = Vec::new();
522 let mut line_scoped = std::collections::BTreeMap::new();
523 for (path, scope) in scopes {
524 match scope {
525 config::LineScope::WholeFile => whole_file.push(path),
526 config::LineScope::Lines(lines) => {
527 line_scoped.insert(path, lines);
528 }
529 }
530 }
531 (whole_file, line_scoped)
532}
533
534fn run_packaging(path: &Path, language: Option<colocated_test::Language>) -> anyhow::Result<i32> {
538 let distributions = distributions_at(path, language)?;
539 let mut shipped = 0;
540 for distribution in &distributions {
541 shipped += report_shipped_test_files(distribution)?;
542 }
543 if shipped > 0 {
544 eprintln!(
545 "error: {shipped} test file(s) present in the built distribution(s) \
546 (they must be excluded from packaging)"
547 );
548 return Ok(1);
549 }
550 println!(
551 "checked {} built distribution(s); no test files shipped",
552 distributions.len()
553 );
554 Ok(0)
555}
556
557fn distributions_at(
561 path: &Path,
562 language: Option<colocated_test::Language>,
563) -> anyhow::Result<Vec<packaging::Distribution>> {
564 let distributions = match language {
565 Some(language) => vec![packaging::Distribution {
566 path: path.to_path_buf(),
567 language,
568 }],
569 None => packaging::discover(path)?,
570 };
571 if distributions.is_empty() {
572 anyhow::bail!(
573 "no recognized built distribution (`.whl`, `.tar.gz`, `.tgz`, `.crate`) at `{}`",
574 path.display()
575 );
576 }
577 Ok(distributions)
578}
579
580fn report_shipped_test_files(distribution: &packaging::Distribution) -> anyhow::Result<usize> {
582 let globs = match distribution.language {
583 colocated_test::Language::Python => vec!["*_test.py".to_string()],
584 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
585 colocated_test::Language::Rust => vec!["tests/".to_string()],
587 };
588 let offenders = packaging::inspect(&distribution.path, &globs)?;
589 for offender in &offenders {
590 eprintln!(
591 "test file in built artifact `{}`: {}",
592 distribution.path.display(),
593 offender.display()
594 );
595 }
596 Ok(offenders.len())
597}
598
599fn run_workflow(path: &Path) -> anyhow::Result<i32> {
602 let violations = workflow::check(path, &command())?;
603 if violations.is_empty() {
604 return Ok(0);
605 }
606 for v in &violations {
607 eprintln!(
608 "{}:{}: {} — {}",
609 v.file.display(),
610 v.line,
611 v.rule,
612 v.message
613 );
614 }
615 eprintln!(
616 "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
617 violations.len()
618 );
619 Ok(1)
620}
621
622fn run_workflow_lint(path: &Path) -> anyhow::Result<i32> {
623 let findings = workflow_lint::scan(path)?;
624 if findings.is_empty() {
625 return Ok(0);
626 }
627 for f in &findings {
628 eprintln!(
629 "{}:{}: {} step `{}` encodes logic inline ({}) — move it into a tested package in \
630 this repository's own language, invoked as a one-line `run:`",
631 f.file.display(),
632 f.line,
633 f.kind,
634 f.step,
635 f.reasons.join("; ")
636 );
637 }
638 eprintln!(
639 "error: {} step(s) encode logic in CI YAML, where nothing tests it",
640 findings.len()
641 );
642 Ok(1)
643}
644
645fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
648 let repo = std::env::current_dir()?;
649 let attestation = e2e::attest(&repo, command)?;
650 if attestation.exit_code != 0 {
651 eprintln!(
652 "e2e command `{command}` exited {}; a receipt records a run that passed — \
653 fix the failure and attest again",
654 attestation.exit_code
655 );
656 return Ok(attestation.exit_code);
657 }
658 println!(
659 "e2e receipt recorded for branch {} at {}/{}.json",
660 attestation.branch,
661 e2e::RECEIPTS_DIR,
662 e2e::branch_slug(&attestation.branch),
663 );
664 Ok(0)
665}
666
667fn run_e2e_verify(
671 path: &Path,
672 scope: Option<&Path>,
673 base: Option<&str>,
674 extra_scopes: &[PathBuf],
675 excludes: &[PathBuf],
676 branch: Option<&str>,
677) -> anyhow::Result<i32> {
678 match e2e::verify_extra_scoped(
679 path,
680 scope.unwrap_or(path),
681 base,
682 extra_scopes,
683 excludes,
684 branch,
685 )? {
686 e2e::Verification::Fresh => Ok(0),
687 e2e::Verification::Missing => {
688 eprintln!(
689 "no e2e receipt answers this change — run \
690 `testing-conventions e2e attest '<your e2e command>'`; the command is \
691 your judgment: the full suite, a targeted subset, or a no-op"
692 );
693 Ok(1)
694 }
695 }
696}
697
698fn run_e2e_slug(branch: Option<&str>) -> anyhow::Result<i32> {
700 let slug = match branch {
701 Some(name) => e2e::branch_slug(name),
702 None => {
703 let repo = std::env::current_dir()?;
704 e2e::branch_slug(&e2e::current_branch(&repo)?)
705 }
706 };
707 println!("{slug}");
708 Ok(0)
709}
710
711#[cfg(test)]
712mod tests {
713 use std::sync::atomic::{AtomicU64, Ordering};
714
715 use super::*;
716
717 fn scratch(slug: &str, files: &[(&str, &str)]) -> PathBuf {
719 static COUNTER: AtomicU64 = AtomicU64::new(0);
720 let root = std::env::temp_dir().join(format!(
721 "tc-lib-{}-{}-{}",
722 slug,
723 std::process::id(),
724 COUNTER.fetch_add(1, Ordering::Relaxed),
725 ));
726 let _ = std::fs::remove_dir_all(&root);
727 std::fs::create_dir_all(&root).unwrap();
728 for (rel, contents) in files {
729 let full = root.join(rel);
730 std::fs::create_dir_all(full.parent().unwrap()).unwrap();
731 std::fs::write(full, contents).unwrap();
732 }
733 root
734 }
735
736 #[test]
737 fn a_directory_holding_no_distribution_is_an_error_rather_than_a_clean_run() {
738 let root = scratch("packaging-empty", &[("README.md", "hi\n")]);
740 assert!(run_packaging(&root, None).is_err());
741 }
742
743 #[test]
744 fn a_rust_file_with_no_inline_tests_makes_the_presence_report_unclean() {
745 let root = scratch(
746 "presence-orphan",
747 &[("src/widget.rs", "pub fn f() -> u8 { 1 }\n")],
748 );
749 let config = root.join("testing-conventions.toml");
750 assert!(
751 !report_colocated_presence(&root, colocated_test::Language::Rust, &config).unwrap()
752 );
753 }
754
755 #[test]
756 fn a_rust_file_carrying_inline_tests_makes_the_presence_report_clean() {
757 let root = scratch(
758 "presence-clean",
759 &[(
760 "src/widget.rs",
761 "pub fn f() -> u8 { 1 }\n#[cfg(test)]\nmod tests { #[test] fn t() {} }\n",
762 )],
763 );
764 let config = root.join("testing-conventions.toml");
765 assert!(report_colocated_presence(&root, colocated_test::Language::Rust, &config).unwrap());
766 }
767
768 #[test]
769 fn rust_is_told_about_inline_test_modules_and_the_others_about_sibling_files() {
770 let (label, summary) = orphan_wording(colocated_test::Language::Rust);
771 assert!(label.contains("inline"), "got: {label}");
772 assert!(summary.contains("inline test module"), "got: {summary}");
773
774 let (label, summary) = orphan_wording(colocated_test::Language::Python);
775 assert!(label.contains("colocated unit test"), "got: {label}");
776 assert!(summary.contains("add a colocated test"), "got: {summary}");
777 }
778
779 #[test]
780 fn a_named_language_makes_the_path_itself_the_distribution() {
781 let found = distributions_at(
782 Path::new("/tmp/pkg.whl"),
783 Some(colocated_test::Language::Python),
784 )
785 .unwrap();
786 assert_eq!(found.len(), 1);
787 assert_eq!(found[0].path, Path::new("/tmp/pkg.whl"));
788 }
789
790 #[test]
791 fn a_search_that_finds_nothing_is_an_error_not_a_clean_run() {
792 let dir = std::env::temp_dir().join(format!(
793 "testing-conventions-no-distributions-{}",
794 std::process::id()
795 ));
796 std::fs::create_dir_all(&dir).unwrap();
797 let err = distributions_at(&dir, None).unwrap_err();
798 assert!(
799 err.to_string().contains("no recognized built distribution"),
800 "got: {err}"
801 );
802 }
803
804 #[test]
805 fn no_args_returns_ok_zero() {
806 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
807 }
808
809 #[test]
810 fn unknown_flag_errors() {
811 assert!(run(["testing-conventions", "--bogus"]).is_err());
812 }
813
814 #[test]
815 fn split_scopes_separates_whole_file_paths_from_line_sets() {
816 let mut scopes = std::collections::BTreeMap::new();
817 scopes.insert("shim.py".to_string(), config::LineScope::WholeFile);
818 scopes.insert(
819 "widget.py".to_string(),
820 config::LineScope::Lines(std::collections::BTreeSet::from([3])),
821 );
822 let (whole_file, line_scoped) = split_scopes(scopes);
823 assert_eq!(whole_file, vec!["shim.py".to_string()]);
824 assert_eq!(line_scoped.len(), 1);
825 assert_eq!(
826 line_scoped["widget.py"],
827 std::collections::BTreeSet::from([3])
828 );
829 }
830
831 #[test]
832 fn help_flag_returns_clap_display_help() {
833 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
834 let clap_err = err
835 .downcast_ref::<clap::Error>()
836 .expect("error should be a clap::Error");
837 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
838 }
839
840 #[test]
841 fn version_flag_returns_clap_display_version() {
842 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
843 let clap_err = err
844 .downcast_ref::<clap::Error>()
845 .expect("error should be a clap::Error");
846 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
847 }
848}