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