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;
15mod subprocess_seam;
16pub mod tiers;
17pub mod ts;
18mod unit_coverage;
19pub mod violation;
20mod walk;
21pub mod workflow;
22pub mod workflow_lint;
23
24use std::path::{Path, PathBuf};
25
26use clap::{CommandFactory, Parser, Subcommand};
27
28#[derive(Parser, Debug)]
29#[command(
30 name = "testing-conventions",
31 version,
32 about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
33 long_about = None,
34)]
35pub struct Cli {
36 #[command(subcommand)]
37 command: Option<Command>,
38}
39
40#[derive(Subcommand, Debug)]
41enum Command {
42 Install {
47 #[arg(default_value = "AGENTS.md")]
49 path: PathBuf,
50 },
51 Unit {
53 #[command(subcommand)]
54 rule: UnitRule,
55 },
56 Integration {
58 #[command(subcommand)]
59 rule: IntegrationRule,
60 },
61 Packaging {
63 path: PathBuf,
66 #[arg(long, value_enum)]
69 language: Option<colocated_test::Language>,
70 },
71 #[command(hide = true)]
76 Workflow {
77 path: PathBuf,
79 },
80 E2e {
82 #[command(subcommand)]
83 command: E2eCommand,
84 },
85 WorkflowLint {
89 #[arg(default_value = ".github")]
91 path: PathBuf,
92 },
93 Changelog {
96 #[arg(long)]
98 base: String,
99 #[arg(default_value = ".")]
101 path: PathBuf,
102 },
103}
104
105#[derive(Subcommand, Debug)]
106enum UnitRule {
107 ColocatedTest {
113 path: PathBuf,
115 #[arg(long, value_enum)]
117 language: colocated_test::Language,
118 #[arg(long)]
124 base: Option<String>,
125 #[arg(long, default_value = "testing-conventions.toml")]
128 config: PathBuf,
129 },
130 Coverage {
135 path: PathBuf,
137 #[arg(long, value_enum)]
139 language: colocated_test::Language,
140 #[arg(long)]
146 base: Option<String>,
147 #[arg(long, default_value = "testing-conventions.toml")]
152 config: PathBuf,
153 },
154 OneFunctionPerFile {
158 path: PathBuf,
160 #[arg(long, value_enum)]
162 language: colocated_test::Language,
163 #[arg(long, default_value = "testing-conventions.toml")]
168 config: PathBuf,
169 },
170 Lint {
172 path: PathBuf,
174 #[arg(long, value_enum)]
176 language: isolation::Language,
177 #[arg(long, default_value = "testing-conventions.toml")]
180 config: PathBuf,
181 },
182 Mutation {
188 path: PathBuf,
190 #[arg(long, value_enum)]
192 language: colocated_test::Language,
193 #[arg(long)]
197 base: Option<String>,
198 #[arg(long, default_value = "testing-conventions.toml")]
201 config: PathBuf,
202 #[arg(long = "ts-mutation-adapter", hide = true)]
206 ts_adapter: Option<PathBuf>,
207 },
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
212pub enum IntegrationLintLanguage {
213 #[value(name = "python")]
215 Python,
216 #[value(name = "typescript")]
218 TypeScript,
219 #[value(name = "rust")]
221 Rust,
222}
223
224#[derive(Subcommand, Debug)]
225enum IntegrationRule {
226 Lint {
228 path: PathBuf,
230 #[arg(long, value_enum)]
232 language: IntegrationLintLanguage,
233 #[arg(long, default_value = "testing-conventions.toml")]
236 config: PathBuf,
237 },
238}
239
240#[derive(Subcommand, Debug)]
241enum E2eCommand {
242 Attest {
246 command: String,
248 },
249 Verify {
251 #[arg(default_value = ".")]
254 path: PathBuf,
255 #[arg(long)]
258 scope: Option<PathBuf>,
259 #[arg(long)]
266 base: Option<String>,
267 #[arg(long = "extra-scope")]
272 extra_scope: Vec<PathBuf>,
273 #[arg(long = "exclude")]
277 exclude: Vec<PathBuf>,
278 #[arg(long)]
281 branch: Option<String>,
282 },
283 Slug {
286 branch: Option<String>,
288 },
289}
290
291pub fn run<I, T>(args: I) -> anyhow::Result<i32>
292where
293 I: IntoIterator<Item = T>,
294 T: Into<std::ffi::OsString> + Clone,
295{
296 eprintln!("testing-conventions {}", env!("CARGO_PKG_VERSION"));
299 let cli = Cli::try_parse_from(args)?;
300 match cli.command {
301 None => Ok(0),
302 Some(Command::Unit { rule }) => match rule {
303 UnitRule::ColocatedTest {
304 path,
305 language,
306 base,
307 config,
308 } => run_unit_colocated_test(&path, language, base.as_deref(), &config),
309 UnitRule::Coverage {
310 path,
311 language,
312 base,
313 config,
314 } => unit_coverage::run(&path, language, base.as_deref(), &config),
315 UnitRule::OneFunctionPerFile {
316 path,
317 language,
318 config,
319 } => run_unit_one_function(&path, language, &config),
320 UnitRule::Lint {
321 path,
322 language,
323 config,
324 } => run_unit_lint(&path, language, &config),
325 UnitRule::Mutation {
326 path,
327 language,
328 base,
329 config,
330 ts_adapter,
331 } => run_unit_mutation(
332 &path,
333 language,
334 base.as_deref(),
335 &config,
336 ts_adapter.as_deref(),
337 ),
338 },
339 Some(Command::Integration { rule }) => match rule {
340 IntegrationRule::Lint {
341 path,
342 language,
343 config,
344 } => run_integration_lint(&path, language, &config),
345 },
346 Some(Command::Packaging { path, language }) => run_packaging(&path, language),
347 Some(Command::Changelog { base, path }) => run_changelog(&base, &path),
348 Some(Command::Workflow { path }) => run_workflow(&path),
349 Some(Command::WorkflowLint { path }) => run_workflow_lint(&path),
350 Some(Command::E2e { command }) => match command {
351 E2eCommand::Attest { command } => run_e2e_attest(&command),
352 E2eCommand::Verify {
353 path,
354 scope,
355 base,
356 extra_scope,
357 exclude,
358 branch,
359 } => run_e2e_verify(
360 &path,
361 scope.as_deref(),
362 base.as_deref(),
363 &extra_scope,
364 &exclude,
365 branch.as_deref(),
366 ),
367 E2eCommand::Slug { branch } => run_e2e_slug(branch.as_deref()),
368 },
369 Some(Command::Install { path }) => {
370 agents::install(&path)?;
371 Ok(0)
372 }
373 }
374}
375
376pub fn command() -> clap::Command {
378 Cli::command()
379}
380
381fn run_unit_colocated_test(
384 root: &Path,
385 language: colocated_test::Language,
386 base: Option<&str>,
387 config_path: &Path,
388) -> anyhow::Result<i32> {
389 if base.is_some() && language == colocated_test::Language::Rust {
390 anyhow::bail!(
391 "`unit colocated-test --base` supports `--language python` / `typescript`; Rust \
392 units are inline `#[cfg(test)]` in the same file, so a sibling test can't go stale"
393 );
394 }
395 let presence_clean = report_colocated_presence(root, language, config_path)?;
396 let co_change_clean = match base {
397 Some(base) => report_co_change(root, base, language, config_path)?,
398 None => true,
399 };
400 Ok(if presence_clean && co_change_clean {
401 0
402 } else {
403 1
404 })
405}
406
407fn report_colocated_presence(
410 root: &Path,
411 language: colocated_test::Language,
412 config_path: &Path,
413) -> anyhow::Result<bool> {
414 let exempt = colocated_test_exemptions(root, language, config_path)?;
415 let orphans = match language {
416 colocated_test::Language::Rust => colocated_test::missing_inline_tests(root, &exempt)?,
417 _ => colocated_test::missing_unit_tests(root, language, &exempt)?,
418 };
419 if orphans.is_empty() {
420 return Ok(true);
421 }
422 let (label, summary) = match language {
423 colocated_test::Language::Rust => (
424 "missing inline `#[cfg(test)]` tests",
425 "source file(s) with testable code but no inline `#[cfg(test)]` module \
426 (add an inline test module, or an `exempt` entry with a reason)",
427 ),
428 _ => (
429 "missing colocated unit test",
430 "source file(s) missing a colocated unit test \
431 (add a colocated test, or an `exempt` entry with a reason)",
432 ),
433 };
434 for orphan in &orphans {
435 eprintln!("{label}: {}", orphan.display());
436 }
437 eprintln!("error: {} {summary}", orphans.len());
438 Ok(false)
439}
440
441fn colocated_test_exemptions(
443 root: &Path,
444 language: colocated_test::Language,
445 config_path: &Path,
446) -> anyhow::Result<std::collections::BTreeSet<String>> {
447 if !config_path.exists() {
448 return Ok(std::collections::BTreeSet::new());
449 }
450 let config = config::load_config(config_path)?;
451 config::resolve_exempt(
452 root,
453 config.exemptions(language),
454 config::Rule::ColocatedTest,
455 )
456}
457
458fn report_co_change(
461 root: &Path,
462 base: &str,
463 language: colocated_test::Language,
464 config_path: &Path,
465) -> anyhow::Result<bool> {
466 let exempt = co_change_exemptions(root, language, config_path)?;
467 let stale = co_change::stale_sources(root, base, language, &exempt)?;
468 if stale.is_empty() {
469 return Ok(true);
470 }
471 for source in &stale {
472 eprintln!(
473 "source changed without its colocated test: {}",
474 source.display()
475 );
476 }
477 eprintln!(
478 "error: {} source file(s) changed without their colocated test co-changing \
479 (update the test, or add an `exempt` entry with a reason)",
480 stale.len()
481 );
482 Ok(false)
483}
484
485fn co_change_exemptions(
487 root: &Path,
488 language: colocated_test::Language,
489 config_path: &Path,
490) -> anyhow::Result<std::collections::BTreeSet<String>> {
491 if !config_path.exists() {
492 return Ok(std::collections::BTreeSet::new());
493 }
494 let config = config::load_config(config_path)?;
495 config::resolve_exempt(root, config.exemptions(language), config::Rule::CoChange)
496}
497
498fn split_scopes(
500 scopes: std::collections::BTreeMap<String, config::LineScope>,
501) -> (
502 Vec<String>,
503 std::collections::BTreeMap<String, std::collections::BTreeSet<u32>>,
504) {
505 let mut whole_file = Vec::new();
506 let mut line_scoped = std::collections::BTreeMap::new();
507 for (path, scope) in scopes {
508 match scope {
509 config::LineScope::WholeFile => whole_file.push(path),
510 config::LineScope::Lines(lines) => {
511 line_scoped.insert(path, lines);
512 }
513 }
514 }
515 (whole_file, line_scoped)
516}
517
518fn run_unit_mutation(
521 root: &Path,
522 language: colocated_test::Language,
523 base: Option<&str>,
524 config_path: &Path,
525 ts_adapter: Option<&Path>,
526) -> anyhow::Result<i32> {
527 let config = if config_path.exists() {
528 config::load_config(config_path)?
529 } else {
530 config::Config::default()
531 };
532 let measurement = match language {
533 colocated_test::Language::Rust => {
534 let rust = config.rust.unwrap_or_default();
535 let scopes = config::resolve_exempt_scoped(root, &rust.exempt, config::Rule::Mutation)?;
536 let (exempt, exempt_lines) = split_scopes(scopes);
537 mutation::measure_rust(root, &exempt, &exempt_lines, base, &rust.features)?
538 }
539 colocated_test::Language::TypeScript => {
540 let typescript = config.typescript.unwrap_or_default();
541 let scopes =
542 config::resolve_exempt_scoped(root, &typescript.exempt, config::Rule::Mutation)?;
543 let (exempt, exempt_lines) = split_scopes(scopes);
544 let adapter = ts_adapter.ok_or_else(|| {
545 anyhow::anyhow!(
546 "the TypeScript mutation adapter path is required: pass \
547 `--ts-mutation-adapter <path>`. The npm `testing-conventions` CLI appends it \
548 automatically — run the check through that CLI, not the raw binary."
549 )
550 })?;
551 mutation::measure_typescript(root, &exempt, &exempt_lines, base, adapter)?
552 }
553 colocated_test::Language::Python => {
554 let python = config.python.unwrap_or_default();
555 let scopes =
556 config::resolve_exempt_scoped(root, &python.exempt, config::Rule::Mutation)?;
557 let (exempt, exempt_lines) = split_scopes(scopes);
558 mutation::measure_python(root, &exempt, &exempt_lines, base)?
559 }
560 };
561 let (count, survivors) = match measurement {
562 mutation::Measurement::EngineNotRun => {
563 println!("unit mutation: no mutatable changed lines — engine not run");
564 return Ok(0);
565 }
566 mutation::Measurement::Tested { count, survivors } => (count, survivors),
567 };
568 if survivors.is_empty() {
569 if count == 0 {
570 println!("unit mutation: the engine found no mutants to test");
571 } else {
572 println!(
573 "unit mutation: no surviving mutants — every mutation was caught \
574 ({count} mutant(s) tested)"
575 );
576 }
577 return Ok(0);
578 }
579
580 eprintln!(
581 "error: {} unexplained surviving mutant(s) — kill each with an assertion, or lift an \
582 equivalent/defensive one with a reason-required `[[<language>.exempt]] rules = [\"mutation\"]`:",
583 survivors.len()
584 );
585 for survivor in &survivors {
586 eprintln!(
587 " {}:{}: {}",
588 survivor.file, survivor.line, survivor.description
589 );
590 }
591 Ok(1)
592}
593
594fn run_unit_one_function(
597 root: &Path,
598 language: colocated_test::Language,
599 config_path: &Path,
600) -> anyhow::Result<i32> {
601 let threshold = if config_path.exists() {
602 config::load_config(config_path)?.one_function_threshold(language)
603 } else {
604 config::Config::default().one_function_threshold(language)
605 };
606 let key = match language {
607 colocated_test::Language::Python => "python",
608 colocated_test::Language::TypeScript => "typescript",
609 colocated_test::Language::Rust => "rust",
610 };
611 let Some(max_lines) = threshold else {
612 println!(
613 "unit one-function-per-file: not enabled for {key} — \
614 set `[{key}].one_function_per_file` to opt in"
615 );
616 return Ok(0);
617 };
618 let (raw, scanned) = one_function::find_violations(root, language, max_lines)?;
619 let select: ExemptSelect = match language {
620 colocated_test::Language::Python => |c| c.exemptions(colocated_test::Language::Python),
621 colocated_test::Language::TypeScript => {
622 |c| c.exemptions(colocated_test::Language::TypeScript)
623 }
624 colocated_test::Language::Rust => |c| c.rust_exemptions(),
625 };
626 let violations = apply_waivers(raw, root, config_path, select)?;
627 if violations.is_empty() {
628 eprintln!("one-function-per-file: scanned {scanned} file(s), 0 violations");
629 return Ok(0);
630 }
631 for v in &violations {
632 eprintln!(
633 "{}:{}: {} — {}",
634 v.file.display(),
635 v.line,
636 v.rule,
637 v.message
638 );
639 }
640 eprintln!(
641 "error: {} function(s) sharing a file with another function over the \
642 {max_lines}-line threshold (move each to its own module, or add an \
643 `exempt` entry with a reason)",
644 violations.len()
645 );
646 Ok(1)
647}
648
649fn run_unit_lint(
653 root: &Path,
654 language: isolation::Language,
655 config_path: &Path,
656) -> anyhow::Result<i32> {
657 let crate_root = tiers::package_root(root, "Cargo.toml");
658 let (raw, select, waiver_root): (Vec<lint::Violation>, ExemptSelect, &Path) = match language {
659 isolation::Language::Rust => {
660 let crate_root = crate_root.as_deref().unwrap_or(root);
661 (
662 isolation::find_violations(root, crate_root)?,
663 |c| c.rust_exemptions(),
664 crate_root,
665 )
666 }
667 isolation::Language::TypeScript => (
668 ts::find_unit_violations(root)?,
669 |c| c.exemptions(colocated_test::Language::TypeScript),
670 root,
671 ),
672 isolation::Language::Python => (
673 lint::find_unit_isolation_violations(root)?,
674 |c| c.exemptions(colocated_test::Language::Python),
675 root,
676 ),
677 };
678 let violations = apply_waivers(raw, waiver_root, config_path, select)?;
679 if violations.is_empty() {
680 return Ok(0);
681 }
682 for v in &violations {
683 eprintln!(
684 "{}:{}: {} — {}",
685 v.file.display(),
686 v.line,
687 v.rule,
688 v.message
689 );
690 }
691 eprintln!("error: {} isolation violation(s)", violations.len());
692 Ok(1)
693}
694
695fn run_integration_lint(
698 root: &Path,
699 language: IntegrationLintLanguage,
700 config_path: &Path,
701) -> anyhow::Result<i32> {
702 let manifest = match language {
703 IntegrationLintLanguage::Python => "pyproject.toml",
704 IntegrationLintLanguage::TypeScript => "package.json",
705 IntegrationLintLanguage::Rust => "Cargo.toml",
706 };
707 let package_root = tiers::package_root(root, manifest);
708 let scan_root = package_root.as_deref().unwrap_or(root);
709 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
710 IntegrationLintLanguage::Python => (
711 match &package_root {
712 Some(package_root) => lint::find_suite_violations(package_root)?,
713 None => lint::find_violations(root)?,
714 },
715 |c| c.exemptions(colocated_test::Language::Python),
716 ),
717 IntegrationLintLanguage::TypeScript => (
718 match &package_root {
719 Some(package_root) => ts::find_suite_violations(package_root)?,
720 None => ts::find_integration_violations(root)?,
721 },
722 |c| c.exemptions(colocated_test::Language::TypeScript),
723 ),
724 IntegrationLintLanguage::Rust => {
725 (isolation::find_integration_violations(scan_root)?, |c| {
726 c.rust_exemptions()
727 })
728 }
729 };
730 let violations = apply_waivers(raw, scan_root, config_path, select)?;
731 if violations.is_empty() {
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!("error: {} lint violation(s)", violations.len());
744 Ok(1)
745}
746
747type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
749
750fn apply_waivers(
752 violations: Vec<lint::Violation>,
753 root: &Path,
754 config_path: &Path,
755 exemptions: ExemptSelect,
756) -> anyhow::Result<Vec<lint::Violation>> {
757 use std::collections::hash_map::Entry;
758
759 if !config_path.exists() {
760 return Ok(violations);
761 }
762 let config = config::load_config(config_path)?;
763 let exempt = exemptions(&config);
764 let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
765 std::collections::HashMap::new();
766 let mut kept = Vec::new();
767 for violation in violations {
768 let waived = match config::Rule::from_id(violation.rule) {
769 Some(rule) => {
770 let exempt_paths = match resolved.entry(rule) {
771 Entry::Occupied(entry) => entry.into_mut(),
772 Entry::Vacant(entry) => {
773 entry.insert(config::resolve_exempt(root, exempt, rule)?)
774 }
775 };
776 violation
777 .file
778 .strip_prefix(root)
779 .ok()
780 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
781 .is_some_and(|rel| exempt_paths.contains(&rel))
782 }
783 None => false,
784 };
785 if !waived {
786 kept.push(violation);
787 }
788 }
789 Ok(kept)
790}
791
792fn run_changelog(base: &str, root: &Path) -> anyhow::Result<i32> {
795 let Some(layout) = changelog::discover_layout(root) else {
796 println!(
797 "No fragment directories under `{}`; changelog check skipped.",
798 root.display()
799 );
800 return Ok(0);
801 };
802 if changelog::has_skip_line(&changelog::commit_bodies(root, base)?) {
803 println!("A `skip-changelog:` line is present; changelog check bypassed.");
804 return Ok(0);
805 }
806 let changed = changelog::changed_files(root, base)?;
807 let added = changelog::added_files(root, base)?;
808 let migrations = changelog::migrations_enforced(root);
809 let found = changelog::findings(&layout, migrations, &changed, &added);
810 if found.is_empty() {
811 println!("Every scope that changed public surface added its fragments.");
812 return Ok(0);
813 }
814 for finding in &found {
815 match &finding.file {
816 Some(file) => println!("::error file={file}::{}", finding.message),
817 None => println!("::error::{}", finding.message),
818 }
819 }
820 Ok(1)
821}
822
823fn run_packaging(path: &Path, language: Option<colocated_test::Language>) -> anyhow::Result<i32> {
827 let distributions = match language {
828 Some(language) => vec![packaging::Distribution {
829 path: path.to_path_buf(),
830 language,
831 }],
832 None => packaging::discover(path)?,
833 };
834 if distributions.is_empty() {
835 anyhow::bail!(
836 "no recognized built distribution (`.whl`, `.tar.gz`, `.tgz`, `.crate`) at `{}`",
837 path.display()
838 );
839 }
840 let mut shipped = 0;
841 for distribution in &distributions {
842 shipped += report_shipped_test_files(distribution)?;
843 }
844 if shipped > 0 {
845 eprintln!(
846 "error: {shipped} test file(s) present in the built distribution(s) \
847 (they must be excluded from packaging)"
848 );
849 return Ok(1);
850 }
851 println!(
852 "checked {} built distribution(s); no test files shipped",
853 distributions.len()
854 );
855 Ok(0)
856}
857
858fn report_shipped_test_files(distribution: &packaging::Distribution) -> anyhow::Result<usize> {
860 let globs = match distribution.language {
861 colocated_test::Language::Python => vec!["*_test.py".to_string()],
862 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
863 colocated_test::Language::Rust => vec!["tests/".to_string()],
865 };
866 let offenders = packaging::inspect(&distribution.path, &globs)?;
867 for offender in &offenders {
868 eprintln!(
869 "test file in built artifact `{}`: {}",
870 distribution.path.display(),
871 offender.display()
872 );
873 }
874 Ok(offenders.len())
875}
876
877fn run_workflow(path: &Path) -> anyhow::Result<i32> {
880 let violations = workflow::check(path, &command())?;
881 if violations.is_empty() {
882 return Ok(0);
883 }
884 for v in &violations {
885 eprintln!(
886 "{}:{}: {} — {}",
887 v.file.display(),
888 v.line,
889 v.rule,
890 v.message
891 );
892 }
893 eprintln!(
894 "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
895 violations.len()
896 );
897 Ok(1)
898}
899
900fn run_workflow_lint(path: &Path) -> anyhow::Result<i32> {
901 let findings = workflow_lint::scan(path)?;
902 if findings.is_empty() {
903 return Ok(0);
904 }
905 for f in &findings {
906 eprintln!(
907 "{}:{}: {} step `{}` encodes logic inline ({}) — move it into a tested package in \
908 this repository's own language, invoked as a one-line `run:`",
909 f.file.display(),
910 f.line,
911 f.kind,
912 f.step,
913 f.reasons.join("; ")
914 );
915 }
916 eprintln!(
917 "error: {} step(s) encode logic in CI YAML, where nothing tests it",
918 findings.len()
919 );
920 Ok(1)
921}
922
923fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
926 let repo = std::env::current_dir()?;
927 let attestation = e2e::attest(&repo, command)?;
928 if attestation.exit_code != 0 {
929 eprintln!(
930 "e2e command `{command}` exited {}; a receipt records a run that passed — \
931 fix the failure and attest again",
932 attestation.exit_code
933 );
934 return Ok(attestation.exit_code);
935 }
936 println!(
937 "e2e receipt recorded for branch {} at {}/{}.json",
938 attestation.branch,
939 e2e::RECEIPTS_DIR,
940 e2e::branch_slug(&attestation.branch),
941 );
942 Ok(0)
943}
944
945fn run_e2e_verify(
949 path: &Path,
950 scope: Option<&Path>,
951 base: Option<&str>,
952 extra_scopes: &[PathBuf],
953 excludes: &[PathBuf],
954 branch: Option<&str>,
955) -> anyhow::Result<i32> {
956 match e2e::verify_extra_scoped(
957 path,
958 scope.unwrap_or(path),
959 base,
960 extra_scopes,
961 excludes,
962 branch,
963 )? {
964 e2e::Verification::Fresh => Ok(0),
965 e2e::Verification::Missing => {
966 eprintln!(
967 "no e2e receipt answers this change — run \
968 `testing-conventions e2e attest '<your e2e command>'`; the command is \
969 your judgment: the full suite, a targeted subset, or a no-op"
970 );
971 Ok(1)
972 }
973 }
974}
975
976fn run_e2e_slug(branch: Option<&str>) -> anyhow::Result<i32> {
978 let slug = match branch {
979 Some(name) => e2e::branch_slug(name),
980 None => {
981 let repo = std::env::current_dir()?;
982 e2e::branch_slug(&e2e::current_branch(&repo)?)
983 }
984 };
985 println!("{slug}");
986 Ok(0)
987}
988
989#[cfg(test)]
990mod tests {
991 use super::*;
992
993 #[test]
994 fn no_args_returns_ok_zero() {
995 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
996 }
997
998 #[test]
999 fn unknown_flag_errors() {
1000 assert!(run(["testing-conventions", "--bogus"]).is_err());
1001 }
1002
1003 #[test]
1004 fn split_scopes_separates_whole_file_paths_from_line_sets() {
1005 let mut scopes = std::collections::BTreeMap::new();
1006 scopes.insert("shim.py".to_string(), config::LineScope::WholeFile);
1007 scopes.insert(
1008 "widget.py".to_string(),
1009 config::LineScope::Lines(std::collections::BTreeSet::from([3])),
1010 );
1011 let (whole_file, line_scoped) = split_scopes(scopes);
1012 assert_eq!(whole_file, vec!["shim.py".to_string()]);
1013 assert_eq!(line_scoped.len(), 1);
1014 assert_eq!(
1015 line_scoped["widget.py"],
1016 std::collections::BTreeSet::from([3])
1017 );
1018 }
1019
1020 fn python_exemptions(config: &config::Config) -> &[config::Exemption] {
1021 config.exemptions(colocated_test::Language::Python)
1022 }
1023
1024 #[test]
1025 fn a_violation_with_an_unwaivable_rule_id_is_kept() {
1026 let dir = std::env::temp_dir().join(format!("tc-lib-waiver-{}", std::process::id()));
1027 std::fs::create_dir_all(&dir).unwrap();
1028 let config_path = dir.join("testing-conventions.toml");
1029 std::fs::write(&config_path, "").unwrap();
1030 let violation = lint::Violation {
1031 file: dir.join("widget_test.py"),
1032 line: 1,
1033 rule: "not-a-waivable-rule",
1034 message: "synthetic".to_string(),
1035 };
1036 let kept = apply_waivers(
1037 vec![violation.clone()],
1038 &dir,
1039 &config_path,
1040 python_exemptions,
1041 );
1042 let _ = std::fs::remove_dir_all(&dir);
1043 assert_eq!(kept.unwrap(), vec![violation]);
1044 }
1045
1046 #[test]
1047 fn a_missing_config_keeps_every_violation() {
1048 let violation = lint::Violation {
1049 file: PathBuf::from("/tree/widget_test.py"),
1050 line: 1,
1051 rule: "no-monkeypatch",
1052 message: "synthetic".to_string(),
1053 };
1054 let kept = apply_waivers(
1055 vec![violation.clone()],
1056 Path::new("/tree"),
1057 Path::new("/nonexistent-tc-lib.toml"),
1058 python_exemptions,
1059 );
1060 assert_eq!(kept.unwrap(), vec![violation]);
1061 }
1062
1063 #[test]
1064 fn waivers_resolve_each_rule_once_and_keep_out_of_root_files() {
1065 let dir = std::env::temp_dir().join(format!("tc-lib-waiver-full-{}", std::process::id()));
1066 std::fs::create_dir_all(&dir).unwrap();
1067 std::fs::write(dir.join("widget_test.py"), "def test_widget():\n pass\n").unwrap();
1068 let config_path = dir.join("testing-conventions.toml");
1069 std::fs::write(
1070 &config_path,
1071 "[[python.exempt]]\n\
1072 path = \"widget_test.py\"\n\
1073 rules = [\"no-monkeypatch\"]\n\
1074 reason = \"synthetic waiver for the resolution paths\"\n",
1075 )
1076 .unwrap();
1077 let violation = |file: PathBuf| lint::Violation {
1078 file,
1079 line: 1,
1080 rule: "no-monkeypatch",
1081 message: "synthetic".to_string(),
1082 };
1083 let waived = violation(dir.join("widget_test.py"));
1084 let kept_in_root = violation(dir.join("other_test.py"));
1085 let outside_root = violation(PathBuf::from("/elsewhere/widget_test.py"));
1086 let kept = apply_waivers(
1087 vec![waived, kept_in_root.clone(), outside_root.clone()],
1088 &dir,
1089 &config_path,
1090 python_exemptions,
1091 );
1092 let _ = std::fs::remove_dir_all(&dir);
1093 assert_eq!(kept.unwrap(), vec![kept_in_root, outside_root]);
1094 }
1095
1096 #[test]
1097 fn help_flag_returns_clap_display_help() {
1098 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
1099 let clap_err = err
1100 .downcast_ref::<clap::Error>()
1101 .expect("error should be a clap::Error");
1102 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
1103 }
1104
1105 #[test]
1106 fn version_flag_returns_clap_display_version() {
1107 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
1108 let clap_err = err
1109 .downcast_ref::<clap::Error>()
1110 .expect("error should be a clap::Error");
1111 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
1112 }
1113}