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