1pub mod co_change;
2pub mod colocated_test;
3pub mod config;
4pub mod coverage;
5pub mod e2e;
6pub mod isolation;
7pub mod lint;
8pub mod mutation;
9pub mod packaging;
10pub mod patch_coverage;
11pub mod ts;
12pub mod violation;
13pub mod workflow;
14
15use std::path::{Path, PathBuf};
16
17use clap::{CommandFactory, Parser, Subcommand};
18
19#[derive(Parser, Debug)]
20#[command(
21 name = "testing-conventions",
22 version,
23 about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
24 long_about = None,
25)]
26pub struct Cli {
27 #[command(subcommand)]
28 command: Option<Command>,
29}
30
31#[derive(Subcommand, Debug)]
32enum Command {
33 Check,
35 Unit {
37 #[command(subcommand)]
38 rule: UnitRule,
39 },
40 Integration {
42 #[command(subcommand)]
43 rule: IntegrationRule,
44 },
45 Packaging {
47 path: PathBuf,
49 #[arg(long, value_enum)]
51 language: colocated_test::Language,
52 },
53 #[command(hide = true)]
58 Workflow {
59 path: PathBuf,
61 },
62 E2e {
64 #[command(subcommand)]
65 command: E2eCommand,
66 },
67}
68
69#[derive(Subcommand, Debug)]
71enum UnitRule {
72 ColocatedTest {
78 path: PathBuf,
80 #[arg(long, value_enum)]
82 language: colocated_test::Language,
83 #[arg(long)]
89 base: Option<String>,
90 #[arg(long, default_value = "testing-conventions.toml")]
93 config: PathBuf,
94 },
95 Coverage {
100 path: PathBuf,
102 #[arg(long, value_enum)]
104 language: colocated_test::Language,
105 #[arg(long)]
111 base: Option<String>,
112 #[arg(long, default_value = "testing-conventions.toml")]
117 config: PathBuf,
118 },
119 Lint {
121 path: PathBuf,
123 #[arg(long, value_enum)]
125 language: isolation::Language,
126 #[arg(long, default_value = "testing-conventions.toml")]
129 config: PathBuf,
130 },
131 Mutation {
137 path: PathBuf,
139 #[arg(long, value_enum)]
141 language: colocated_test::Language,
142 #[arg(long)]
146 base: Option<String>,
147 #[arg(long, default_value = "testing-conventions.toml")]
150 config: PathBuf,
151 #[arg(long = "ts-mutation-adapter", hide = true)]
155 ts_adapter: Option<PathBuf>,
156 },
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
163pub enum IntegrationLintLanguage {
164 #[value(name = "python")]
166 Python,
167 #[value(name = "typescript")]
169 TypeScript,
170 #[value(name = "rust")]
172 Rust,
173}
174
175#[derive(Subcommand, Debug)]
178enum IntegrationRule {
179 Lint {
181 path: PathBuf,
183 #[arg(long, value_enum)]
185 language: IntegrationLintLanguage,
186 #[arg(long, default_value = "testing-conventions.toml")]
189 config: PathBuf,
190 },
191}
192
193#[derive(Subcommand, Debug)]
196enum E2eCommand {
197 Attest {
199 command: String,
201 },
202 Verify,
204}
205
206pub fn run<I, T>(args: I) -> anyhow::Result<i32>
207where
208 I: IntoIterator<Item = T>,
209 T: Into<std::ffi::OsString> + Clone,
210{
211 let cli = Cli::try_parse_from(args)?;
212 match cli.command {
213 Some(Command::Check) | None => Ok(0),
217 Some(Command::Unit { rule }) => match rule {
218 UnitRule::ColocatedTest {
219 path,
220 language,
221 base,
222 config,
223 } => run_unit_colocated_test(&path, language, base.as_deref(), &config),
224 UnitRule::Coverage {
225 path,
226 language,
227 base,
228 config,
229 } => run_unit_coverage(&path, language, base.as_deref(), &config),
230 UnitRule::Lint {
231 path,
232 language,
233 config,
234 } => run_unit_lint(&path, language, &config),
235 UnitRule::Mutation {
236 path,
237 language,
238 base,
239 config,
240 ts_adapter,
241 } => run_unit_mutation(
242 &path,
243 language,
244 base.as_deref(),
245 &config,
246 ts_adapter.as_deref(),
247 ),
248 },
249 Some(Command::Integration { rule }) => match rule {
250 IntegrationRule::Lint {
251 path,
252 language,
253 config,
254 } => run_integration_lint(&path, language, &config),
255 },
256 Some(Command::Packaging { path, language }) => run_packaging(&path, language),
257 Some(Command::Workflow { path }) => run_workflow(&path),
258 Some(Command::E2e { command }) => match command {
259 E2eCommand::Attest { command } => run_e2e_attest(&command),
260 E2eCommand::Verify => run_e2e_verify(),
261 },
262 }
263}
264
265pub fn command() -> clap::Command {
269 Cli::command()
270}
271
272fn run_unit_colocated_test(
285 root: &Path,
286 language: colocated_test::Language,
287 base: Option<&str>,
288 config_path: &Path,
289) -> anyhow::Result<i32> {
290 if base.is_some() && language == colocated_test::Language::Rust {
293 anyhow::bail!(
294 "`unit colocated-test --base` supports `--language python` / `typescript`; Rust \
295 units are inline `#[cfg(test)]` in the same file, so a sibling test can't go stale"
296 );
297 }
298 let presence_clean = report_colocated_presence(root, language, config_path)?;
299 let co_change_clean = match base {
300 Some(base) => report_co_change(root, base, language, config_path)?,
301 None => true,
302 };
303 Ok(if presence_clean && co_change_clean {
304 0
305 } else {
306 1
307 })
308}
309
310fn report_colocated_presence(
316 root: &Path,
317 language: colocated_test::Language,
318 config_path: &Path,
319) -> anyhow::Result<bool> {
320 let exempt = colocated_test_exemptions(root, language, config_path)?;
321 let orphans = match language {
322 colocated_test::Language::Rust => colocated_test::missing_inline_tests(root, &exempt)?,
325 _ => colocated_test::missing_unit_tests(root, language, &exempt)?,
326 };
327 if orphans.is_empty() {
328 return Ok(true);
329 }
330 let (label, summary) = match language {
331 colocated_test::Language::Rust => (
332 "missing inline `#[cfg(test)]` tests",
333 "source file(s) with testable code but no inline `#[cfg(test)]` module \
334 (add an inline test module, or an `exempt` entry with a reason)",
335 ),
336 _ => (
337 "missing colocated unit test",
338 "source file(s) missing a colocated unit test \
339 (add a colocated test, or an `exempt` entry with a reason)",
340 ),
341 };
342 for orphan in &orphans {
343 eprintln!("{label}: {}", orphan.display());
344 }
345 eprintln!("error: {} {summary}", orphans.len());
346 Ok(false)
347}
348
349fn colocated_test_exemptions(
353 root: &Path,
354 language: colocated_test::Language,
355 config_path: &Path,
356) -> anyhow::Result<std::collections::BTreeSet<String>> {
357 if !config_path.exists() {
358 return Ok(std::collections::BTreeSet::new());
359 }
360 let config = config::load_config(config_path)?;
361 config::resolve_exempt(
362 root,
363 config.exemptions(language),
364 config::Rule::ColocatedTest,
365 )
366}
367
368fn report_co_change(
378 root: &Path,
379 base: &str,
380 language: colocated_test::Language,
381 config_path: &Path,
382) -> anyhow::Result<bool> {
383 let exempt = co_change_exemptions(root, language, config_path)?;
384 let stale = co_change::stale_sources(root, base, language, &exempt)?;
385 if stale.is_empty() {
386 return Ok(true);
387 }
388 for source in &stale {
389 eprintln!(
390 "source changed without its colocated test: {}",
391 source.display()
392 );
393 }
394 eprintln!(
395 "error: {} source file(s) changed without their colocated test co-changing \
396 (update the test, or add an `exempt` entry with a reason)",
397 stale.len()
398 );
399 Ok(false)
400}
401
402fn co_change_exemptions(
406 root: &Path,
407 language: colocated_test::Language,
408 config_path: &Path,
409) -> anyhow::Result<std::collections::BTreeSet<String>> {
410 if !config_path.exists() {
411 return Ok(std::collections::BTreeSet::new());
412 }
413 let config = config::load_config(config_path)?;
414 config::resolve_exempt(root, config.exemptions(language), config::Rule::CoChange)
415}
416
417fn split_scopes(
422 scopes: std::collections::BTreeMap<String, config::LineScope>,
423) -> (
424 Vec<String>,
425 std::collections::BTreeMap<String, std::collections::BTreeSet<u32>>,
426) {
427 let mut whole_file = Vec::new();
428 let mut line_scoped = std::collections::BTreeMap::new();
429 for (path, scope) in scopes {
430 match scope {
431 config::LineScope::WholeFile => whole_file.push(path),
432 config::LineScope::Lines(lines) => {
433 line_scoped.insert(path, lines);
434 }
435 }
436 }
437 (whole_file, line_scoped)
438}
439
440fn run_unit_coverage(
458 root: &Path,
459 language: colocated_test::Language,
460 base: Option<&str>,
461 config_path: &Path,
462) -> anyhow::Result<i32> {
463 let config = if config_path.exists() {
464 config::load_config(config_path)?
465 } else {
466 config::Config::default()
467 };
468 let outcome = match language {
469 colocated_test::Language::Python => {
470 let python = config.python.unwrap_or_default();
471 let coverage = python.coverage.unwrap_or_default();
472 let thresholds = coverage::Thresholds {
473 fail_under: coverage.fail_under,
474 branch: coverage.branch,
475 };
476 let (omit, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
477 root,
478 &python.exempt,
479 config::Rule::Coverage,
480 )?);
481 match base {
482 Some(base) => {
483 patch_coverage::measure(root, base, thresholds, &omit, &exempt_lines)?
484 }
485 None if exempt_lines.is_empty() => coverage::measure(root, thresholds, &omit)?,
486 None => {
487 patch_coverage::measure_line_exempt(root, thresholds, &omit, &exempt_lines)?
488 }
489 }
490 }
491 colocated_test::Language::TypeScript => {
492 let typescript = config.typescript.unwrap_or_default();
493 let coverage = typescript.coverage.unwrap_or_default();
494 let thresholds = coverage::TypeScriptThresholds {
495 lines: coverage.lines,
496 branches: coverage.branches,
497 functions: coverage.functions,
498 statements: coverage.statements,
499 };
500 let (exclude, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
501 root,
502 &typescript.exempt,
503 config::Rule::Coverage,
504 )?);
505 match base {
506 Some(base) => patch_coverage::measure_typescript(
507 root,
508 base,
509 thresholds,
510 &exclude,
511 &exempt_lines,
512 )?,
513 None if exempt_lines.is_empty() => {
514 coverage::measure_typescript(root, thresholds, &exclude)?
515 }
516 None => patch_coverage::measure_line_exempt_typescript(
517 root,
518 thresholds,
519 &exclude,
520 &exempt_lines,
521 )?,
522 }
523 }
524 colocated_test::Language::Rust => {
525 let rust = config.rust.unwrap_or_default();
526 let coverage = rust.coverage.unwrap_or_default();
531 let thresholds = coverage::RustThresholds {
532 regions: coverage.regions,
533 lines: coverage.lines,
534 };
535 let (ignore, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
536 root,
537 &rust.exempt,
538 config::Rule::Coverage,
539 )?);
540 match base {
541 Some(base) => {
542 patch_coverage::measure_rust(root, base, thresholds, &ignore, &exempt_lines)?
543 }
544 None if exempt_lines.is_empty() => {
545 coverage::measure_rust(root, thresholds, &ignore)?
546 }
547 None => patch_coverage::measure_line_exempt_rust(
548 root,
549 thresholds,
550 &ignore,
551 &exempt_lines,
552 )?,
553 }
554 }
555 };
556 match outcome {
557 coverage::Outcome::Pass => Ok(0),
558 coverage::Outcome::Fail(reason) => {
559 eprintln!("error: coverage check failed — {reason}");
560 Ok(1)
561 }
562 }
563}
564
565fn run_unit_mutation(
576 root: &Path,
577 language: colocated_test::Language,
578 base: Option<&str>,
579 config_path: &Path,
580 ts_adapter: Option<&Path>,
581) -> anyhow::Result<i32> {
582 let config = if config_path.exists() {
583 config::load_config(config_path)?
584 } else {
585 config::Config::default()
586 };
587 let survivors = match language {
588 colocated_test::Language::Rust => {
589 let rust = config.rust.unwrap_or_default();
590 let (exempt, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
591 root,
592 &rust.exempt,
593 config::Rule::Mutation,
594 )?);
595 mutation::measure_rust(root, &exempt, &exempt_lines, base)?
596 }
597 colocated_test::Language::TypeScript => {
598 let typescript = config.typescript.unwrap_or_default();
599 let (exempt, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
600 root,
601 &typescript.exempt,
602 config::Rule::Mutation,
603 )?);
604 let adapter = ts_adapter.ok_or_else(|| {
607 anyhow::anyhow!(
608 "the TypeScript mutation adapter path is required: pass \
609 `--ts-mutation-adapter <path>`. The npm `testing-conventions` CLI appends it \
610 automatically — run the rule through that CLI, not the raw binary."
611 )
612 })?;
613 mutation::measure_typescript(root, &exempt, &exempt_lines, base, adapter)?
614 }
615 colocated_test::Language::Python => {
616 let python = config.python.unwrap_or_default();
617 let (exempt, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
618 root,
619 &python.exempt,
620 config::Rule::Mutation,
621 )?);
622 mutation::measure_python(root, &exempt, &exempt_lines, base)?
623 }
624 };
625 if survivors.is_empty() {
626 println!("unit mutation: no surviving mutants — every mutation was caught");
627 return Ok(0);
628 }
629
630 eprintln!(
631 "error: {} unexplained surviving mutant(s) — kill each with an assertion, or lift an \
632 equivalent/defensive one with a reason-required `[[<language>.exempt]] rules = [\"mutation\"]`:",
633 survivors.len()
634 );
635 for survivor in &survivors {
636 eprintln!(
637 " {}:{}: {}",
638 survivor.file, survivor.line, survivor.description
639 );
640 }
641 Ok(1)
642}
643
644fn run_unit_lint(
649 root: &Path,
650 language: isolation::Language,
651 config_path: &Path,
652) -> anyhow::Result<i32> {
653 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
654 isolation::Language::Rust => (isolation::find_violations(root)?, |c| c.rust_exemptions()),
655 isolation::Language::TypeScript => (ts::find_unit_violations(root)?, |c| {
656 c.exemptions(colocated_test::Language::TypeScript)
657 }),
658 isolation::Language::Python => (lint::find_unit_isolation_violations(root)?, |c| {
659 c.exemptions(colocated_test::Language::Python)
660 }),
661 };
662 let violations = apply_waivers(raw, root, config_path, select)?;
663 if violations.is_empty() {
664 return Ok(0);
665 }
666 for v in &violations {
667 eprintln!(
668 "{}:{}: {} — {}",
669 v.file.display(),
670 v.line,
671 v.rule,
672 v.message
673 );
674 }
675 eprintln!("error: {} isolation violation(s)", violations.len());
676 Ok(1)
677}
678
679fn run_integration_lint(
683 root: &Path,
684 language: IntegrationLintLanguage,
685 config_path: &Path,
686) -> anyhow::Result<i32> {
687 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
688 IntegrationLintLanguage::Python => (lint::find_violations(root)?, |c| {
689 c.exemptions(colocated_test::Language::Python)
690 }),
691 IntegrationLintLanguage::TypeScript => (ts::find_integration_violations(root)?, |c| {
692 c.exemptions(colocated_test::Language::TypeScript)
693 }),
694 IntegrationLintLanguage::Rust => (isolation::find_integration_violations(root)?, |c| {
695 c.rust_exemptions()
696 }),
697 };
698 let violations = apply_waivers(raw, root, config_path, select)?;
699 if violations.is_empty() {
700 return Ok(0);
701 }
702 for v in &violations {
703 eprintln!(
704 "{}:{}: {} — {}",
705 v.file.display(),
706 v.line,
707 v.rule,
708 v.message
709 );
710 }
711 eprintln!("error: {} lint violation(s)", violations.len());
712 Ok(1)
713}
714
715type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
718
719fn apply_waivers(
726 violations: Vec<lint::Violation>,
727 root: &Path,
728 config_path: &Path,
729 exemptions: ExemptSelect,
730) -> anyhow::Result<Vec<lint::Violation>> {
731 use std::collections::hash_map::Entry;
732
733 if !config_path.exists() {
734 return Ok(violations);
735 }
736 let config = config::load_config(config_path)?;
737 let exempt = exemptions(&config);
738 let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
740 std::collections::HashMap::new();
741 let mut kept = Vec::new();
742 for violation in violations {
743 let waived = match config::Rule::from_id(violation.rule) {
744 Some(rule) => {
745 let exempt_paths = match resolved.entry(rule) {
746 Entry::Occupied(entry) => entry.into_mut(),
747 Entry::Vacant(entry) => {
748 entry.insert(config::resolve_exempt(root, exempt, rule)?)
749 }
750 };
751 violation
752 .file
753 .strip_prefix(root)
754 .ok()
755 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
756 .is_some_and(|rel| exempt_paths.contains(&rel))
757 }
758 None => false,
759 };
760 if !waived {
761 kept.push(violation);
762 }
763 }
764 Ok(kept)
765}
766
767fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
776 let globs = match language {
777 colocated_test::Language::Python => vec!["*_test.py".to_string()],
778 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
779 colocated_test::Language::Rust => vec!["tests/".to_string()],
782 };
783 let offenders = packaging::inspect(artifact, &globs)?;
784 if offenders.is_empty() {
785 return Ok(0);
786 }
787 for offender in &offenders {
788 eprintln!("test file in built artifact: {}", offender.display());
789 }
790 eprintln!(
791 "error: {} test file(s) present in the built artifact \
792 (they must be excluded from packaging)",
793 offenders.len()
794 );
795 Ok(1)
796}
797
798fn run_workflow(path: &Path) -> anyhow::Result<i32> {
803 let violations = workflow::check(path, &command())?;
804 if violations.is_empty() {
805 return Ok(0);
806 }
807 for v in &violations {
808 eprintln!(
809 "{}:{}: {} — {}",
810 v.file.display(),
811 v.line,
812 v.rule,
813 v.message
814 );
815 }
816 eprintln!(
817 "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
818 violations.len()
819 );
820 Ok(1)
821}
822
823fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
827 let repo = std::env::current_dir()?;
828 let attestation = e2e::attest(&repo, command)?;
829 println!(
830 "e2e attestation recorded for commit {} (command exited {})",
831 attestation.commit, attestation.exit_code
832 );
833 Ok(0)
834}
835
836fn run_e2e_verify() -> anyhow::Result<i32> {
840 let repo = std::env::current_dir()?;
841 match e2e::verify(&repo)? {
842 e2e::Verification::Fresh => Ok(0),
843 e2e::Verification::Missing => {
844 eprintln!(
845 "e2e attestation missing — run `testing-conventions e2e attest '<your e2e command>'`"
846 );
847 Ok(1)
848 }
849 e2e::Verification::Stale { attested, latest } => {
850 eprintln!(
851 "e2e attestation out of date: attested {}, latest code commit {} — \
852 run `testing-conventions e2e attest '<your e2e command>'`",
853 &attested[..attested.len().min(7)],
854 &latest[..latest.len().min(7)]
855 );
856 Ok(1)
857 }
858 }
859}
860
861#[cfg(test)]
862mod tests {
863 use super::*;
864
865 #[test]
866 fn no_args_returns_ok_zero() {
867 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
868 }
869
870 #[test]
871 fn check_returns_ok_zero() {
872 assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
873 }
874
875 #[test]
876 fn unknown_flag_errors() {
877 assert!(run(["testing-conventions", "--bogus"]).is_err());
878 }
879
880 #[test]
881 fn help_flag_returns_clap_display_help() {
882 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
883 let clap_err = err
884 .downcast_ref::<clap::Error>()
885 .expect("error should be a clap::Error");
886 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
887 }
888
889 #[test]
890 fn version_flag_returns_clap_display_version() {
891 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
892 let clap_err = err
893 .downcast_ref::<clap::Error>()
894 .expect("error should be a clap::Error");
895 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
896 }
897}