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;
9pub mod isolation;
10pub mod lint;
11pub mod mutation;
12pub mod one_function;
13pub mod packaging;
14pub mod patch_coverage;
15pub mod tiers;
16pub mod ts;
17pub mod violation;
18mod walk;
19pub mod workflow;
20pub mod workflow_lint;
21
22use std::path::{Path, PathBuf};
23
24use clap::{CommandFactory, Parser, Subcommand};
25
26#[derive(Parser, Debug)]
27#[command(
28 name = "testing-conventions",
29 version,
30 about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
31 long_about = None,
32)]
33pub struct Cli {
34 #[command(subcommand)]
35 command: Option<Command>,
36}
37
38#[derive(Subcommand, Debug)]
39enum Command {
40 Install {
45 #[arg(default_value = "AGENTS.md")]
47 path: PathBuf,
48 },
49 Unit {
51 #[command(subcommand)]
52 rule: UnitRule,
53 },
54 Integration {
56 #[command(subcommand)]
57 rule: IntegrationRule,
58 },
59 Packaging {
61 path: PathBuf,
64 #[arg(long, value_enum)]
67 language: Option<colocated_test::Language>,
68 },
69 #[command(hide = true)]
74 Workflow {
75 path: PathBuf,
77 },
78 E2e {
80 #[command(subcommand)]
81 command: E2eCommand,
82 },
83 WorkflowLint {
87 #[arg(default_value = ".github")]
89 path: PathBuf,
90 },
91 Changelog {
94 #[arg(long)]
96 base: String,
97 #[arg(default_value = ".")]
99 path: PathBuf,
100 },
101}
102
103#[derive(Subcommand, Debug)]
104enum UnitRule {
105 ColocatedTest {
111 path: PathBuf,
113 #[arg(long, value_enum)]
115 language: colocated_test::Language,
116 #[arg(long)]
122 base: Option<String>,
123 #[arg(long, default_value = "testing-conventions.toml")]
126 config: PathBuf,
127 },
128 Coverage {
133 path: PathBuf,
135 #[arg(long, value_enum)]
137 language: colocated_test::Language,
138 #[arg(long)]
144 base: Option<String>,
145 #[arg(long, default_value = "testing-conventions.toml")]
150 config: PathBuf,
151 },
152 OneFunctionPerFile {
156 path: PathBuf,
158 #[arg(long, value_enum)]
160 language: colocated_test::Language,
161 #[arg(long, default_value = "testing-conventions.toml")]
166 config: PathBuf,
167 },
168 Lint {
170 path: PathBuf,
172 #[arg(long, value_enum)]
174 language: isolation::Language,
175 #[arg(long, default_value = "testing-conventions.toml")]
178 config: PathBuf,
179 },
180 Mutation {
186 path: PathBuf,
188 #[arg(long, value_enum)]
190 language: colocated_test::Language,
191 #[arg(long)]
195 base: Option<String>,
196 #[arg(long, default_value = "testing-conventions.toml")]
199 config: PathBuf,
200 #[arg(long = "ts-mutation-adapter", hide = true)]
204 ts_adapter: Option<PathBuf>,
205 },
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
210pub enum IntegrationLintLanguage {
211 #[value(name = "python")]
213 Python,
214 #[value(name = "typescript")]
216 TypeScript,
217 #[value(name = "rust")]
219 Rust,
220}
221
222#[derive(Subcommand, Debug)]
223enum IntegrationRule {
224 Lint {
226 path: PathBuf,
228 #[arg(long, value_enum)]
230 language: IntegrationLintLanguage,
231 #[arg(long, default_value = "testing-conventions.toml")]
234 config: PathBuf,
235 },
236}
237
238#[derive(Subcommand, Debug)]
239enum E2eCommand {
240 Attest {
244 command: String,
246 },
247 Verify {
249 #[arg(default_value = ".")]
252 path: PathBuf,
253 #[arg(long)]
256 scope: Option<PathBuf>,
257 #[arg(long)]
264 base: Option<String>,
265 #[arg(long = "extra-scope")]
270 extra_scope: Vec<PathBuf>,
271 #[arg(long = "exclude")]
275 exclude: Vec<PathBuf>,
276 #[arg(long)]
279 branch: Option<String>,
280 },
281 Slug {
284 branch: Option<String>,
286 },
287}
288
289pub fn run<I, T>(args: I) -> anyhow::Result<i32>
290where
291 I: IntoIterator<Item = T>,
292 T: Into<std::ffi::OsString> + Clone,
293{
294 eprintln!("testing-conventions {}", env!("CARGO_PKG_VERSION"));
297 let cli = Cli::try_parse_from(args)?;
298 match cli.command {
299 None => Ok(0),
300 Some(Command::Unit { rule }) => match rule {
301 UnitRule::ColocatedTest {
302 path,
303 language,
304 base,
305 config,
306 } => run_unit_colocated_test(&path, language, base.as_deref(), &config),
307 UnitRule::Coverage {
308 path,
309 language,
310 base,
311 config,
312 } => run_unit_coverage(&path, language, base.as_deref(), &config),
313 UnitRule::OneFunctionPerFile {
314 path,
315 language,
316 config,
317 } => run_unit_one_function(&path, language, &config),
318 UnitRule::Lint {
319 path,
320 language,
321 config,
322 } => run_unit_lint(&path, language, &config),
323 UnitRule::Mutation {
324 path,
325 language,
326 base,
327 config,
328 ts_adapter,
329 } => run_unit_mutation(
330 &path,
331 language,
332 base.as_deref(),
333 &config,
334 ts_adapter.as_deref(),
335 ),
336 },
337 Some(Command::Integration { rule }) => match rule {
338 IntegrationRule::Lint {
339 path,
340 language,
341 config,
342 } => run_integration_lint(&path, language, &config),
343 },
344 Some(Command::Packaging { path, language }) => run_packaging(&path, language),
345 Some(Command::Changelog { base, path }) => run_changelog(&base, &path),
346 Some(Command::Workflow { path }) => run_workflow(&path),
347 Some(Command::WorkflowLint { path }) => run_workflow_lint(&path),
348 Some(Command::E2e { command }) => match command {
349 E2eCommand::Attest { command } => run_e2e_attest(&command),
350 E2eCommand::Verify {
351 path,
352 scope,
353 base,
354 extra_scope,
355 exclude,
356 branch,
357 } => run_e2e_verify(
358 &path,
359 scope.as_deref(),
360 base.as_deref(),
361 &extra_scope,
362 &exclude,
363 branch.as_deref(),
364 ),
365 E2eCommand::Slug { branch } => run_e2e_slug(branch.as_deref()),
366 },
367 Some(Command::Install { path }) => {
368 agents::install(&path)?;
369 Ok(0)
370 }
371 }
372}
373
374pub fn command() -> clap::Command {
376 Cli::command()
377}
378
379fn run_unit_colocated_test(
382 root: &Path,
383 language: colocated_test::Language,
384 base: Option<&str>,
385 config_path: &Path,
386) -> anyhow::Result<i32> {
387 if base.is_some() && language == colocated_test::Language::Rust {
388 anyhow::bail!(
389 "`unit colocated-test --base` supports `--language python` / `typescript`; Rust \
390 units are inline `#[cfg(test)]` in the same file, so a sibling test can't go stale"
391 );
392 }
393 let presence_clean = report_colocated_presence(root, language, config_path)?;
394 let co_change_clean = match base {
395 Some(base) => report_co_change(root, base, language, config_path)?,
396 None => true,
397 };
398 Ok(if presence_clean && co_change_clean {
399 0
400 } else {
401 1
402 })
403}
404
405fn report_colocated_presence(
408 root: &Path,
409 language: colocated_test::Language,
410 config_path: &Path,
411) -> anyhow::Result<bool> {
412 let exempt = colocated_test_exemptions(root, language, config_path)?;
413 let orphans = match language {
414 colocated_test::Language::Rust => colocated_test::missing_inline_tests(root, &exempt)?,
415 _ => colocated_test::missing_unit_tests(root, language, &exempt)?,
416 };
417 if orphans.is_empty() {
418 return Ok(true);
419 }
420 let (label, summary) = match language {
421 colocated_test::Language::Rust => (
422 "missing inline `#[cfg(test)]` tests",
423 "source file(s) with testable code but no inline `#[cfg(test)]` module \
424 (add an inline test module, or an `exempt` entry with a reason)",
425 ),
426 _ => (
427 "missing colocated unit test",
428 "source file(s) missing a colocated unit test \
429 (add a colocated test, or an `exempt` entry with a reason)",
430 ),
431 };
432 for orphan in &orphans {
433 eprintln!("{label}: {}", orphan.display());
434 }
435 eprintln!("error: {} {summary}", orphans.len());
436 Ok(false)
437}
438
439fn colocated_test_exemptions(
441 root: &Path,
442 language: colocated_test::Language,
443 config_path: &Path,
444) -> anyhow::Result<std::collections::BTreeSet<String>> {
445 if !config_path.exists() {
446 return Ok(std::collections::BTreeSet::new());
447 }
448 let config = config::load_config(config_path)?;
449 config::resolve_exempt(
450 root,
451 config.exemptions(language),
452 config::Rule::ColocatedTest,
453 )
454}
455
456fn report_co_change(
459 root: &Path,
460 base: &str,
461 language: colocated_test::Language,
462 config_path: &Path,
463) -> anyhow::Result<bool> {
464 let exempt = co_change_exemptions(root, language, config_path)?;
465 let stale = co_change::stale_sources(root, base, language, &exempt)?;
466 if stale.is_empty() {
467 return Ok(true);
468 }
469 for source in &stale {
470 eprintln!(
471 "source changed without its colocated test: {}",
472 source.display()
473 );
474 }
475 eprintln!(
476 "error: {} source file(s) changed without their colocated test co-changing \
477 (update the test, or add an `exempt` entry with a reason)",
478 stale.len()
479 );
480 Ok(false)
481}
482
483fn co_change_exemptions(
485 root: &Path,
486 language: colocated_test::Language,
487 config_path: &Path,
488) -> anyhow::Result<std::collections::BTreeSet<String>> {
489 if !config_path.exists() {
490 return Ok(std::collections::BTreeSet::new());
491 }
492 let config = config::load_config(config_path)?;
493 config::resolve_exempt(root, config.exemptions(language), config::Rule::CoChange)
494}
495
496fn split_scopes(
498 scopes: std::collections::BTreeMap<String, config::LineScope>,
499) -> (
500 Vec<String>,
501 std::collections::BTreeMap<String, std::collections::BTreeSet<u32>>,
502) {
503 let mut whole_file = Vec::new();
504 let mut line_scoped = std::collections::BTreeMap::new();
505 for (path, scope) in scopes {
506 match scope {
507 config::LineScope::WholeFile => whole_file.push(path),
508 config::LineScope::Lines(lines) => {
509 line_scoped.insert(path, lines);
510 }
511 }
512 }
513 (whole_file, line_scoped)
514}
515
516fn run_unit_coverage(
519 root: &Path,
520 language: colocated_test::Language,
521 base: Option<&str>,
522 config_path: &Path,
523) -> anyhow::Result<i32> {
524 let config = if config_path.exists() {
525 config::load_config(config_path)?
526 } else {
527 config::Config::default()
528 };
529 let outcome = match language {
530 colocated_test::Language::Python => {
531 let python = config.python.unwrap_or_default();
532 let coverage = python.coverage.unwrap_or_default();
533 let thresholds = coverage::Thresholds {
534 fail_under: coverage.fail_under,
535 branch: coverage.branch,
536 };
537 let scopes =
538 config::resolve_exempt_scoped(root, &python.exempt, config::Rule::Coverage)?;
539 let (omit, exempt_lines) = split_scopes(scopes);
540 match base {
541 Some(base) => {
542 patch_coverage::measure(root, base, thresholds, &omit, &exempt_lines)?
543 }
544 None if exempt_lines.is_empty() => coverage::measure(root, thresholds, &omit)?,
545 None => {
546 patch_coverage::measure_line_exempt(root, thresholds, &omit, &exempt_lines)?
547 }
548 }
549 }
550 colocated_test::Language::TypeScript => {
551 let typescript = config.typescript.unwrap_or_default();
552 let coverage = typescript.coverage.unwrap_or_default();
553 let thresholds = coverage::TypeScriptThresholds {
554 lines: coverage.lines,
555 branches: coverage.branches,
556 functions: coverage.functions,
557 statements: coverage.statements,
558 };
559 let scopes =
560 config::resolve_exempt_scoped(root, &typescript.exempt, config::Rule::Coverage)?;
561 let (exclude, exempt_lines) = split_scopes(scopes);
562 match base {
563 Some(base) => patch_coverage::measure_typescript(
564 root,
565 base,
566 thresholds,
567 &exclude,
568 &exempt_lines,
569 )?,
570 None if exempt_lines.is_empty() => {
571 coverage::measure_typescript(root, thresholds, &exclude)?
572 }
573 None => patch_coverage::measure_line_exempt_typescript(
574 root,
575 thresholds,
576 &exclude,
577 &exempt_lines,
578 )?,
579 }
580 }
581 colocated_test::Language::Rust => {
582 let rust = config.rust.unwrap_or_default();
583 let coverage = rust.coverage.unwrap_or_default();
584 let thresholds = coverage::RustThresholds {
585 regions: coverage.regions,
586 lines: coverage.lines,
587 functions: coverage.functions,
588 branch: coverage.branch,
589 };
590 let scopes = config::resolve_exempt_scoped(root, &rust.exempt, config::Rule::Coverage)?;
591 let (ignore, exempt_lines) = split_scopes(scopes);
592 match base {
593 Some(base) => patch_coverage::measure_rust(
594 root,
595 base,
596 thresholds,
597 &ignore,
598 &exempt_lines,
599 &rust.features,
600 )?,
601 None if exempt_lines.is_empty() => {
602 coverage::measure_rust(root, thresholds, &ignore, &rust.features)?
603 }
604 None => patch_coverage::measure_line_exempt_rust(
605 root,
606 thresholds,
607 &ignore,
608 &exempt_lines,
609 &rust.features,
610 )?,
611 }
612 }
613 };
614 match outcome {
615 coverage::Outcome::Pass => Ok(0),
616 coverage::Outcome::Fail(reason) => {
617 eprintln!("error: coverage check failed — {reason}");
618 Ok(1)
619 }
620 }
621}
622
623fn run_unit_mutation(
626 root: &Path,
627 language: colocated_test::Language,
628 base: Option<&str>,
629 config_path: &Path,
630 ts_adapter: Option<&Path>,
631) -> anyhow::Result<i32> {
632 let config = if config_path.exists() {
633 config::load_config(config_path)?
634 } else {
635 config::Config::default()
636 };
637 let measurement = match language {
638 colocated_test::Language::Rust => {
639 let rust = config.rust.unwrap_or_default();
640 let scopes = config::resolve_exempt_scoped(root, &rust.exempt, config::Rule::Mutation)?;
641 let (exempt, exempt_lines) = split_scopes(scopes);
642 mutation::measure_rust(root, &exempt, &exempt_lines, base, &rust.features)?
643 }
644 colocated_test::Language::TypeScript => {
645 let typescript = config.typescript.unwrap_or_default();
646 let scopes =
647 config::resolve_exempt_scoped(root, &typescript.exempt, config::Rule::Mutation)?;
648 let (exempt, exempt_lines) = split_scopes(scopes);
649 let adapter = ts_adapter.ok_or_else(|| {
650 anyhow::anyhow!(
651 "the TypeScript mutation adapter path is required: pass \
652 `--ts-mutation-adapter <path>`. The npm `testing-conventions` CLI appends it \
653 automatically — run the check through that CLI, not the raw binary."
654 )
655 })?;
656 mutation::measure_typescript(root, &exempt, &exempt_lines, base, adapter)?
657 }
658 colocated_test::Language::Python => {
659 let python = config.python.unwrap_or_default();
660 let scopes =
661 config::resolve_exempt_scoped(root, &python.exempt, config::Rule::Mutation)?;
662 let (exempt, exempt_lines) = split_scopes(scopes);
663 mutation::measure_python(root, &exempt, &exempt_lines, base)?
664 }
665 };
666 let (count, survivors) = match measurement {
667 mutation::Measurement::EngineNotRun => {
668 println!("unit mutation: no mutatable changed lines — engine not run");
669 return Ok(0);
670 }
671 mutation::Measurement::Tested { count, survivors } => (count, survivors),
672 };
673 if survivors.is_empty() {
674 if count == 0 {
675 println!("unit mutation: the engine found no mutants to test");
676 } else {
677 println!(
678 "unit mutation: no surviving mutants — every mutation was caught \
679 ({count} mutant(s) tested)"
680 );
681 }
682 return Ok(0);
683 }
684
685 eprintln!(
686 "error: {} unexplained surviving mutant(s) — kill each with an assertion, or lift an \
687 equivalent/defensive one with a reason-required `[[<language>.exempt]] rules = [\"mutation\"]`:",
688 survivors.len()
689 );
690 for survivor in &survivors {
691 eprintln!(
692 " {}:{}: {}",
693 survivor.file, survivor.line, survivor.description
694 );
695 }
696 Ok(1)
697}
698
699fn run_unit_one_function(
702 root: &Path,
703 language: colocated_test::Language,
704 config_path: &Path,
705) -> anyhow::Result<i32> {
706 let threshold = if config_path.exists() {
707 config::load_config(config_path)?.one_function_threshold(language)
708 } else {
709 config::Config::default().one_function_threshold(language)
710 };
711 let key = match language {
712 colocated_test::Language::Python => "python",
713 colocated_test::Language::TypeScript => "typescript",
714 colocated_test::Language::Rust => "rust",
715 };
716 let Some(max_lines) = threshold else {
717 println!(
718 "unit one-function-per-file: not enabled for {key} — \
719 set `[{key}].one_function_per_file` to opt in"
720 );
721 return Ok(0);
722 };
723 let (raw, scanned) = one_function::find_violations(root, language, max_lines)?;
724 let select: ExemptSelect = match language {
725 colocated_test::Language::Python => |c| c.exemptions(colocated_test::Language::Python),
726 colocated_test::Language::TypeScript => {
727 |c| c.exemptions(colocated_test::Language::TypeScript)
728 }
729 colocated_test::Language::Rust => |c| c.rust_exemptions(),
730 };
731 let violations = apply_waivers(raw, root, config_path, select)?;
732 if violations.is_empty() {
733 eprintln!("one-function-per-file: scanned {scanned} file(s), 0 violations");
734 return Ok(0);
735 }
736 for v in &violations {
737 eprintln!(
738 "{}:{}: {} — {}",
739 v.file.display(),
740 v.line,
741 v.rule,
742 v.message
743 );
744 }
745 eprintln!(
746 "error: {} function(s) sharing a file with another function over the \
747 {max_lines}-line threshold (move each to its own module, or add an \
748 `exempt` entry with a reason)",
749 violations.len()
750 );
751 Ok(1)
752}
753
754fn run_unit_lint(
758 root: &Path,
759 language: isolation::Language,
760 config_path: &Path,
761) -> anyhow::Result<i32> {
762 let crate_root = tiers::package_root(root, "Cargo.toml");
763 let (raw, select, waiver_root): (Vec<lint::Violation>, ExemptSelect, &Path) = match language {
764 isolation::Language::Rust => {
765 let crate_root = crate_root.as_deref().unwrap_or(root);
766 (
767 isolation::find_violations(root, crate_root)?,
768 |c| c.rust_exemptions(),
769 crate_root,
770 )
771 }
772 isolation::Language::TypeScript => (
773 ts::find_unit_violations(root)?,
774 |c| c.exemptions(colocated_test::Language::TypeScript),
775 root,
776 ),
777 isolation::Language::Python => (
778 lint::find_unit_isolation_violations(root)?,
779 |c| c.exemptions(colocated_test::Language::Python),
780 root,
781 ),
782 };
783 let violations = apply_waivers(raw, waiver_root, config_path, select)?;
784 if violations.is_empty() {
785 return Ok(0);
786 }
787 for v in &violations {
788 eprintln!(
789 "{}:{}: {} — {}",
790 v.file.display(),
791 v.line,
792 v.rule,
793 v.message
794 );
795 }
796 eprintln!("error: {} isolation violation(s)", violations.len());
797 Ok(1)
798}
799
800fn run_integration_lint(
803 root: &Path,
804 language: IntegrationLintLanguage,
805 config_path: &Path,
806) -> anyhow::Result<i32> {
807 let manifest = match language {
808 IntegrationLintLanguage::Python => "pyproject.toml",
809 IntegrationLintLanguage::TypeScript => "package.json",
810 IntegrationLintLanguage::Rust => "Cargo.toml",
811 };
812 let package_root = tiers::package_root(root, manifest);
813 let scan_root = package_root.as_deref().unwrap_or(root);
814 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
815 IntegrationLintLanguage::Python => (
816 match &package_root {
817 Some(package_root) => lint::find_suite_violations(package_root)?,
818 None => lint::find_violations(root)?,
819 },
820 |c| c.exemptions(colocated_test::Language::Python),
821 ),
822 IntegrationLintLanguage::TypeScript => (
823 match &package_root {
824 Some(package_root) => ts::find_suite_violations(package_root)?,
825 None => ts::find_integration_violations(root)?,
826 },
827 |c| c.exemptions(colocated_test::Language::TypeScript),
828 ),
829 IntegrationLintLanguage::Rust => {
830 (isolation::find_integration_violations(scan_root)?, |c| {
831 c.rust_exemptions()
832 })
833 }
834 };
835 let violations = apply_waivers(raw, scan_root, config_path, select)?;
836 if violations.is_empty() {
837 return Ok(0);
838 }
839 for v in &violations {
840 eprintln!(
841 "{}:{}: {} — {}",
842 v.file.display(),
843 v.line,
844 v.rule,
845 v.message
846 );
847 }
848 eprintln!("error: {} lint violation(s)", violations.len());
849 Ok(1)
850}
851
852type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
854
855fn apply_waivers(
857 violations: Vec<lint::Violation>,
858 root: &Path,
859 config_path: &Path,
860 exemptions: ExemptSelect,
861) -> anyhow::Result<Vec<lint::Violation>> {
862 use std::collections::hash_map::Entry;
863
864 if !config_path.exists() {
865 return Ok(violations);
866 }
867 let config = config::load_config(config_path)?;
868 let exempt = exemptions(&config);
869 let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
870 std::collections::HashMap::new();
871 let mut kept = Vec::new();
872 for violation in violations {
873 let waived = match config::Rule::from_id(violation.rule) {
874 Some(rule) => {
875 let exempt_paths = match resolved.entry(rule) {
876 Entry::Occupied(entry) => entry.into_mut(),
877 Entry::Vacant(entry) => {
878 entry.insert(config::resolve_exempt(root, exempt, rule)?)
879 }
880 };
881 violation
882 .file
883 .strip_prefix(root)
884 .ok()
885 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
886 .is_some_and(|rel| exempt_paths.contains(&rel))
887 }
888 None => false,
889 };
890 if !waived {
891 kept.push(violation);
892 }
893 }
894 Ok(kept)
895}
896
897fn run_changelog(base: &str, root: &Path) -> anyhow::Result<i32> {
900 let Some(layout) = changelog::discover_layout(root) else {
901 println!(
902 "No fragment directories under `{}`; changelog check skipped.",
903 root.display()
904 );
905 return Ok(0);
906 };
907 if changelog::has_skip_line(&changelog::commit_bodies(root, base)?) {
908 println!("A `skip-changelog:` line is present; changelog check bypassed.");
909 return Ok(0);
910 }
911 let changed = changelog::changed_files(root, base)?;
912 let added = changelog::added_files(root, base)?;
913 let migrations = changelog::migrations_enforced(root);
914 let found = changelog::findings(&layout, migrations, &changed, &added);
915 if found.is_empty() {
916 println!("Every scope that changed public surface added its fragments.");
917 return Ok(0);
918 }
919 for finding in &found {
920 match &finding.file {
921 Some(file) => println!("::error file={file}::{}", finding.message),
922 None => println!("::error::{}", finding.message),
923 }
924 }
925 Ok(1)
926}
927
928fn run_packaging(path: &Path, language: Option<colocated_test::Language>) -> anyhow::Result<i32> {
932 let distributions = match language {
933 Some(language) => vec![packaging::Distribution {
934 path: path.to_path_buf(),
935 language,
936 }],
937 None => packaging::discover(path)?,
938 };
939 if distributions.is_empty() {
940 anyhow::bail!(
941 "no recognized built distribution (`.whl`, `.tar.gz`, `.tgz`, `.crate`) at `{}`",
942 path.display()
943 );
944 }
945 let mut shipped = 0;
946 for distribution in &distributions {
947 shipped += report_shipped_test_files(distribution)?;
948 }
949 if shipped > 0 {
950 eprintln!(
951 "error: {shipped} test file(s) present in the built distribution(s) \
952 (they must be excluded from packaging)"
953 );
954 return Ok(1);
955 }
956 println!(
957 "checked {} built distribution(s); no test files shipped",
958 distributions.len()
959 );
960 Ok(0)
961}
962
963fn report_shipped_test_files(distribution: &packaging::Distribution) -> anyhow::Result<usize> {
965 let globs = match distribution.language {
966 colocated_test::Language::Python => vec!["*_test.py".to_string()],
967 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
968 colocated_test::Language::Rust => vec!["tests/".to_string()],
970 };
971 let offenders = packaging::inspect(&distribution.path, &globs)?;
972 for offender in &offenders {
973 eprintln!(
974 "test file in built artifact `{}`: {}",
975 distribution.path.display(),
976 offender.display()
977 );
978 }
979 Ok(offenders.len())
980}
981
982fn run_workflow(path: &Path) -> anyhow::Result<i32> {
985 let violations = workflow::check(path, &command())?;
986 if violations.is_empty() {
987 return Ok(0);
988 }
989 for v in &violations {
990 eprintln!(
991 "{}:{}: {} — {}",
992 v.file.display(),
993 v.line,
994 v.rule,
995 v.message
996 );
997 }
998 eprintln!(
999 "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
1000 violations.len()
1001 );
1002 Ok(1)
1003}
1004
1005fn run_workflow_lint(path: &Path) -> anyhow::Result<i32> {
1006 let findings = workflow_lint::scan(path)?;
1007 if findings.is_empty() {
1008 return Ok(0);
1009 }
1010 for f in &findings {
1011 eprintln!(
1012 "{}:{}: {} step `{}` encodes logic inline ({}) — move it into a tested package in \
1013 this repository's own language, invoked as a one-line `run:`",
1014 f.file.display(),
1015 f.line,
1016 f.kind,
1017 f.step,
1018 f.reasons.join("; ")
1019 );
1020 }
1021 eprintln!(
1022 "error: {} step(s) encode logic in CI YAML, where nothing tests it",
1023 findings.len()
1024 );
1025 Ok(1)
1026}
1027
1028fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
1031 let repo = std::env::current_dir()?;
1032 let attestation = e2e::attest(&repo, command)?;
1033 if attestation.exit_code != 0 {
1034 eprintln!(
1035 "e2e command `{command}` exited {}; a receipt records a run that passed — \
1036 fix the failure and attest again",
1037 attestation.exit_code
1038 );
1039 return Ok(attestation.exit_code);
1040 }
1041 println!(
1042 "e2e receipt recorded for branch {} at {}/{}.json",
1043 attestation.branch,
1044 e2e::RECEIPTS_DIR,
1045 e2e::branch_slug(&attestation.branch),
1046 );
1047 Ok(0)
1048}
1049
1050fn run_e2e_verify(
1054 path: &Path,
1055 scope: Option<&Path>,
1056 base: Option<&str>,
1057 extra_scopes: &[PathBuf],
1058 excludes: &[PathBuf],
1059 branch: Option<&str>,
1060) -> anyhow::Result<i32> {
1061 match e2e::verify_extra_scoped(
1062 path,
1063 scope.unwrap_or(path),
1064 base,
1065 extra_scopes,
1066 excludes,
1067 branch,
1068 )? {
1069 e2e::Verification::Fresh => Ok(0),
1070 e2e::Verification::Missing => {
1071 eprintln!(
1072 "no e2e receipt answers this change — run \
1073 `testing-conventions e2e attest '<your e2e command>'`; the command is \
1074 your judgment: the full suite, a targeted subset, or a no-op"
1075 );
1076 Ok(1)
1077 }
1078 }
1079}
1080
1081fn run_e2e_slug(branch: Option<&str>) -> anyhow::Result<i32> {
1083 let slug = match branch {
1084 Some(name) => e2e::branch_slug(name),
1085 None => {
1086 let repo = std::env::current_dir()?;
1087 e2e::branch_slug(&e2e::current_branch(&repo)?)
1088 }
1089 };
1090 println!("{slug}");
1091 Ok(0)
1092}
1093
1094#[cfg(test)]
1095mod tests {
1096 use super::*;
1097
1098 #[test]
1099 fn no_args_returns_ok_zero() {
1100 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
1101 }
1102
1103 #[test]
1104 fn unknown_flag_errors() {
1105 assert!(run(["testing-conventions", "--bogus"]).is_err());
1106 }
1107
1108 #[test]
1109 fn split_scopes_separates_whole_file_paths_from_line_sets() {
1110 let mut scopes = std::collections::BTreeMap::new();
1111 scopes.insert("shim.py".to_string(), config::LineScope::WholeFile);
1112 scopes.insert(
1113 "widget.py".to_string(),
1114 config::LineScope::Lines(std::collections::BTreeSet::from([3])),
1115 );
1116 let (whole_file, line_scoped) = split_scopes(scopes);
1117 assert_eq!(whole_file, vec!["shim.py".to_string()]);
1118 assert_eq!(line_scoped.len(), 1);
1119 assert_eq!(
1120 line_scoped["widget.py"],
1121 std::collections::BTreeSet::from([3])
1122 );
1123 }
1124
1125 fn python_exemptions(config: &config::Config) -> &[config::Exemption] {
1126 config.exemptions(colocated_test::Language::Python)
1127 }
1128
1129 #[test]
1130 fn a_violation_with_an_unwaivable_rule_id_is_kept() {
1131 let dir = std::env::temp_dir().join(format!("tc-lib-waiver-{}", std::process::id()));
1132 std::fs::create_dir_all(&dir).unwrap();
1133 let config_path = dir.join("testing-conventions.toml");
1134 std::fs::write(&config_path, "").unwrap();
1135 let violation = lint::Violation {
1136 file: dir.join("widget_test.py"),
1137 line: 1,
1138 rule: "not-a-waivable-rule",
1139 message: "synthetic".to_string(),
1140 };
1141 let kept = apply_waivers(
1142 vec![violation.clone()],
1143 &dir,
1144 &config_path,
1145 python_exemptions,
1146 );
1147 let _ = std::fs::remove_dir_all(&dir);
1148 assert_eq!(kept.unwrap(), vec![violation]);
1149 }
1150
1151 #[test]
1152 fn a_missing_config_keeps_every_violation() {
1153 let violation = lint::Violation {
1154 file: PathBuf::from("/tree/widget_test.py"),
1155 line: 1,
1156 rule: "no-monkeypatch",
1157 message: "synthetic".to_string(),
1158 };
1159 let kept = apply_waivers(
1160 vec![violation.clone()],
1161 Path::new("/tree"),
1162 Path::new("/nonexistent-tc-lib.toml"),
1163 python_exemptions,
1164 );
1165 assert_eq!(kept.unwrap(), vec![violation]);
1166 }
1167
1168 #[test]
1169 fn waivers_resolve_each_rule_once_and_keep_out_of_root_files() {
1170 let dir = std::env::temp_dir().join(format!("tc-lib-waiver-full-{}", std::process::id()));
1171 std::fs::create_dir_all(&dir).unwrap();
1172 std::fs::write(dir.join("widget_test.py"), "def test_widget():\n pass\n").unwrap();
1173 let config_path = dir.join("testing-conventions.toml");
1174 std::fs::write(
1175 &config_path,
1176 "[[python.exempt]]\n\
1177 path = \"widget_test.py\"\n\
1178 rules = [\"no-monkeypatch\"]\n\
1179 reason = \"synthetic waiver for the resolution paths\"\n",
1180 )
1181 .unwrap();
1182 let violation = |file: PathBuf| lint::Violation {
1183 file,
1184 line: 1,
1185 rule: "no-monkeypatch",
1186 message: "synthetic".to_string(),
1187 };
1188 let waived = violation(dir.join("widget_test.py"));
1189 let kept_in_root = violation(dir.join("other_test.py"));
1190 let outside_root = violation(PathBuf::from("/elsewhere/widget_test.py"));
1191 let kept = apply_waivers(
1192 vec![waived, kept_in_root.clone(), outside_root.clone()],
1193 &dir,
1194 &config_path,
1195 python_exemptions,
1196 );
1197 let _ = std::fs::remove_dir_all(&dir);
1198 assert_eq!(kept.unwrap(), vec![kept_in_root, outside_root]);
1199 }
1200
1201 #[test]
1202 fn help_flag_returns_clap_display_help() {
1203 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
1204 let clap_err = err
1205 .downcast_ref::<clap::Error>()
1206 .expect("error should be a clap::Error");
1207 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
1208 }
1209
1210 #[test]
1211 fn version_flag_returns_clap_display_version() {
1212 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
1213 let clap_err = err
1214 .downcast_ref::<clap::Error>()
1215 .expect("error should be a clap::Error");
1216 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
1217 }
1218}