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 }) => run_changelog(&base, &path),
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) = match language {
432 colocated_test::Language::Rust => (
433 "missing inline `#[cfg(test)]` tests",
434 "source file(s) with testable code but no inline `#[cfg(test)]` module \
435 (add an inline test module, or an `exempt` entry with a reason)",
436 ),
437 _ => (
438 "missing colocated unit test",
439 "source file(s) missing a colocated unit test \
440 (add a colocated test, or an `exempt` entry with a reason)",
441 ),
442 };
443 for orphan in &orphans {
444 eprintln!("{label}: {}", orphan.display());
445 }
446 eprintln!("error: {} {summary}", orphans.len());
447 Ok(false)
448}
449
450fn colocated_test_exemptions(
452 root: &Path,
453 language: colocated_test::Language,
454 config_path: &Path,
455) -> anyhow::Result<std::collections::BTreeSet<String>> {
456 if !config_path.exists() {
457 return Ok(std::collections::BTreeSet::new());
458 }
459 let config = config::load_config(config_path)?;
460 config::resolve_exempt(
461 root,
462 config.exemptions(language),
463 config::Rule::ColocatedTest,
464 )
465}
466
467fn report_co_change(
470 root: &Path,
471 base: &str,
472 language: colocated_test::Language,
473 config_path: &Path,
474) -> anyhow::Result<bool> {
475 let exempt = co_change_exemptions(root, language, config_path)?;
476 let stale = co_change::stale_sources(root, base, language, &exempt)?;
477 if stale.is_empty() {
478 return Ok(true);
479 }
480 for source in &stale {
481 eprintln!(
482 "source changed without its colocated test: {}",
483 source.display()
484 );
485 }
486 eprintln!(
487 "error: {} source file(s) changed without their colocated test co-changing \
488 (update the test, or add an `exempt` entry with a reason)",
489 stale.len()
490 );
491 Ok(false)
492}
493
494fn co_change_exemptions(
496 root: &Path,
497 language: colocated_test::Language,
498 config_path: &Path,
499) -> anyhow::Result<std::collections::BTreeSet<String>> {
500 if !config_path.exists() {
501 return Ok(std::collections::BTreeSet::new());
502 }
503 let config = config::load_config(config_path)?;
504 config::resolve_exempt(root, config.exemptions(language), config::Rule::CoChange)
505}
506
507fn split_scopes(
509 scopes: std::collections::BTreeMap<String, config::LineScope>,
510) -> (
511 Vec<String>,
512 std::collections::BTreeMap<String, std::collections::BTreeSet<u32>>,
513) {
514 let mut whole_file = Vec::new();
515 let mut line_scoped = std::collections::BTreeMap::new();
516 for (path, scope) in scopes {
517 match scope {
518 config::LineScope::WholeFile => whole_file.push(path),
519 config::LineScope::Lines(lines) => {
520 line_scoped.insert(path, lines);
521 }
522 }
523 }
524 (whole_file, line_scoped)
525}
526
527fn run_changelog(base: &str, root: &Path) -> anyhow::Result<i32> {
530 let Some(layout) = changelog::discover_layout(root) else {
531 println!(
532 "No fragment directories under `{}`; changelog check skipped.",
533 root.display()
534 );
535 return Ok(0);
536 };
537 if changelog::has_skip_line(&changelog::commit_bodies(root, base)?) {
538 println!("A `skip-changelog:` line is present; changelog check bypassed.");
539 return Ok(0);
540 }
541 let changed = changelog::changed_files(root, base)?;
542 let added = changelog::added_files(root, base)?;
543 let migrations = changelog::migrations_enforced(root);
544 let found = changelog::findings(&layout, migrations, &changed, &added);
545 if found.is_empty() {
546 println!("Every scope that changed public surface added its fragments.");
547 return Ok(0);
548 }
549 for finding in &found {
550 match &finding.file {
551 Some(file) => println!("::error file={file}::{}", finding.message),
552 None => println!("::error::{}", finding.message),
553 }
554 }
555 Ok(1)
556}
557
558fn run_packaging(path: &Path, language: Option<colocated_test::Language>) -> anyhow::Result<i32> {
562 let distributions = match language {
563 Some(language) => vec![packaging::Distribution {
564 path: path.to_path_buf(),
565 language,
566 }],
567 None => packaging::discover(path)?,
568 };
569 if distributions.is_empty() {
570 anyhow::bail!(
571 "no recognized built distribution (`.whl`, `.tar.gz`, `.tgz`, `.crate`) at `{}`",
572 path.display()
573 );
574 }
575 let mut shipped = 0;
576 for distribution in &distributions {
577 shipped += report_shipped_test_files(distribution)?;
578 }
579 if shipped > 0 {
580 eprintln!(
581 "error: {shipped} test file(s) present in the built distribution(s) \
582 (they must be excluded from packaging)"
583 );
584 return Ok(1);
585 }
586 println!(
587 "checked {} built distribution(s); no test files shipped",
588 distributions.len()
589 );
590 Ok(0)
591}
592
593fn report_shipped_test_files(distribution: &packaging::Distribution) -> anyhow::Result<usize> {
595 let globs = match distribution.language {
596 colocated_test::Language::Python => vec!["*_test.py".to_string()],
597 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
598 colocated_test::Language::Rust => vec!["tests/".to_string()],
600 };
601 let offenders = packaging::inspect(&distribution.path, &globs)?;
602 for offender in &offenders {
603 eprintln!(
604 "test file in built artifact `{}`: {}",
605 distribution.path.display(),
606 offender.display()
607 );
608 }
609 Ok(offenders.len())
610}
611
612fn run_workflow(path: &Path) -> anyhow::Result<i32> {
615 let violations = workflow::check(path, &command())?;
616 if violations.is_empty() {
617 return Ok(0);
618 }
619 for v in &violations {
620 eprintln!(
621 "{}:{}: {} — {}",
622 v.file.display(),
623 v.line,
624 v.rule,
625 v.message
626 );
627 }
628 eprintln!(
629 "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
630 violations.len()
631 );
632 Ok(1)
633}
634
635fn run_workflow_lint(path: &Path) -> anyhow::Result<i32> {
636 let findings = workflow_lint::scan(path)?;
637 if findings.is_empty() {
638 return Ok(0);
639 }
640 for f in &findings {
641 eprintln!(
642 "{}:{}: {} step `{}` encodes logic inline ({}) — move it into a tested package in \
643 this repository's own language, invoked as a one-line `run:`",
644 f.file.display(),
645 f.line,
646 f.kind,
647 f.step,
648 f.reasons.join("; ")
649 );
650 }
651 eprintln!(
652 "error: {} step(s) encode logic in CI YAML, where nothing tests it",
653 findings.len()
654 );
655 Ok(1)
656}
657
658fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
661 let repo = std::env::current_dir()?;
662 let attestation = e2e::attest(&repo, command)?;
663 if attestation.exit_code != 0 {
664 eprintln!(
665 "e2e command `{command}` exited {}; a receipt records a run that passed — \
666 fix the failure and attest again",
667 attestation.exit_code
668 );
669 return Ok(attestation.exit_code);
670 }
671 println!(
672 "e2e receipt recorded for branch {} at {}/{}.json",
673 attestation.branch,
674 e2e::RECEIPTS_DIR,
675 e2e::branch_slug(&attestation.branch),
676 );
677 Ok(0)
678}
679
680fn run_e2e_verify(
684 path: &Path,
685 scope: Option<&Path>,
686 base: Option<&str>,
687 extra_scopes: &[PathBuf],
688 excludes: &[PathBuf],
689 branch: Option<&str>,
690) -> anyhow::Result<i32> {
691 match e2e::verify_extra_scoped(
692 path,
693 scope.unwrap_or(path),
694 base,
695 extra_scopes,
696 excludes,
697 branch,
698 )? {
699 e2e::Verification::Fresh => Ok(0),
700 e2e::Verification::Missing => {
701 eprintln!(
702 "no e2e receipt answers this change — run \
703 `testing-conventions e2e attest '<your e2e command>'`; the command is \
704 your judgment: the full suite, a targeted subset, or a no-op"
705 );
706 Ok(1)
707 }
708 }
709}
710
711fn run_e2e_slug(branch: Option<&str>) -> anyhow::Result<i32> {
713 let slug = match branch {
714 Some(name) => e2e::branch_slug(name),
715 None => {
716 let repo = std::env::current_dir()?;
717 e2e::branch_slug(&e2e::current_branch(&repo)?)
718 }
719 };
720 println!("{slug}");
721 Ok(0)
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727
728 #[test]
729 fn no_args_returns_ok_zero() {
730 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
731 }
732
733 #[test]
734 fn unknown_flag_errors() {
735 assert!(run(["testing-conventions", "--bogus"]).is_err());
736 }
737
738 #[test]
739 fn split_scopes_separates_whole_file_paths_from_line_sets() {
740 let mut scopes = std::collections::BTreeMap::new();
741 scopes.insert("shim.py".to_string(), config::LineScope::WholeFile);
742 scopes.insert(
743 "widget.py".to_string(),
744 config::LineScope::Lines(std::collections::BTreeSet::from([3])),
745 );
746 let (whole_file, line_scoped) = split_scopes(scopes);
747 assert_eq!(whole_file, vec!["shim.py".to_string()]);
748 assert_eq!(line_scoped.len(), 1);
749 assert_eq!(
750 line_scoped["widget.py"],
751 std::collections::BTreeSet::from([3])
752 );
753 }
754
755 #[test]
756 fn help_flag_returns_clap_display_help() {
757 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
758 let clap_err = err
759 .downcast_ref::<clap::Error>()
760 .expect("error should be a clap::Error");
761 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
762 }
763
764 #[test]
765 fn version_flag_returns_clap_display_version() {
766 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
767 let clap_err = err
768 .downcast_ref::<clap::Error>()
769 .expect("error should be a clap::Error");
770 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
771 }
772}