1pub mod agents;
2pub mod co_change;
3pub mod colocated_test;
4pub mod config;
5pub mod coverage;
6pub mod e2e;
7pub mod isolation;
8pub mod lint;
9pub mod mutation;
10pub mod packaging;
11pub mod patch_coverage;
12pub mod ts;
13pub mod violation;
14pub mod workflow;
15
16use std::path::{Path, PathBuf};
17
18use clap::{CommandFactory, Parser, Subcommand};
19
20#[derive(Parser, Debug)]
21#[command(
22 name = "testing-conventions",
23 version,
24 about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
25 long_about = None,
26)]
27pub struct Cli {
28 #[command(subcommand)]
29 command: Option<Command>,
30}
31
32#[derive(Subcommand, Debug)]
33enum Command {
34 Check,
36 Install {
41 #[arg(default_value = "AGENTS.md")]
43 path: PathBuf,
44 },
45 Unit {
47 #[command(subcommand)]
48 rule: UnitRule,
49 },
50 Integration {
52 #[command(subcommand)]
53 rule: IntegrationRule,
54 },
55 Packaging {
57 path: PathBuf,
59 #[arg(long, value_enum)]
61 language: colocated_test::Language,
62 },
63 #[command(hide = true)]
68 Workflow {
69 path: PathBuf,
71 },
72 E2e {
74 #[command(subcommand)]
75 command: E2eCommand,
76 },
77}
78
79#[derive(Subcommand, Debug)]
81enum UnitRule {
82 ColocatedTest {
88 path: PathBuf,
90 #[arg(long, value_enum)]
92 language: colocated_test::Language,
93 #[arg(long)]
99 base: Option<String>,
100 #[arg(long, default_value = "testing-conventions.toml")]
103 config: PathBuf,
104 },
105 Coverage {
110 path: PathBuf,
112 #[arg(long, value_enum)]
114 language: colocated_test::Language,
115 #[arg(long)]
121 base: Option<String>,
122 #[arg(long, default_value = "testing-conventions.toml")]
127 config: PathBuf,
128 },
129 Lint {
131 path: PathBuf,
133 #[arg(long, value_enum)]
135 language: isolation::Language,
136 #[arg(long, default_value = "testing-conventions.toml")]
139 config: PathBuf,
140 },
141 Mutation {
147 path: PathBuf,
149 #[arg(long, value_enum)]
151 language: colocated_test::Language,
152 #[arg(long)]
156 base: Option<String>,
157 #[arg(long, default_value = "testing-conventions.toml")]
160 config: PathBuf,
161 #[arg(long = "ts-mutation-adapter", hide = true)]
165 ts_adapter: Option<PathBuf>,
166 },
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
173pub enum IntegrationLintLanguage {
174 #[value(name = "python")]
176 Python,
177 #[value(name = "typescript")]
179 TypeScript,
180 #[value(name = "rust")]
182 Rust,
183}
184
185#[derive(Subcommand, Debug)]
188enum IntegrationRule {
189 Lint {
191 path: PathBuf,
193 #[arg(long, value_enum)]
195 language: IntegrationLintLanguage,
196 #[arg(long, default_value = "testing-conventions.toml")]
199 config: PathBuf,
200 },
201}
202
203#[derive(Subcommand, Debug)]
206enum E2eCommand {
207 Attest {
209 command: String,
211 },
212 Verify,
214}
215
216pub fn run<I, T>(args: I) -> anyhow::Result<i32>
217where
218 I: IntoIterator<Item = T>,
219 T: Into<std::ffi::OsString> + Clone,
220{
221 let cli = Cli::try_parse_from(args)?;
222 match cli.command {
223 Some(Command::Check) | None => Ok(0),
227 Some(Command::Unit { rule }) => match rule {
228 UnitRule::ColocatedTest {
229 path,
230 language,
231 base,
232 config,
233 } => run_unit_colocated_test(&path, language, base.as_deref(), &config),
234 UnitRule::Coverage {
235 path,
236 language,
237 base,
238 config,
239 } => run_unit_coverage(&path, language, base.as_deref(), &config),
240 UnitRule::Lint {
241 path,
242 language,
243 config,
244 } => run_unit_lint(&path, language, &config),
245 UnitRule::Mutation {
246 path,
247 language,
248 base,
249 config,
250 ts_adapter,
251 } => run_unit_mutation(
252 &path,
253 language,
254 base.as_deref(),
255 &config,
256 ts_adapter.as_deref(),
257 ),
258 },
259 Some(Command::Integration { rule }) => match rule {
260 IntegrationRule::Lint {
261 path,
262 language,
263 config,
264 } => run_integration_lint(&path, language, &config),
265 },
266 Some(Command::Packaging { path, language }) => run_packaging(&path, language),
267 Some(Command::Workflow { path }) => run_workflow(&path),
268 Some(Command::E2e { command }) => match command {
269 E2eCommand::Attest { command } => run_e2e_attest(&command),
270 E2eCommand::Verify => run_e2e_verify(),
271 },
272 Some(Command::Install { path }) => {
273 agents::install(&path)?;
274 Ok(0)
275 }
276 }
277}
278
279pub fn command() -> clap::Command {
283 Cli::command()
284}
285
286fn run_unit_colocated_test(
299 root: &Path,
300 language: colocated_test::Language,
301 base: Option<&str>,
302 config_path: &Path,
303) -> anyhow::Result<i32> {
304 if base.is_some() && language == colocated_test::Language::Rust {
307 anyhow::bail!(
308 "`unit colocated-test --base` supports `--language python` / `typescript`; Rust \
309 units are inline `#[cfg(test)]` in the same file, so a sibling test can't go stale"
310 );
311 }
312 let presence_clean = report_colocated_presence(root, language, config_path)?;
313 let co_change_clean = match base {
314 Some(base) => report_co_change(root, base, language, config_path)?,
315 None => true,
316 };
317 Ok(if presence_clean && co_change_clean {
318 0
319 } else {
320 1
321 })
322}
323
324fn report_colocated_presence(
330 root: &Path,
331 language: colocated_test::Language,
332 config_path: &Path,
333) -> anyhow::Result<bool> {
334 let exempt = colocated_test_exemptions(root, language, config_path)?;
335 let orphans = match language {
336 colocated_test::Language::Rust => colocated_test::missing_inline_tests(root, &exempt)?,
339 _ => colocated_test::missing_unit_tests(root, language, &exempt)?,
340 };
341 if orphans.is_empty() {
342 return Ok(true);
343 }
344 let (label, summary) = match language {
345 colocated_test::Language::Rust => (
346 "missing inline `#[cfg(test)]` tests",
347 "source file(s) with testable code but no inline `#[cfg(test)]` module \
348 (add an inline test module, or an `exempt` entry with a reason)",
349 ),
350 _ => (
351 "missing colocated unit test",
352 "source file(s) missing a colocated unit test \
353 (add a colocated test, or an `exempt` entry with a reason)",
354 ),
355 };
356 for orphan in &orphans {
357 eprintln!("{label}: {}", orphan.display());
358 }
359 eprintln!("error: {} {summary}", orphans.len());
360 Ok(false)
361}
362
363fn colocated_test_exemptions(
367 root: &Path,
368 language: colocated_test::Language,
369 config_path: &Path,
370) -> anyhow::Result<std::collections::BTreeSet<String>> {
371 if !config_path.exists() {
372 return Ok(std::collections::BTreeSet::new());
373 }
374 let config = config::load_config(config_path)?;
375 config::resolve_exempt(
376 root,
377 config.exemptions(language),
378 config::Rule::ColocatedTest,
379 )
380}
381
382fn report_co_change(
392 root: &Path,
393 base: &str,
394 language: colocated_test::Language,
395 config_path: &Path,
396) -> anyhow::Result<bool> {
397 let exempt = co_change_exemptions(root, language, config_path)?;
398 let stale = co_change::stale_sources(root, base, language, &exempt)?;
399 if stale.is_empty() {
400 return Ok(true);
401 }
402 for source in &stale {
403 eprintln!(
404 "source changed without its colocated test: {}",
405 source.display()
406 );
407 }
408 eprintln!(
409 "error: {} source file(s) changed without their colocated test co-changing \
410 (update the test, or add an `exempt` entry with a reason)",
411 stale.len()
412 );
413 Ok(false)
414}
415
416fn co_change_exemptions(
420 root: &Path,
421 language: colocated_test::Language,
422 config_path: &Path,
423) -> anyhow::Result<std::collections::BTreeSet<String>> {
424 if !config_path.exists() {
425 return Ok(std::collections::BTreeSet::new());
426 }
427 let config = config::load_config(config_path)?;
428 config::resolve_exempt(root, config.exemptions(language), config::Rule::CoChange)
429}
430
431fn split_scopes(
436 scopes: std::collections::BTreeMap<String, config::LineScope>,
437) -> (
438 Vec<String>,
439 std::collections::BTreeMap<String, std::collections::BTreeSet<u32>>,
440) {
441 let mut whole_file = Vec::new();
442 let mut line_scoped = std::collections::BTreeMap::new();
443 for (path, scope) in scopes {
444 match scope {
445 config::LineScope::WholeFile => whole_file.push(path),
446 config::LineScope::Lines(lines) => {
447 line_scoped.insert(path, lines);
448 }
449 }
450 }
451 (whole_file, line_scoped)
452}
453
454fn run_unit_coverage(
472 root: &Path,
473 language: colocated_test::Language,
474 base: Option<&str>,
475 config_path: &Path,
476) -> anyhow::Result<i32> {
477 let config = if config_path.exists() {
478 config::load_config(config_path)?
479 } else {
480 config::Config::default()
481 };
482 let outcome = match language {
483 colocated_test::Language::Python => {
484 let python = config.python.unwrap_or_default();
485 let coverage = python.coverage.unwrap_or_default();
486 let thresholds = coverage::Thresholds {
487 fail_under: coverage.fail_under,
488 branch: coverage.branch,
489 };
490 let (omit, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
491 root,
492 &python.exempt,
493 config::Rule::Coverage,
494 )?);
495 match base {
496 Some(base) => {
497 patch_coverage::measure(root, base, thresholds, &omit, &exempt_lines)?
498 }
499 None if exempt_lines.is_empty() => coverage::measure(root, thresholds, &omit)?,
500 None => {
501 patch_coverage::measure_line_exempt(root, thresholds, &omit, &exempt_lines)?
502 }
503 }
504 }
505 colocated_test::Language::TypeScript => {
506 let typescript = config.typescript.unwrap_or_default();
507 let coverage = typescript.coverage.unwrap_or_default();
508 let thresholds = coverage::TypeScriptThresholds {
509 lines: coverage.lines,
510 branches: coverage.branches,
511 functions: coverage.functions,
512 statements: coverage.statements,
513 };
514 let (exclude, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
515 root,
516 &typescript.exempt,
517 config::Rule::Coverage,
518 )?);
519 match base {
520 Some(base) => patch_coverage::measure_typescript(
521 root,
522 base,
523 thresholds,
524 &exclude,
525 &exempt_lines,
526 )?,
527 None if exempt_lines.is_empty() => {
528 coverage::measure_typescript(root, thresholds, &exclude)?
529 }
530 None => patch_coverage::measure_line_exempt_typescript(
531 root,
532 thresholds,
533 &exclude,
534 &exempt_lines,
535 )?,
536 }
537 }
538 colocated_test::Language::Rust => {
539 let rust = config.rust.unwrap_or_default();
540 let coverage = rust.coverage.unwrap_or_default();
545 let thresholds = coverage::RustThresholds {
546 regions: coverage.regions,
547 lines: coverage.lines,
548 };
549 let (ignore, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
550 root,
551 &rust.exempt,
552 config::Rule::Coverage,
553 )?);
554 match base {
555 Some(base) => {
556 patch_coverage::measure_rust(root, base, thresholds, &ignore, &exempt_lines)?
557 }
558 None if exempt_lines.is_empty() => {
559 coverage::measure_rust(root, thresholds, &ignore)?
560 }
561 None => patch_coverage::measure_line_exempt_rust(
562 root,
563 thresholds,
564 &ignore,
565 &exempt_lines,
566 )?,
567 }
568 }
569 };
570 match outcome {
571 coverage::Outcome::Pass => Ok(0),
572 coverage::Outcome::Fail(reason) => {
573 eprintln!("error: coverage check failed — {reason}");
574 Ok(1)
575 }
576 }
577}
578
579fn run_unit_mutation(
590 root: &Path,
591 language: colocated_test::Language,
592 base: Option<&str>,
593 config_path: &Path,
594 ts_adapter: Option<&Path>,
595) -> anyhow::Result<i32> {
596 let config = if config_path.exists() {
597 config::load_config(config_path)?
598 } else {
599 config::Config::default()
600 };
601 let survivors = match language {
602 colocated_test::Language::Rust => {
603 let rust = config.rust.unwrap_or_default();
604 let (exempt, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
605 root,
606 &rust.exempt,
607 config::Rule::Mutation,
608 )?);
609 mutation::measure_rust(root, &exempt, &exempt_lines, base)?
610 }
611 colocated_test::Language::TypeScript => {
612 let typescript = config.typescript.unwrap_or_default();
613 let (exempt, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
614 root,
615 &typescript.exempt,
616 config::Rule::Mutation,
617 )?);
618 let adapter = ts_adapter.ok_or_else(|| {
621 anyhow::anyhow!(
622 "the TypeScript mutation adapter path is required: pass \
623 `--ts-mutation-adapter <path>`. The npm `testing-conventions` CLI appends it \
624 automatically — run the rule through that CLI, not the raw binary."
625 )
626 })?;
627 mutation::measure_typescript(root, &exempt, &exempt_lines, base, adapter)?
628 }
629 colocated_test::Language::Python => {
630 let python = config.python.unwrap_or_default();
631 let (exempt, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
632 root,
633 &python.exempt,
634 config::Rule::Mutation,
635 )?);
636 mutation::measure_python(root, &exempt, &exempt_lines, base)?
637 }
638 };
639 if survivors.is_empty() {
640 println!("unit mutation: no surviving mutants — every mutation was caught");
641 return Ok(0);
642 }
643
644 eprintln!(
645 "error: {} unexplained surviving mutant(s) — kill each with an assertion, or lift an \
646 equivalent/defensive one with a reason-required `[[<language>.exempt]] rules = [\"mutation\"]`:",
647 survivors.len()
648 );
649 for survivor in &survivors {
650 eprintln!(
651 " {}:{}: {}",
652 survivor.file, survivor.line, survivor.description
653 );
654 }
655 Ok(1)
656}
657
658fn run_unit_lint(
663 root: &Path,
664 language: isolation::Language,
665 config_path: &Path,
666) -> anyhow::Result<i32> {
667 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
668 isolation::Language::Rust => (isolation::find_violations(root)?, |c| c.rust_exemptions()),
669 isolation::Language::TypeScript => (ts::find_unit_violations(root)?, |c| {
670 c.exemptions(colocated_test::Language::TypeScript)
671 }),
672 isolation::Language::Python => (lint::find_unit_isolation_violations(root)?, |c| {
673 c.exemptions(colocated_test::Language::Python)
674 }),
675 };
676 let violations = apply_waivers(raw, root, config_path, select)?;
677 if violations.is_empty() {
678 return Ok(0);
679 }
680 for v in &violations {
681 eprintln!(
682 "{}:{}: {} — {}",
683 v.file.display(),
684 v.line,
685 v.rule,
686 v.message
687 );
688 }
689 eprintln!("error: {} isolation violation(s)", violations.len());
690 Ok(1)
691}
692
693fn run_integration_lint(
697 root: &Path,
698 language: IntegrationLintLanguage,
699 config_path: &Path,
700) -> anyhow::Result<i32> {
701 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
702 IntegrationLintLanguage::Python => (lint::find_violations(root)?, |c| {
703 c.exemptions(colocated_test::Language::Python)
704 }),
705 IntegrationLintLanguage::TypeScript => (ts::find_integration_violations(root)?, |c| {
706 c.exemptions(colocated_test::Language::TypeScript)
707 }),
708 IntegrationLintLanguage::Rust => (isolation::find_integration_violations(root)?, |c| {
709 c.rust_exemptions()
710 }),
711 };
712 let violations = apply_waivers(raw, root, config_path, select)?;
713 if violations.is_empty() {
714 return Ok(0);
715 }
716 for v in &violations {
717 eprintln!(
718 "{}:{}: {} — {}",
719 v.file.display(),
720 v.line,
721 v.rule,
722 v.message
723 );
724 }
725 eprintln!("error: {} lint violation(s)", violations.len());
726 Ok(1)
727}
728
729type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
732
733fn apply_waivers(
740 violations: Vec<lint::Violation>,
741 root: &Path,
742 config_path: &Path,
743 exemptions: ExemptSelect,
744) -> anyhow::Result<Vec<lint::Violation>> {
745 use std::collections::hash_map::Entry;
746
747 if !config_path.exists() {
748 return Ok(violations);
749 }
750 let config = config::load_config(config_path)?;
751 let exempt = exemptions(&config);
752 let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
754 std::collections::HashMap::new();
755 let mut kept = Vec::new();
756 for violation in violations {
757 let waived = match config::Rule::from_id(violation.rule) {
758 Some(rule) => {
759 let exempt_paths = match resolved.entry(rule) {
760 Entry::Occupied(entry) => entry.into_mut(),
761 Entry::Vacant(entry) => {
762 entry.insert(config::resolve_exempt(root, exempt, rule)?)
763 }
764 };
765 violation
766 .file
767 .strip_prefix(root)
768 .ok()
769 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
770 .is_some_and(|rel| exempt_paths.contains(&rel))
771 }
772 None => false,
773 };
774 if !waived {
775 kept.push(violation);
776 }
777 }
778 Ok(kept)
779}
780
781fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
790 let globs = match language {
791 colocated_test::Language::Python => vec!["*_test.py".to_string()],
792 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
793 colocated_test::Language::Rust => vec!["tests/".to_string()],
796 };
797 let offenders = packaging::inspect(artifact, &globs)?;
798 if offenders.is_empty() {
799 return Ok(0);
800 }
801 for offender in &offenders {
802 eprintln!("test file in built artifact: {}", offender.display());
803 }
804 eprintln!(
805 "error: {} test file(s) present in the built artifact \
806 (they must be excluded from packaging)",
807 offenders.len()
808 );
809 Ok(1)
810}
811
812fn run_workflow(path: &Path) -> anyhow::Result<i32> {
817 let violations = workflow::check(path, &command())?;
818 if violations.is_empty() {
819 return Ok(0);
820 }
821 for v in &violations {
822 eprintln!(
823 "{}:{}: {} — {}",
824 v.file.display(),
825 v.line,
826 v.rule,
827 v.message
828 );
829 }
830 eprintln!(
831 "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
832 violations.len()
833 );
834 Ok(1)
835}
836
837fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
841 let repo = std::env::current_dir()?;
842 let attestation = e2e::attest(&repo, command)?;
843 println!(
844 "e2e attestation recorded for commit {} (command exited {})",
845 attestation.commit, attestation.exit_code
846 );
847 Ok(0)
848}
849
850fn run_e2e_verify() -> anyhow::Result<i32> {
854 let repo = std::env::current_dir()?;
855 match e2e::verify(&repo)? {
856 e2e::Verification::Fresh => Ok(0),
857 e2e::Verification::Missing => {
858 eprintln!(
859 "e2e attestation missing — run `testing-conventions e2e attest '<your e2e command>'`"
860 );
861 Ok(1)
862 }
863 e2e::Verification::Stale { attested, latest } => {
864 eprintln!(
865 "e2e attestation out of date: attested {}, latest code commit {} — \
866 run `testing-conventions e2e attest '<your e2e command>'`",
867 &attested[..attested.len().min(7)],
868 &latest[..latest.len().min(7)]
869 );
870 Ok(1)
871 }
872 }
873}
874
875#[cfg(test)]
876mod tests {
877 use super::*;
878
879 #[test]
880 fn no_args_returns_ok_zero() {
881 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
882 }
883
884 #[test]
885 fn check_returns_ok_zero() {
886 assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
887 }
888
889 #[test]
890 fn unknown_flag_errors() {
891 assert!(run(["testing-conventions", "--bogus"]).is_err());
892 }
893
894 #[test]
895 fn help_flag_returns_clap_display_help() {
896 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
897 let clap_err = err
898 .downcast_ref::<clap::Error>()
899 .expect("error should be a clap::Error");
900 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
901 }
902
903 #[test]
904 fn version_flag_returns_clap_display_version() {
905 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
906 let clap_err = err
907 .downcast_ref::<clap::Error>()
908 .expect("error should be a clap::Error");
909 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
910 }
911}