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