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