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 functions: coverage.functions,
549 branch: coverage.branch,
550 };
551 let (ignore, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
552 root,
553 &rust.exempt,
554 config::Rule::Coverage,
555 )?);
556 match base {
557 Some(base) => patch_coverage::measure_rust(
558 root,
559 base,
560 thresholds,
561 &ignore,
562 &exempt_lines,
563 &rust.features,
564 )?,
565 None if exempt_lines.is_empty() => {
566 coverage::measure_rust(root, thresholds, &ignore, &rust.features)?
567 }
568 None => patch_coverage::measure_line_exempt_rust(
569 root,
570 thresholds,
571 &ignore,
572 &exempt_lines,
573 &rust.features,
574 )?,
575 }
576 }
577 };
578 match outcome {
579 coverage::Outcome::Pass => Ok(0),
580 coverage::Outcome::Fail(reason) => {
581 eprintln!("error: coverage check failed — {reason}");
582 Ok(1)
583 }
584 }
585}
586
587fn run_unit_mutation(
598 root: &Path,
599 language: colocated_test::Language,
600 base: Option<&str>,
601 config_path: &Path,
602 ts_adapter: Option<&Path>,
603) -> anyhow::Result<i32> {
604 let config = if config_path.exists() {
605 config::load_config(config_path)?
606 } else {
607 config::Config::default()
608 };
609 let survivors = match language {
610 colocated_test::Language::Rust => {
611 let rust = config.rust.unwrap_or_default();
612 let (exempt, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
613 root,
614 &rust.exempt,
615 config::Rule::Mutation,
616 )?);
617 mutation::measure_rust(root, &exempt, &exempt_lines, base, &rust.features)?
618 }
619 colocated_test::Language::TypeScript => {
620 let typescript = config.typescript.unwrap_or_default();
621 let (exempt, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
622 root,
623 &typescript.exempt,
624 config::Rule::Mutation,
625 )?);
626 let adapter = ts_adapter.ok_or_else(|| {
629 anyhow::anyhow!(
630 "the TypeScript mutation adapter path is required: pass \
631 `--ts-mutation-adapter <path>`. The npm `testing-conventions` CLI appends it \
632 automatically — run the rule through that CLI, not the raw binary."
633 )
634 })?;
635 mutation::measure_typescript(root, &exempt, &exempt_lines, base, adapter)?
636 }
637 colocated_test::Language::Python => {
638 let python = config.python.unwrap_or_default();
639 let (exempt, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
640 root,
641 &python.exempt,
642 config::Rule::Mutation,
643 )?);
644 mutation::measure_python(root, &exempt, &exempt_lines, base)?
645 }
646 };
647 if survivors.is_empty() {
648 println!("unit mutation: no surviving mutants — every mutation was caught");
649 return Ok(0);
650 }
651
652 eprintln!(
653 "error: {} unexplained surviving mutant(s) — kill each with an assertion, or lift an \
654 equivalent/defensive one with a reason-required `[[<language>.exempt]] rules = [\"mutation\"]`:",
655 survivors.len()
656 );
657 for survivor in &survivors {
658 eprintln!(
659 " {}:{}: {}",
660 survivor.file, survivor.line, survivor.description
661 );
662 }
663 Ok(1)
664}
665
666fn run_unit_lint(
671 root: &Path,
672 language: isolation::Language,
673 config_path: &Path,
674) -> anyhow::Result<i32> {
675 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
676 isolation::Language::Rust => (isolation::find_violations(root)?, |c| c.rust_exemptions()),
677 isolation::Language::TypeScript => (ts::find_unit_violations(root)?, |c| {
678 c.exemptions(colocated_test::Language::TypeScript)
679 }),
680 isolation::Language::Python => (lint::find_unit_isolation_violations(root)?, |c| {
681 c.exemptions(colocated_test::Language::Python)
682 }),
683 };
684 let violations = apply_waivers(raw, root, config_path, select)?;
685 if violations.is_empty() {
686 return Ok(0);
687 }
688 for v in &violations {
689 eprintln!(
690 "{}:{}: {} — {}",
691 v.file.display(),
692 v.line,
693 v.rule,
694 v.message
695 );
696 }
697 eprintln!("error: {} isolation violation(s)", violations.len());
698 Ok(1)
699}
700
701fn run_integration_lint(
705 root: &Path,
706 language: IntegrationLintLanguage,
707 config_path: &Path,
708) -> anyhow::Result<i32> {
709 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
710 IntegrationLintLanguage::Python => (lint::find_violations(root)?, |c| {
711 c.exemptions(colocated_test::Language::Python)
712 }),
713 IntegrationLintLanguage::TypeScript => (ts::find_integration_violations(root)?, |c| {
714 c.exemptions(colocated_test::Language::TypeScript)
715 }),
716 IntegrationLintLanguage::Rust => (isolation::find_integration_violations(root)?, |c| {
717 c.rust_exemptions()
718 }),
719 };
720 let violations = apply_waivers(raw, root, config_path, select)?;
721 if violations.is_empty() {
722 return Ok(0);
723 }
724 for v in &violations {
725 eprintln!(
726 "{}:{}: {} — {}",
727 v.file.display(),
728 v.line,
729 v.rule,
730 v.message
731 );
732 }
733 eprintln!("error: {} lint violation(s)", violations.len());
734 Ok(1)
735}
736
737type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
740
741fn apply_waivers(
748 violations: Vec<lint::Violation>,
749 root: &Path,
750 config_path: &Path,
751 exemptions: ExemptSelect,
752) -> anyhow::Result<Vec<lint::Violation>> {
753 use std::collections::hash_map::Entry;
754
755 if !config_path.exists() {
756 return Ok(violations);
757 }
758 let config = config::load_config(config_path)?;
759 let exempt = exemptions(&config);
760 let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
762 std::collections::HashMap::new();
763 let mut kept = Vec::new();
764 for violation in violations {
765 let waived = match config::Rule::from_id(violation.rule) {
766 Some(rule) => {
767 let exempt_paths = match resolved.entry(rule) {
768 Entry::Occupied(entry) => entry.into_mut(),
769 Entry::Vacant(entry) => {
770 entry.insert(config::resolve_exempt(root, exempt, rule)?)
771 }
772 };
773 violation
774 .file
775 .strip_prefix(root)
776 .ok()
777 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
778 .is_some_and(|rel| exempt_paths.contains(&rel))
779 }
780 None => false,
781 };
782 if !waived {
783 kept.push(violation);
784 }
785 }
786 Ok(kept)
787}
788
789fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
798 let globs = match language {
799 colocated_test::Language::Python => vec!["*_test.py".to_string()],
800 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
801 colocated_test::Language::Rust => vec!["tests/".to_string()],
804 };
805 let offenders = packaging::inspect(artifact, &globs)?;
806 if offenders.is_empty() {
807 return Ok(0);
808 }
809 for offender in &offenders {
810 eprintln!("test file in built artifact: {}", offender.display());
811 }
812 eprintln!(
813 "error: {} test file(s) present in the built artifact \
814 (they must be excluded from packaging)",
815 offenders.len()
816 );
817 Ok(1)
818}
819
820fn run_workflow(path: &Path) -> anyhow::Result<i32> {
825 let violations = workflow::check(path, &command())?;
826 if violations.is_empty() {
827 return Ok(0);
828 }
829 for v in &violations {
830 eprintln!(
831 "{}:{}: {} — {}",
832 v.file.display(),
833 v.line,
834 v.rule,
835 v.message
836 );
837 }
838 eprintln!(
839 "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
840 violations.len()
841 );
842 Ok(1)
843}
844
845fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
849 let repo = std::env::current_dir()?;
850 let attestation = e2e::attest(&repo, command)?;
851 println!(
852 "e2e attestation recorded for commit {} (command exited {})",
853 attestation.commit, attestation.exit_code
854 );
855 Ok(0)
856}
857
858fn run_e2e_verify() -> anyhow::Result<i32> {
862 let repo = std::env::current_dir()?;
863 match e2e::verify(&repo)? {
864 e2e::Verification::Fresh => Ok(0),
865 e2e::Verification::Missing => {
866 eprintln!(
867 "e2e attestation missing — run `testing-conventions e2e attest '<your e2e command>'`"
868 );
869 Ok(1)
870 }
871 e2e::Verification::Stale { attested, latest } => {
872 eprintln!(
873 "e2e attestation out of date: attested {}, latest code commit {} — \
874 run `testing-conventions e2e attest '<your e2e command>'`",
875 &attested[..attested.len().min(7)],
876 &latest[..latest.len().min(7)]
877 );
878 Ok(1)
879 }
880 }
881}
882
883#[cfg(test)]
884mod tests {
885 use super::*;
886
887 #[test]
888 fn no_args_returns_ok_zero() {
889 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
890 }
891
892 #[test]
893 fn check_returns_ok_zero() {
894 assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
895 }
896
897 #[test]
898 fn unknown_flag_errors() {
899 assert!(run(["testing-conventions", "--bogus"]).is_err());
900 }
901
902 #[test]
903 fn help_flag_returns_clap_display_help() {
904 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
905 let clap_err = err
906 .downcast_ref::<clap::Error>()
907 .expect("error should be a clap::Error");
908 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
909 }
910
911 #[test]
912 fn version_flag_returns_clap_display_version() {
913 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
914 let clap_err = err
915 .downcast_ref::<clap::Error>()
916 .expect("error should be a clap::Error");
917 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
918 }
919}