Skip to main content

testing_conventions/
lib.rs

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 rust_patch_coverage;
16mod subprocess_seam;
17pub mod tiers;
18pub mod ts;
19mod unit_coverage;
20mod unit_mutation;
21pub mod violation;
22mod walk;
23pub mod workflow;
24pub mod workflow_lint;
25
26use std::path::{Path, PathBuf};
27
28use clap::{CommandFactory, Parser, Subcommand};
29
30#[derive(Parser, Debug)]
31#[command(
32    name = "testing-conventions",
33    version,
34    about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
35    long_about = None,
36)]
37pub struct Cli {
38    #[command(subcommand)]
39    command: Option<Command>,
40}
41
42#[derive(Subcommand, Debug)]
43enum Command {
44    /// Write the testing contract into the repository's agent context file:
45    /// a marker-delimited, hash-versioned block in `AGENTS.md` that a
46    /// coding agent reads before writing code. Idempotent — re-running
47    /// refreshes the owned region and touches nothing outside it.
48    Install {
49        /// The agent context file to manage.
50        #[arg(default_value = "AGENTS.md")]
51        path: PathBuf,
52    },
53    /// Unit-test conventions.
54    Unit {
55        #[command(subcommand)]
56        rule: UnitRule,
57    },
58    /// Integration-test conventions.
59    Integration {
60        #[command(subcommand)]
61        rule: IntegrationRule,
62    },
63    /// Packaging conventions: test files must not ship in the built artifact.
64    Packaging {
65        /// Built distributions to check: a directory holding them (searched recursively), one
66        /// named `.whl` / `.tar.gz` / `.tgz` / `.crate`, or an unpacked artifact root.
67        path: PathBuf,
68        /// Language convention to enforce for `path`. Omitted, each distribution found takes
69        /// the language its file name names.
70        #[arg(long, value_enum)]
71        language: Option<colocated_test::Language>,
72    },
73    /// Workflow guard (private — hidden from `--help`): every `testing-conventions`
74    /// invocation in a CI workflow must name a subcommand this binary still exposes
75    /// (guards the `@v0` path). Run from our own CI, not a documented consumer command;
76    /// it stays in the binary because the guard needs the in-process command tree.
77    #[command(hide = true)]
78    Workflow {
79        /// Workflow file (or a directory of them) to scan.
80        path: PathBuf,
81    },
82    /// End-to-end-test conventions.
83    E2e {
84        #[command(subcommand)]
85        command: E2eCommand,
86    },
87    /// Workflow conventions: a GitHub Actions `run:` or `github-script` body must be wiring, not a
88    /// program. Iteration, multi-branch dispatch, text-munging, and a body past a dozen commands
89    /// belong in a tested package in the repository's own language, invoked as a one-line `run:`.
90    WorkflowLint {
91        /// Workflow or action file, or a directory of them (defaults to `.github`).
92        #[arg(default_value = ".github")]
93        path: PathBuf,
94    },
95    /// Changelog conventions: a pull request that changes a package's public surface adds a
96    /// fragment recording it. Skipped when the repository keeps no fragment directories.
97    Changelog {
98        /// Base commit of the pull request; the range checked is `<base>...HEAD`.
99        #[arg(long)]
100        base: String,
101        /// Repository root to read the fragment layout from.
102        #[arg(default_value = ".")]
103        path: PathBuf,
104    },
105}
106
107#[derive(Subcommand, Debug)]
108enum UnitRule {
109    /// Check that every source file has a colocated, matching-named unit test
110    /// (tree-wide presence). With `--base`, additionally run the commit-scoped
111    /// `co-change` check over `<base>...HEAD`: a modified or deleted source
112    /// whose colocated test is not in the diff fails. Presence always runs;
113    /// `--base` *adds* the diff-scoped check.
114    ColocatedTest {
115        /// Directory to scan recursively.
116        path: PathBuf,
117        /// Language convention to enforce (required).
118        #[arg(long, value_enum)]
119        language: colocated_test::Language,
120        /// Opt-in commit-scoped co-change check: diff `<base>...HEAD` and
121        /// also flag a modified or deleted source whose colocated test didn't
122        /// co-change. Absent means presence-only — there is no default. Python /
123        /// TypeScript only: `--base --language rust` is rejected (inline
124        /// `#[cfg(test)]` units have no sibling test to go stale).
125        #[arg(long)]
126        base: Option<String>,
127        /// testing-conventions config file providing the `exempt` list. Optional:
128        /// if the file is absent, no files are exempt.
129        #[arg(long, default_value = "testing-conventions.toml")]
130        config: PathBuf,
131    },
132    /// Check that the unit suite meets the configured coverage floor. With
133    /// `--base`, the same configured floor is measured over the `<base>...HEAD`
134    /// diff (the changed lines) instead of the whole tree — a changed line
135    /// below the floor fails, no matter how small the diff.
136    Coverage {
137        /// Directory whose unit suite is run and measured.
138        path: PathBuf,
139        /// Language convention to enforce (required).
140        #[arg(long, value_enum)]
141        language: colocated_test::Language,
142        /// Opt-in diff-scoped coverage: diff `<base>...HEAD` and measure the
143        /// configured floor over only the changed lines, instead of the whole tree.
144        /// Absent means whole-tree — there is no default. This is the patch-scoped
145        /// check the old `unit patch-coverage` command did, re-homed onto the floor
146        /// it shares.
147        #[arg(long)]
148        base: Option<String>,
149        /// testing-conventions config file with the coverage thresholds and
150        /// `exempt` list. Optional: if the file — or its `[<language>].coverage`
151        /// table — is absent, the language's sane default floor is used and
152        /// nothing is exempt.
153        #[arg(long, default_value = "testing-conventions.toml")]
154        config: PathBuf,
155    },
156    /// Check that no source file holds more than one module-scope function whose body
157    /// runs longer than the configured threshold. Trivial functions — at or under the
158    /// threshold — share a file freely.
159    OneFunctionPerFile {
160        /// Directory to scan recursively.
161        path: PathBuf,
162        /// Language convention to enforce (required).
163        #[arg(long, value_enum)]
164        language: colocated_test::Language,
165        /// testing-conventions config file providing the `max_lines` threshold and the
166        /// `exempt` list. Optional: if the file — or its
167        /// `[<language>].one_function_per_file` table — is absent, the default threshold
168        /// of one line applies and nothing is exempt.
169        #[arg(long, default_value = "testing-conventions.toml")]
170        config: PathBuf,
171    },
172    /// Lint unit test files for isolation: mock every collaborator (Python, TypeScript, Rust).
173    Lint {
174        /// Crate root / source dir to scan recursively.
175        path: PathBuf,
176        /// Language convention to enforce (required).
177        #[arg(long, value_enum)]
178        language: isolation::Language,
179        /// testing-conventions config file providing the `exempt` list (waivers).
180        /// Optional: if the file is absent, nothing is waived.
181        #[arg(long, default_value = "testing-conventions.toml")]
182        config: PathBuf,
183    },
184    /// Run mutation testing over the unit suite and fail on any surviving mutant not
185    /// lifted by a `mutation` exemption — the rung above coverage. The check is
186    /// on by default (no report-only mode). All three languages (Python, TypeScript,
187    /// Rust) are at parity and wired into the reusable workflow as a diff-scoped,
188    /// PR-only job.
189    Mutation {
190        /// Crate whose unit suite is mutated.
191        path: PathBuf,
192        /// Language convention to enforce (required): `python`, `typescript`, or `rust`.
193        #[arg(long, value_enum)]
194        language: colocated_test::Language,
195        /// Opt-in diff-scoping: restrict to mutants on lines a `<base>...HEAD`
196        /// diff added or modified, via cargo-mutants' `--in-diff`. Absent means the
197        /// whole crate (slower).
198        #[arg(long)]
199        base: Option<String>,
200        /// testing-conventions config file providing the `exempt` list. Optional:
201        /// absent means nothing is exempt (every survivor must be killed).
202        #[arg(long, default_value = "testing-conventions.toml")]
203        config: PathBuf,
204        /// Path to the bundled TypeScript mutation adapter (`dist/mutation/main.js`), used
205        /// only by `--language typescript`. The npm launcher appends it; hidden because a
206        /// consumer never sets it by hand.
207        #[arg(long = "ts-mutation-adapter", hide = true)]
208        ts_adapter: Option<PathBuf>,
209    },
210}
211
212/// Languages the integration-test lints support.
213#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
214pub enum IntegrationLintLanguage {
215    /// Python test files (`*_test.py`, `test_*.py`, `conftest.py`).
216    #[value(name = "python")]
217    Python,
218    /// TypeScript test files (`*.test.{ts,tsx,mts,cts}`).
219    #[value(name = "typescript")]
220    TypeScript,
221    /// Rust integration crates under `tests/`.
222    #[value(name = "rust")]
223    Rust,
224}
225
226#[derive(Subcommand, Debug)]
227enum IntegrationRule {
228    /// Lint integration test files for mocking mechanism & style (Python, TypeScript, Rust).
229    Lint {
230        /// Directory to scan recursively for test files.
231        path: PathBuf,
232        /// Language convention to enforce (required).
233        #[arg(long, value_enum)]
234        language: IntegrationLintLanguage,
235        /// testing-conventions config file providing the `exempt` list (waivers).
236        /// Optional: if the file is absent, nothing is waived.
237        #[arg(long, default_value = "testing-conventions.toml")]
238        config: PathBuf,
239    },
240}
241
242#[derive(Subcommand, Debug)]
243enum E2eCommand {
244    /// Run the e2e command of your choosing and, when it passes, commit the
245    /// branch's receipt — the command (full suite, targeted subset, or a no-op)
246    /// is the judgment the receipt records. Exits with the command's own code.
247    Attest {
248        /// The e2e command to run (e.g. `pnpm run e2e`), executed via the shell.
249        command: String,
250    },
251    /// Verify a receipt answers this branch's e2e nudge (the CI gate).
252    Verify {
253        /// Directory whose committed receipts (`e2e-attestations/`) are read
254        /// (default: current directory).
255        #[arg(default_value = ".")]
256        path: PathBuf,
257        /// Directory defining what counts as scoped source, if narrower than
258        /// `path` (default: `path` itself). Must be `path` or a descendant of it.
259        #[arg(long)]
260        scope: Option<PathBuf>,
261        /// Base ref for the branch's content diff (`<base>...HEAD`): a branch
262        /// whose diff leaves the scoped source untouched owes no decision, and
263        /// one that changed it passes when its diff adds or updates a receipt —
264        /// the way the changed-line coverage/mutation checks read the diff, and
265        /// indifferent to rebases and squash merges. Absent, presence of a
266        /// committed receipt is the whole check.
267        #[arg(long)]
268        base: Option<String>,
269        /// Extra scopes: repo-root-relative directories outside `path` that
270        /// join the scoped diff — a shared source tree beside the package (a
271        /// native core bound into several bindings) that no `--scope`
272        /// at-or-below `path` can reach. Repeatable.
273        #[arg(long = "extra-scope")]
274        extra_scope: Vec<PathBuf>,
275        /// Feature-gated subtrees carved back out of the `--extra-scope` union:
276        /// repo-root-relative directories (a core `cli/` compiled out of the
277        /// bindings) whose changes owe no decision. Repeatable.
278        #[arg(long = "exclude")]
279        exclude: Vec<PathBuf>,
280        /// The acting branch's name, for a checkout with no branch to read (a
281        /// detached HEAD). Absent, `verify` reads the checked-out branch.
282        #[arg(long)]
283        branch: Option<String>,
284    },
285    /// Print the standardized receipt slug for a branch name — the receipt
286    /// lives at `e2e-attestations/<slug>.json`.
287    Slug {
288        /// Branch name to standardize (default: the checked-out branch).
289        branch: Option<String>,
290    },
291}
292
293pub fn run<I, T>(args: I) -> anyhow::Result<i32>
294where
295    I: IntoIterator<Item = T>,
296    T: Into<std::ffi::OsString> + Clone,
297{
298    // Printed before parsing so a run that dies on an unrecognized flag still names its
299    // version, and on stderr because `e2e slug`'s stdout is read by command substitution.
300    eprintln!("testing-conventions {}", env!("CARGO_PKG_VERSION"));
301    let cli = Cli::try_parse_from(args)?;
302    match cli.command {
303        None => Ok(0),
304        Some(Command::Unit { rule }) => match rule {
305            UnitRule::ColocatedTest {
306                path,
307                language,
308                base,
309                config,
310            } => run_unit_colocated_test(&path, language, base.as_deref(), &config),
311            UnitRule::Coverage {
312                path,
313                language,
314                base,
315                config,
316            } => unit_coverage::run(&path, language, base.as_deref(), &config),
317            UnitRule::OneFunctionPerFile {
318                path,
319                language,
320                config,
321            } => run_unit_one_function(&path, language, &config),
322            UnitRule::Lint {
323                path,
324                language,
325                config,
326            } => run_unit_lint(&path, language, &config),
327            UnitRule::Mutation {
328                path,
329                language,
330                base,
331                config,
332                ts_adapter,
333            } => unit_mutation::run(
334                &path,
335                language,
336                base.as_deref(),
337                &config,
338                ts_adapter.as_deref(),
339            ),
340        },
341        Some(Command::Integration { rule }) => match rule {
342            IntegrationRule::Lint {
343                path,
344                language,
345                config,
346            } => run_integration_lint(&path, language, &config),
347        },
348        Some(Command::Packaging { path, language }) => run_packaging(&path, language),
349        Some(Command::Changelog { base, path }) => run_changelog(&base, &path),
350        Some(Command::Workflow { path }) => run_workflow(&path),
351        Some(Command::WorkflowLint { path }) => run_workflow_lint(&path),
352        Some(Command::E2e { command }) => match command {
353            E2eCommand::Attest { command } => run_e2e_attest(&command),
354            E2eCommand::Verify {
355                path,
356                scope,
357                base,
358                extra_scope,
359                exclude,
360                branch,
361            } => run_e2e_verify(
362                &path,
363                scope.as_deref(),
364                base.as_deref(),
365                &extra_scope,
366                &exclude,
367                branch.as_deref(),
368            ),
369            E2eCommand::Slug { branch } => run_e2e_slug(branch.as_deref()),
370        },
371        Some(Command::Install { path }) => {
372            agents::install(&path)?;
373            Ok(0)
374        }
375    }
376}
377
378/// The binary's own clap command tree, which the `workflow` guard checks invocations against.
379pub fn command() -> clap::Command {
380    Cli::command()
381}
382
383/// Run the colocated-test presence check over `root`, plus the diff-scoped co-change
384/// check when `base` is set. Returns `0` only when both pass.
385fn run_unit_colocated_test(
386    root: &Path,
387    language: colocated_test::Language,
388    base: Option<&str>,
389    config_path: &Path,
390) -> anyhow::Result<i32> {
391    if base.is_some() && language == colocated_test::Language::Rust {
392        anyhow::bail!(
393            "`unit colocated-test --base` supports `--language python` / `typescript`; Rust \
394             units are inline `#[cfg(test)]` in the same file, so a sibling test can't go stale"
395        );
396    }
397    let presence_clean = report_colocated_presence(root, language, config_path)?;
398    let co_change_clean = match base {
399        Some(base) => report_co_change(root, base, language, config_path)?,
400        None => true,
401    };
402    Ok(if presence_clean && co_change_clean {
403        0
404    } else {
405        1
406    })
407}
408
409/// Print every source file under `root` missing its colocated unit test; `Ok(false)`
410/// when any were found.
411fn report_colocated_presence(
412    root: &Path,
413    language: colocated_test::Language,
414    config_path: &Path,
415) -> anyhow::Result<bool> {
416    let exempt = colocated_test_exemptions(root, language, config_path)?;
417    let orphans = match language {
418        colocated_test::Language::Rust => colocated_test::missing_inline_tests(root, &exempt)?,
419        _ => colocated_test::missing_unit_tests(root, language, &exempt)?,
420    };
421    if orphans.is_empty() {
422        return Ok(true);
423    }
424    let (label, summary) = match language {
425        colocated_test::Language::Rust => (
426            "missing inline `#[cfg(test)]` tests",
427            "source file(s) with testable code but no inline `#[cfg(test)]` module \
428             (add an inline test module, or an `exempt` entry with a reason)",
429        ),
430        _ => (
431            "missing colocated unit test",
432            "source file(s) missing a colocated unit test \
433             (add a colocated test, or an `exempt` entry with a reason)",
434        ),
435    };
436    for orphan in &orphans {
437        eprintln!("{label}: {}", orphan.display());
438    }
439    eprintln!("error: {} {summary}", orphans.len());
440    Ok(false)
441}
442
443/// The `colocated-test`-rule exempt paths for `language`; empty when the config is absent.
444fn colocated_test_exemptions(
445    root: &Path,
446    language: colocated_test::Language,
447    config_path: &Path,
448) -> anyhow::Result<std::collections::BTreeSet<String>> {
449    if !config_path.exists() {
450        return Ok(std::collections::BTreeSet::new());
451    }
452    let config = config::load_config(config_path)?;
453    config::resolve_exempt(
454        root,
455        config.exemptions(language),
456        config::Rule::ColocatedTest,
457    )
458}
459
460/// Print every source under `root` that `<base>...HEAD` changed without its colocated
461/// test; `Ok(false)` when any were found.
462fn report_co_change(
463    root: &Path,
464    base: &str,
465    language: colocated_test::Language,
466    config_path: &Path,
467) -> anyhow::Result<bool> {
468    let exempt = co_change_exemptions(root, language, config_path)?;
469    let stale = co_change::stale_sources(root, base, language, &exempt)?;
470    if stale.is_empty() {
471        return Ok(true);
472    }
473    for source in &stale {
474        eprintln!(
475            "source changed without its colocated test: {}",
476            source.display()
477        );
478    }
479    eprintln!(
480        "error: {} source file(s) changed without their colocated test co-changing \
481         (update the test, or add an `exempt` entry with a reason)",
482        stale.len()
483    );
484    Ok(false)
485}
486
487/// The `co-change`-rule exempt paths for `language`; empty when the config is absent.
488fn co_change_exemptions(
489    root: &Path,
490    language: colocated_test::Language,
491    config_path: &Path,
492) -> anyhow::Result<std::collections::BTreeSet<String>> {
493    if !config_path.exists() {
494        return Ok(std::collections::BTreeSet::new());
495    }
496    let config = config::load_config(config_path)?;
497    config::resolve_exempt(root, config.exemptions(language), config::Rule::CoChange)
498}
499
500/// Split a resolved exempt-scope map into whole-file paths and line-scoped sets.
501fn split_scopes(
502    scopes: std::collections::BTreeMap<String, config::LineScope>,
503) -> (
504    Vec<String>,
505    std::collections::BTreeMap<String, std::collections::BTreeSet<u32>>,
506) {
507    let mut whole_file = Vec::new();
508    let mut line_scoped = std::collections::BTreeMap::new();
509    for (path, scope) in scopes {
510        match scope {
511            config::LineScope::WholeFile => whole_file.push(path),
512            config::LineScope::Lines(lines) => {
513                line_scoped.insert(path, lines);
514            }
515        }
516    }
517    (whole_file, line_scoped)
518}
519
520/// Run the one-function-per-file rule over `root`, printing each violation and returning
521/// `1` when any are found. A language with no configured threshold reports that and exits `0`.
522fn run_unit_one_function(
523    root: &Path,
524    language: colocated_test::Language,
525    config_path: &Path,
526) -> anyhow::Result<i32> {
527    let threshold = if config_path.exists() {
528        config::load_config(config_path)?.one_function_threshold(language)
529    } else {
530        config::Config::default().one_function_threshold(language)
531    };
532    let key = match language {
533        colocated_test::Language::Python => "python",
534        colocated_test::Language::TypeScript => "typescript",
535        colocated_test::Language::Rust => "rust",
536    };
537    let Some(max_lines) = threshold else {
538        println!(
539            "unit one-function-per-file: not enabled for {key} — \
540             set `[{key}].one_function_per_file` to opt in"
541        );
542        return Ok(0);
543    };
544    let (raw, scanned) = one_function::find_violations(root, language, max_lines)?;
545    let select: ExemptSelect = match language {
546        colocated_test::Language::Python => |c| c.exemptions(colocated_test::Language::Python),
547        colocated_test::Language::TypeScript => {
548            |c| c.exemptions(colocated_test::Language::TypeScript)
549        }
550        colocated_test::Language::Rust => |c| c.rust_exemptions(),
551    };
552    let violations = apply_waivers(raw, root, config_path, select)?;
553    if violations.is_empty() {
554        eprintln!("one-function-per-file: scanned {scanned} file(s), 0 violations");
555        return Ok(0);
556    }
557    for v in &violations {
558        eprintln!(
559            "{}:{}: {} — {}",
560            v.file.display(),
561            v.line,
562            v.rule,
563            v.message
564        );
565    }
566    eprintln!(
567        "error: {} function(s) sharing a file with another function over the \
568         {max_lines}-line threshold (move each to its own module, or add an \
569         `exempt` entry with a reason)",
570        violations.len()
571    );
572    Ok(1)
573}
574
575/// Run the unit-suite isolation lints over `root`, printing each violation and returning
576/// `1` when any are found. The Rust arm derives the crate root above `root` so that a scan
577/// pointed at `src/` still reads the manifest, and exempt paths stay crate-root-relative.
578fn run_unit_lint(
579    root: &Path,
580    language: isolation::Language,
581    config_path: &Path,
582) -> anyhow::Result<i32> {
583    let crate_root = tiers::package_root(root, "Cargo.toml");
584    let (raw, select, waiver_root): (Vec<lint::Violation>, ExemptSelect, &Path) = match language {
585        isolation::Language::Rust => {
586            let crate_root = crate_root.as_deref().unwrap_or(root);
587            (
588                isolation::find_violations(root, crate_root)?,
589                |c| c.rust_exemptions(),
590                crate_root,
591            )
592        }
593        isolation::Language::TypeScript => (
594            ts::find_unit_violations(root)?,
595            |c| c.exemptions(colocated_test::Language::TypeScript),
596            root,
597        ),
598        isolation::Language::Python => (
599            lint::find_unit_isolation_violations(root)?,
600            |c| c.exemptions(colocated_test::Language::Python),
601            root,
602        ),
603    };
604    let violations = apply_waivers(raw, waiver_root, config_path, select)?;
605    if violations.is_empty() {
606        return Ok(0);
607    }
608    for v in &violations {
609        eprintln!(
610            "{}:{}: {} — {}",
611            v.file.display(),
612            v.line,
613            v.rule,
614            v.message
615        );
616    }
617    eprintln!("error: {} isolation violation(s)", violations.len());
618    Ok(1)
619}
620
621/// Run the integration-test lints over the package root above `root`, printing each
622/// violation and returning `1` when any are found. A tree with no manifest is scanned at `root`.
623fn run_integration_lint(
624    root: &Path,
625    language: IntegrationLintLanguage,
626    config_path: &Path,
627) -> anyhow::Result<i32> {
628    let manifest = match language {
629        IntegrationLintLanguage::Python => "pyproject.toml",
630        IntegrationLintLanguage::TypeScript => "package.json",
631        IntegrationLintLanguage::Rust => "Cargo.toml",
632    };
633    let package_root = tiers::package_root(root, manifest);
634    let scan_root = package_root.as_deref().unwrap_or(root);
635    let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
636        IntegrationLintLanguage::Python => (
637            match &package_root {
638                Some(package_root) => lint::find_suite_violations(package_root)?,
639                None => lint::find_violations(root)?,
640            },
641            |c| c.exemptions(colocated_test::Language::Python),
642        ),
643        IntegrationLintLanguage::TypeScript => (
644            match &package_root {
645                Some(package_root) => ts::find_suite_violations(package_root)?,
646                None => ts::find_integration_violations(root)?,
647            },
648            |c| c.exemptions(colocated_test::Language::TypeScript),
649        ),
650        IntegrationLintLanguage::Rust => {
651            (isolation::find_integration_violations(scan_root)?, |c| {
652                c.rust_exemptions()
653            })
654        }
655    };
656    let violations = apply_waivers(raw, scan_root, config_path, select)?;
657    if violations.is_empty() {
658        return Ok(0);
659    }
660    for v in &violations {
661        eprintln!(
662            "{}:{}: {} — {}",
663            v.file.display(),
664            v.line,
665            v.rule,
666            v.message
667        );
668    }
669    eprintln!("error: {} lint violation(s)", violations.len());
670    Ok(1)
671}
672
673/// Selects a language's `[[<lang>.exempt]]` table from a loaded config.
674type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
675
676/// Drop the violations whose `root`-relative path is exempt for their rule.
677fn apply_waivers(
678    violations: Vec<lint::Violation>,
679    root: &Path,
680    config_path: &Path,
681    exemptions: ExemptSelect,
682) -> anyhow::Result<Vec<lint::Violation>> {
683    use std::collections::hash_map::Entry;
684
685    if !config_path.exists() {
686        return Ok(violations);
687    }
688    let config = config::load_config(config_path)?;
689    let exempt = exemptions(&config);
690    let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
691        std::collections::HashMap::new();
692    let mut kept = Vec::new();
693    for violation in violations {
694        let waived = match config::Rule::from_id(violation.rule) {
695            Some(rule) => {
696                let exempt_paths = match resolved.entry(rule) {
697                    Entry::Occupied(entry) => entry.into_mut(),
698                    Entry::Vacant(entry) => {
699                        entry.insert(config::resolve_exempt(root, exempt, rule)?)
700                    }
701                };
702                violation
703                    .file
704                    .strip_prefix(root)
705                    .ok()
706                    .map(|rel| rel.to_string_lossy().replace('\\', "/"))
707                    .is_some_and(|rel| exempt_paths.contains(&rel))
708            }
709            None => false,
710        };
711        if !waived {
712            kept.push(violation);
713        }
714    }
715    Ok(kept)
716}
717
718/// Report every scope in `<base>...HEAD` that changed public surface without adding the
719/// fragments recording it. `0` when `root` keeps no fragment directories.
720fn run_changelog(base: &str, root: &Path) -> anyhow::Result<i32> {
721    let Some(layout) = changelog::discover_layout(root) else {
722        println!(
723            "No fragment directories under `{}`; changelog check skipped.",
724            root.display()
725        );
726        return Ok(0);
727    };
728    if changelog::has_skip_line(&changelog::commit_bodies(root, base)?) {
729        println!("A `skip-changelog:` line is present; changelog check bypassed.");
730        return Ok(0);
731    }
732    let changed = changelog::changed_files(root, base)?;
733    let added = changelog::added_files(root, base)?;
734    let migrations = changelog::migrations_enforced(root);
735    let found = changelog::findings(&layout, migrations, &changed, &added);
736    if found.is_empty() {
737        println!("Every scope that changed public surface added its fragments.");
738        return Ok(0);
739    }
740    for finding in &found {
741        match &finding.file {
742            Some(file) => println!("::error file={file}::{}", finding.message),
743            None => println!("::error::{}", finding.message),
744        }
745    }
746    Ok(1)
747}
748
749/// Inspect the built distributions at `path` for test files. With `language`, `path` is that
750/// language's distribution or unpacked artifact root; without it, `path` is searched and every
751/// distribution found takes the language its file name names. `1` when any test file ships.
752fn run_packaging(path: &Path, language: Option<colocated_test::Language>) -> anyhow::Result<i32> {
753    let distributions = match language {
754        Some(language) => vec![packaging::Distribution {
755            path: path.to_path_buf(),
756            language,
757        }],
758        None => packaging::discover(path)?,
759    };
760    if distributions.is_empty() {
761        anyhow::bail!(
762            "no recognized built distribution (`.whl`, `.tar.gz`, `.tgz`, `.crate`) at `{}`",
763            path.display()
764        );
765    }
766    let mut shipped = 0;
767    for distribution in &distributions {
768        shipped += report_shipped_test_files(distribution)?;
769    }
770    if shipped > 0 {
771        eprintln!(
772            "error: {shipped} test file(s) present in the built distribution(s) \
773             (they must be excluded from packaging)"
774        );
775        return Ok(1);
776    }
777    println!(
778        "checked {} built distribution(s); no test files shipped",
779        distributions.len()
780    );
781    Ok(0)
782}
783
784/// Name every test file `distribution` ships, and how many there were.
785fn report_shipped_test_files(distribution: &packaging::Distribution) -> anyhow::Result<usize> {
786    let globs = match distribution.language {
787        colocated_test::Language::Python => vec!["*_test.py".to_string()],
788        colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
789        // `#[cfg(test)]` units compile out, so only the crate-root `tests/` dir can ship.
790        colocated_test::Language::Rust => vec!["tests/".to_string()],
791    };
792    let offenders = packaging::inspect(&distribution.path, &globs)?;
793    for offender in &offenders {
794        eprintln!(
795            "test file in built artifact `{}`: {}",
796            distribution.path.display(),
797            offender.display()
798        );
799    }
800    Ok(offenders.len())
801}
802
803/// Flag every `testing-conventions` invocation under `path` naming a subcommand this
804/// binary no longer exposes. `1` when any are found.
805fn run_workflow(path: &Path) -> anyhow::Result<i32> {
806    let violations = workflow::check(path, &command())?;
807    if violations.is_empty() {
808        return Ok(0);
809    }
810    for v in &violations {
811        eprintln!(
812            "{}:{}: {} — {}",
813            v.file.display(),
814            v.line,
815            v.rule,
816            v.message
817        );
818    }
819    eprintln!(
820        "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
821        violations.len()
822    );
823    Ok(1)
824}
825
826fn run_workflow_lint(path: &Path) -> anyhow::Result<i32> {
827    let findings = workflow_lint::scan(path)?;
828    if findings.is_empty() {
829        return Ok(0);
830    }
831    for f in &findings {
832        eprintln!(
833            "{}:{}: {} step `{}` encodes logic inline ({}) — move it into a tested package in \
834             this repository's own language, invoked as a one-line `run:`",
835            f.file.display(),
836            f.line,
837            f.kind,
838            f.step,
839            f.reasons.join("; ")
840        );
841    }
842    eprintln!(
843        "error: {} step(s) encode logic in CI YAML, where nothing tests it",
844        findings.len()
845    );
846    Ok(1)
847}
848
849/// Run `command` as the branch's e2e decision and, when it passes, commit the receipt.
850/// Returns `command`'s own exit code.
851fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
852    let repo = std::env::current_dir()?;
853    let attestation = e2e::attest(&repo, command)?;
854    if attestation.exit_code != 0 {
855        eprintln!(
856            "e2e command `{command}` exited {}; a receipt records a run that passed — \
857             fix the failure and attest again",
858            attestation.exit_code
859        );
860        return Ok(attestation.exit_code);
861    }
862    println!(
863        "e2e receipt recorded for branch {} at {}/{}.json",
864        attestation.branch,
865        e2e::RECEIPTS_DIR,
866        e2e::branch_slug(&attestation.branch),
867    );
868    Ok(0)
869}
870
871/// Verify a receipt under `path` answers the acting branch's e2e nudge. `0` when it does;
872/// otherwise prints the hint and returns `1`. `scope` defaults to `path`, `branch` overrides
873/// the checked-out branch, and `base`, when set, makes the check a `<base>...HEAD` diff.
874fn run_e2e_verify(
875    path: &Path,
876    scope: Option<&Path>,
877    base: Option<&str>,
878    extra_scopes: &[PathBuf],
879    excludes: &[PathBuf],
880    branch: Option<&str>,
881) -> anyhow::Result<i32> {
882    match e2e::verify_extra_scoped(
883        path,
884        scope.unwrap_or(path),
885        base,
886        extra_scopes,
887        excludes,
888        branch,
889    )? {
890        e2e::Verification::Fresh => Ok(0),
891        e2e::Verification::Missing => {
892            eprintln!(
893                "no e2e receipt answers this change — run \
894                 `testing-conventions e2e attest '<your e2e command>'`; the command is \
895                 your judgment: the full suite, a targeted subset, or a no-op"
896            );
897            Ok(1)
898        }
899    }
900}
901
902/// Print the receipt slug for `branch`, defaulting to the checked-out branch.
903fn run_e2e_slug(branch: Option<&str>) -> anyhow::Result<i32> {
904    let slug = match branch {
905        Some(name) => e2e::branch_slug(name),
906        None => {
907            let repo = std::env::current_dir()?;
908            e2e::branch_slug(&e2e::current_branch(&repo)?)
909        }
910    };
911    println!("{slug}");
912    Ok(0)
913}
914
915#[cfg(test)]
916mod tests {
917    use super::*;
918
919    #[test]
920    fn no_args_returns_ok_zero() {
921        assert_eq!(run(["testing-conventions"]).unwrap(), 0);
922    }
923
924    #[test]
925    fn unknown_flag_errors() {
926        assert!(run(["testing-conventions", "--bogus"]).is_err());
927    }
928
929    #[test]
930    fn split_scopes_separates_whole_file_paths_from_line_sets() {
931        let mut scopes = std::collections::BTreeMap::new();
932        scopes.insert("shim.py".to_string(), config::LineScope::WholeFile);
933        scopes.insert(
934            "widget.py".to_string(),
935            config::LineScope::Lines(std::collections::BTreeSet::from([3])),
936        );
937        let (whole_file, line_scoped) = split_scopes(scopes);
938        assert_eq!(whole_file, vec!["shim.py".to_string()]);
939        assert_eq!(line_scoped.len(), 1);
940        assert_eq!(
941            line_scoped["widget.py"],
942            std::collections::BTreeSet::from([3])
943        );
944    }
945
946    fn python_exemptions(config: &config::Config) -> &[config::Exemption] {
947        config.exemptions(colocated_test::Language::Python)
948    }
949
950    #[test]
951    fn a_violation_with_an_unwaivable_rule_id_is_kept() {
952        let dir = std::env::temp_dir().join(format!("tc-lib-waiver-{}", std::process::id()));
953        std::fs::create_dir_all(&dir).unwrap();
954        let config_path = dir.join("testing-conventions.toml");
955        std::fs::write(&config_path, "").unwrap();
956        let violation = lint::Violation {
957            file: dir.join("widget_test.py"),
958            line: 1,
959            rule: "not-a-waivable-rule",
960            message: "synthetic".to_string(),
961        };
962        let kept = apply_waivers(
963            vec![violation.clone()],
964            &dir,
965            &config_path,
966            python_exemptions,
967        );
968        let _ = std::fs::remove_dir_all(&dir);
969        assert_eq!(kept.unwrap(), vec![violation]);
970    }
971
972    #[test]
973    fn a_missing_config_keeps_every_violation() {
974        let violation = lint::Violation {
975            file: PathBuf::from("/tree/widget_test.py"),
976            line: 1,
977            rule: "no-monkeypatch",
978            message: "synthetic".to_string(),
979        };
980        let kept = apply_waivers(
981            vec![violation.clone()],
982            Path::new("/tree"),
983            Path::new("/nonexistent-tc-lib.toml"),
984            python_exemptions,
985        );
986        assert_eq!(kept.unwrap(), vec![violation]);
987    }
988
989    #[test]
990    fn waivers_resolve_each_rule_once_and_keep_out_of_root_files() {
991        let dir = std::env::temp_dir().join(format!("tc-lib-waiver-full-{}", std::process::id()));
992        std::fs::create_dir_all(&dir).unwrap();
993        std::fs::write(dir.join("widget_test.py"), "def test_widget():\n    pass\n").unwrap();
994        let config_path = dir.join("testing-conventions.toml");
995        std::fs::write(
996            &config_path,
997            "[[python.exempt]]\n\
998             path = \"widget_test.py\"\n\
999             rules = [\"no-monkeypatch\"]\n\
1000             reason = \"synthetic waiver for the resolution paths\"\n",
1001        )
1002        .unwrap();
1003        let violation = |file: PathBuf| lint::Violation {
1004            file,
1005            line: 1,
1006            rule: "no-monkeypatch",
1007            message: "synthetic".to_string(),
1008        };
1009        let waived = violation(dir.join("widget_test.py"));
1010        let kept_in_root = violation(dir.join("other_test.py"));
1011        let outside_root = violation(PathBuf::from("/elsewhere/widget_test.py"));
1012        let kept = apply_waivers(
1013            vec![waived, kept_in_root.clone(), outside_root.clone()],
1014            &dir,
1015            &config_path,
1016            python_exemptions,
1017        );
1018        let _ = std::fs::remove_dir_all(&dir);
1019        assert_eq!(kept.unwrap(), vec![kept_in_root, outside_root]);
1020    }
1021
1022    #[test]
1023    fn help_flag_returns_clap_display_help() {
1024        let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
1025        let clap_err = err
1026            .downcast_ref::<clap::Error>()
1027            .expect("error should be a clap::Error");
1028        assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
1029    }
1030
1031    #[test]
1032    fn version_flag_returns_clap_display_version() {
1033        let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
1034        let clap_err = err
1035            .downcast_ref::<clap::Error>()
1036            .expect("error should be a clap::Error");
1037        assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
1038    }
1039}