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(
757 root: &Path,
758 language: isolation::Language,
759 config_path: &Path,
760) -> anyhow::Result<i32> {
761 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
762 isolation::Language::Rust => (isolation::find_violations(root)?, |c| c.rust_exemptions()),
763 isolation::Language::TypeScript => (ts::find_unit_violations(root)?, |c| {
764 c.exemptions(colocated_test::Language::TypeScript)
765 }),
766 isolation::Language::Python => (lint::find_unit_isolation_violations(root)?, |c| {
767 c.exemptions(colocated_test::Language::Python)
768 }),
769 };
770 let violations = apply_waivers(raw, root, config_path, select)?;
771 if violations.is_empty() {
772 return Ok(0);
773 }
774 for v in &violations {
775 eprintln!(
776 "{}:{}: {} — {}",
777 v.file.display(),
778 v.line,
779 v.rule,
780 v.message
781 );
782 }
783 eprintln!("error: {} isolation violation(s)", violations.len());
784 Ok(1)
785}
786
787fn run_integration_lint(
790 root: &Path,
791 language: IntegrationLintLanguage,
792 config_path: &Path,
793) -> anyhow::Result<i32> {
794 let manifest = match language {
795 IntegrationLintLanguage::Python => "pyproject.toml",
796 IntegrationLintLanguage::TypeScript => "package.json",
797 IntegrationLintLanguage::Rust => "Cargo.toml",
798 };
799 let package_root = tiers::package_root(root, manifest);
800 let scan_root = package_root.as_deref().unwrap_or(root);
801 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
802 IntegrationLintLanguage::Python => (
803 match &package_root {
804 Some(package_root) => lint::find_suite_violations(package_root)?,
805 None => lint::find_violations(root)?,
806 },
807 |c| c.exemptions(colocated_test::Language::Python),
808 ),
809 IntegrationLintLanguage::TypeScript => (
810 match &package_root {
811 Some(package_root) => ts::find_suite_violations(package_root)?,
812 None => ts::find_integration_violations(root)?,
813 },
814 |c| c.exemptions(colocated_test::Language::TypeScript),
815 ),
816 IntegrationLintLanguage::Rust => {
817 (isolation::find_integration_violations(scan_root)?, |c| {
818 c.rust_exemptions()
819 })
820 }
821 };
822 let violations = apply_waivers(raw, scan_root, config_path, select)?;
823 if violations.is_empty() {
824 return Ok(0);
825 }
826 for v in &violations {
827 eprintln!(
828 "{}:{}: {} — {}",
829 v.file.display(),
830 v.line,
831 v.rule,
832 v.message
833 );
834 }
835 eprintln!("error: {} lint violation(s)", violations.len());
836 Ok(1)
837}
838
839type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
841
842fn apply_waivers(
844 violations: Vec<lint::Violation>,
845 root: &Path,
846 config_path: &Path,
847 exemptions: ExemptSelect,
848) -> anyhow::Result<Vec<lint::Violation>> {
849 use std::collections::hash_map::Entry;
850
851 if !config_path.exists() {
852 return Ok(violations);
853 }
854 let config = config::load_config(config_path)?;
855 let exempt = exemptions(&config);
856 let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
857 std::collections::HashMap::new();
858 let mut kept = Vec::new();
859 for violation in violations {
860 let waived = match config::Rule::from_id(violation.rule) {
861 Some(rule) => {
862 let exempt_paths = match resolved.entry(rule) {
863 Entry::Occupied(entry) => entry.into_mut(),
864 Entry::Vacant(entry) => {
865 entry.insert(config::resolve_exempt(root, exempt, rule)?)
866 }
867 };
868 violation
869 .file
870 .strip_prefix(root)
871 .ok()
872 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
873 .is_some_and(|rel| exempt_paths.contains(&rel))
874 }
875 None => false,
876 };
877 if !waived {
878 kept.push(violation);
879 }
880 }
881 Ok(kept)
882}
883
884fn run_changelog(base: &str, root: &Path) -> anyhow::Result<i32> {
887 let Some(layout) = changelog::discover_layout(root) else {
888 println!(
889 "No fragment directories under `{}`; changelog check skipped.",
890 root.display()
891 );
892 return Ok(0);
893 };
894 if changelog::has_skip_line(&changelog::commit_bodies(root, base)?) {
895 println!("A `skip-changelog:` line is present; changelog check bypassed.");
896 return Ok(0);
897 }
898 let changed = changelog::changed_files(root, base)?;
899 let added = changelog::added_files(root, base)?;
900 let migrations = changelog::migrations_enforced(root);
901 let found = changelog::findings(&layout, migrations, &changed, &added);
902 if found.is_empty() {
903 println!("Every scope that changed public surface added its fragments.");
904 return Ok(0);
905 }
906 for finding in &found {
907 match &finding.file {
908 Some(file) => println!("::error file={file}::{}", finding.message),
909 None => println!("::error::{}", finding.message),
910 }
911 }
912 Ok(1)
913}
914
915fn run_packaging(path: &Path, language: Option<colocated_test::Language>) -> anyhow::Result<i32> {
919 let distributions = match language {
920 Some(language) => vec![packaging::Distribution {
921 path: path.to_path_buf(),
922 language,
923 }],
924 None => packaging::discover(path)?,
925 };
926 if distributions.is_empty() {
927 anyhow::bail!(
928 "no recognized built distribution (`.whl`, `.tar.gz`, `.tgz`, `.crate`) at `{}`",
929 path.display()
930 );
931 }
932 let mut shipped = 0;
933 for distribution in &distributions {
934 shipped += report_shipped_test_files(distribution)?;
935 }
936 if shipped > 0 {
937 eprintln!(
938 "error: {shipped} test file(s) present in the built distribution(s) \
939 (they must be excluded from packaging)"
940 );
941 return Ok(1);
942 }
943 println!(
944 "checked {} built distribution(s); no test files shipped",
945 distributions.len()
946 );
947 Ok(0)
948}
949
950fn report_shipped_test_files(distribution: &packaging::Distribution) -> anyhow::Result<usize> {
952 let globs = match distribution.language {
953 colocated_test::Language::Python => vec!["*_test.py".to_string()],
954 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
955 colocated_test::Language::Rust => vec!["tests/".to_string()],
957 };
958 let offenders = packaging::inspect(&distribution.path, &globs)?;
959 for offender in &offenders {
960 eprintln!(
961 "test file in built artifact `{}`: {}",
962 distribution.path.display(),
963 offender.display()
964 );
965 }
966 Ok(offenders.len())
967}
968
969fn run_workflow(path: &Path) -> anyhow::Result<i32> {
972 let violations = workflow::check(path, &command())?;
973 if violations.is_empty() {
974 return Ok(0);
975 }
976 for v in &violations {
977 eprintln!(
978 "{}:{}: {} — {}",
979 v.file.display(),
980 v.line,
981 v.rule,
982 v.message
983 );
984 }
985 eprintln!(
986 "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
987 violations.len()
988 );
989 Ok(1)
990}
991
992fn run_workflow_lint(path: &Path) -> anyhow::Result<i32> {
993 let findings = workflow_lint::scan(path)?;
994 if findings.is_empty() {
995 return Ok(0);
996 }
997 for f in &findings {
998 eprintln!(
999 "{}:{}: {} step `{}` encodes logic inline ({}) — move it into a tested package in \
1000 this repository's own language, invoked as a one-line `run:`",
1001 f.file.display(),
1002 f.line,
1003 f.kind,
1004 f.step,
1005 f.reasons.join("; ")
1006 );
1007 }
1008 eprintln!(
1009 "error: {} step(s) encode logic in CI YAML, where nothing tests it",
1010 findings.len()
1011 );
1012 Ok(1)
1013}
1014
1015fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
1018 let repo = std::env::current_dir()?;
1019 let attestation = e2e::attest(&repo, command)?;
1020 if attestation.exit_code != 0 {
1021 eprintln!(
1022 "e2e command `{command}` exited {}; a receipt records a run that passed — \
1023 fix the failure and attest again",
1024 attestation.exit_code
1025 );
1026 return Ok(attestation.exit_code);
1027 }
1028 println!(
1029 "e2e receipt recorded for branch {} at {}/{}.json",
1030 attestation.branch,
1031 e2e::RECEIPTS_DIR,
1032 e2e::branch_slug(&attestation.branch),
1033 );
1034 Ok(0)
1035}
1036
1037fn run_e2e_verify(
1041 path: &Path,
1042 scope: Option<&Path>,
1043 base: Option<&str>,
1044 extra_scopes: &[PathBuf],
1045 excludes: &[PathBuf],
1046 branch: Option<&str>,
1047) -> anyhow::Result<i32> {
1048 match e2e::verify_extra_scoped(
1049 path,
1050 scope.unwrap_or(path),
1051 base,
1052 extra_scopes,
1053 excludes,
1054 branch,
1055 )? {
1056 e2e::Verification::Fresh => Ok(0),
1057 e2e::Verification::Missing => {
1058 eprintln!(
1059 "no e2e receipt answers this change — run \
1060 `testing-conventions e2e attest '<your e2e command>'`; the command is \
1061 your judgment: the full suite, a targeted subset, or a no-op"
1062 );
1063 Ok(1)
1064 }
1065 }
1066}
1067
1068fn run_e2e_slug(branch: Option<&str>) -> anyhow::Result<i32> {
1070 let slug = match branch {
1071 Some(name) => e2e::branch_slug(name),
1072 None => {
1073 let repo = std::env::current_dir()?;
1074 e2e::branch_slug(&e2e::current_branch(&repo)?)
1075 }
1076 };
1077 println!("{slug}");
1078 Ok(0)
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083 use super::*;
1084
1085 #[test]
1086 fn no_args_returns_ok_zero() {
1087 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
1088 }
1089
1090 #[test]
1091 fn unknown_flag_errors() {
1092 assert!(run(["testing-conventions", "--bogus"]).is_err());
1093 }
1094
1095 #[test]
1096 fn split_scopes_separates_whole_file_paths_from_line_sets() {
1097 let mut scopes = std::collections::BTreeMap::new();
1098 scopes.insert("shim.py".to_string(), config::LineScope::WholeFile);
1099 scopes.insert(
1100 "widget.py".to_string(),
1101 config::LineScope::Lines(std::collections::BTreeSet::from([3])),
1102 );
1103 let (whole_file, line_scoped) = split_scopes(scopes);
1104 assert_eq!(whole_file, vec!["shim.py".to_string()]);
1105 assert_eq!(line_scoped.len(), 1);
1106 assert_eq!(
1107 line_scoped["widget.py"],
1108 std::collections::BTreeSet::from([3])
1109 );
1110 }
1111
1112 fn python_exemptions(config: &config::Config) -> &[config::Exemption] {
1113 config.exemptions(colocated_test::Language::Python)
1114 }
1115
1116 #[test]
1117 fn a_violation_with_an_unwaivable_rule_id_is_kept() {
1118 let dir = std::env::temp_dir().join(format!("tc-lib-waiver-{}", std::process::id()));
1119 std::fs::create_dir_all(&dir).unwrap();
1120 let config_path = dir.join("testing-conventions.toml");
1121 std::fs::write(&config_path, "").unwrap();
1122 let violation = lint::Violation {
1123 file: dir.join("widget_test.py"),
1124 line: 1,
1125 rule: "not-a-waivable-rule",
1126 message: "synthetic".to_string(),
1127 };
1128 let kept = apply_waivers(
1129 vec![violation.clone()],
1130 &dir,
1131 &config_path,
1132 python_exemptions,
1133 );
1134 let _ = std::fs::remove_dir_all(&dir);
1135 assert_eq!(kept.unwrap(), vec![violation]);
1136 }
1137
1138 #[test]
1139 fn a_missing_config_keeps_every_violation() {
1140 let violation = lint::Violation {
1141 file: PathBuf::from("/tree/widget_test.py"),
1142 line: 1,
1143 rule: "no-monkeypatch",
1144 message: "synthetic".to_string(),
1145 };
1146 let kept = apply_waivers(
1147 vec![violation.clone()],
1148 Path::new("/tree"),
1149 Path::new("/nonexistent-tc-lib.toml"),
1150 python_exemptions,
1151 );
1152 assert_eq!(kept.unwrap(), vec![violation]);
1153 }
1154
1155 #[test]
1156 fn waivers_resolve_each_rule_once_and_keep_out_of_root_files() {
1157 let dir = std::env::temp_dir().join(format!("tc-lib-waiver-full-{}", std::process::id()));
1158 std::fs::create_dir_all(&dir).unwrap();
1159 std::fs::write(dir.join("widget_test.py"), "def test_widget():\n pass\n").unwrap();
1160 let config_path = dir.join("testing-conventions.toml");
1161 std::fs::write(
1162 &config_path,
1163 "[[python.exempt]]\n\
1164 path = \"widget_test.py\"\n\
1165 rules = [\"no-monkeypatch\"]\n\
1166 reason = \"synthetic waiver for the resolution paths\"\n",
1167 )
1168 .unwrap();
1169 let violation = |file: PathBuf| lint::Violation {
1170 file,
1171 line: 1,
1172 rule: "no-monkeypatch",
1173 message: "synthetic".to_string(),
1174 };
1175 let waived = violation(dir.join("widget_test.py"));
1176 let kept_in_root = violation(dir.join("other_test.py"));
1177 let outside_root = violation(PathBuf::from("/elsewhere/widget_test.py"));
1178 let kept = apply_waivers(
1179 vec![waived, kept_in_root.clone(), outside_root.clone()],
1180 &dir,
1181 &config_path,
1182 python_exemptions,
1183 );
1184 let _ = std::fs::remove_dir_all(&dir);
1185 assert_eq!(kept.unwrap(), vec![kept_in_root, outside_root]);
1186 }
1187
1188 #[test]
1189 fn help_flag_returns_clap_display_help() {
1190 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
1191 let clap_err = err
1192 .downcast_ref::<clap::Error>()
1193 .expect("error should be a clap::Error");
1194 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
1195 }
1196
1197 #[test]
1198 fn version_flag_returns_clap_display_version() {
1199 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
1200 let clap_err = err
1201 .downcast_ref::<clap::Error>()
1202 .expect("error should be a clap::Error");
1203 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
1204 }
1205}