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;
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    /// Write the testing contract into the repository's agent context file:
42    /// a marker-delimited, hash-versioned block in `AGENTS.md` that a
43    /// coding agent reads before writing code. Idempotent — re-running
44    /// refreshes the owned region and touches nothing outside it.
45    Install {
46        /// The agent context file to manage.
47        #[arg(default_value = "AGENTS.md")]
48        path: PathBuf,
49    },
50    /// Unit-test conventions.
51    Unit {
52        #[command(subcommand)]
53        rule: UnitRule,
54    },
55    /// Integration-test conventions.
56    Integration {
57        #[command(subcommand)]
58        rule: IntegrationRule,
59    },
60    /// Packaging conventions: test files must not ship in the built artifact.
61    Packaging {
62        /// Built distributions to check: a directory holding them (searched recursively), one
63        /// named `.whl` / `.tar.gz` / `.tgz` / `.crate`, or an unpacked artifact root.
64        path: PathBuf,
65        /// Language convention to enforce for `path`. Omitted, each distribution found takes
66        /// the language its file name names.
67        #[arg(long, value_enum)]
68        language: Option<colocated_test::Language>,
69    },
70    /// Workflow guard (private — hidden from `--help`): every `testing-conventions`
71    /// invocation in a CI workflow must name a subcommand this binary still exposes
72    /// (guards the `@v0` path). Run from our own CI, not a documented consumer command;
73    /// it stays in the binary because the guard needs the in-process command tree.
74    #[command(hide = true)]
75    Workflow {
76        /// Workflow file (or a directory of them) to scan.
77        path: PathBuf,
78    },
79    /// End-to-end-test conventions.
80    E2e {
81        #[command(subcommand)]
82        command: E2eCommand,
83    },
84    /// Workflow conventions: a GitHub Actions `run:` or `github-script` body must be wiring, not a
85    /// program. Iteration, multi-branch dispatch, text-munging, and a body past a dozen commands
86    /// belong in a tested package in the repository's own language, invoked as a one-line `run:`.
87    WorkflowLint {
88        /// Workflow or action file, or a directory of them (defaults to `.github`).
89        #[arg(default_value = ".github")]
90        path: PathBuf,
91    },
92    /// Changelog conventions: a pull request that changes a package's public surface adds a
93    /// fragment recording it. Skipped when the repository keeps no fragment directories.
94    Changelog {
95        /// Base commit of the pull request; the range checked is `<base>...HEAD`.
96        #[arg(long)]
97        base: String,
98        /// Repository root to read the fragment layout from.
99        #[arg(default_value = ".")]
100        path: PathBuf,
101    },
102}
103
104#[derive(Subcommand, Debug)]
105enum UnitRule {
106    /// Check that every source file has a colocated, matching-named unit test
107    /// (tree-wide presence). With `--base`, additionally run the commit-scoped
108    /// `co-change` check over `<base>...HEAD`: a modified or deleted source
109    /// whose colocated test is not in the diff fails. Presence always runs;
110    /// `--base` *adds* the diff-scoped check.
111    ColocatedTest {
112        /// Directory to scan recursively.
113        path: PathBuf,
114        /// Language convention to enforce (required).
115        #[arg(long, value_enum)]
116        language: colocated_test::Language,
117        /// Opt-in commit-scoped co-change check: diff `<base>...HEAD` and
118        /// also flag a modified or deleted source whose colocated test didn't
119        /// co-change. Absent means presence-only — there is no default. Python /
120        /// TypeScript only: `--base --language rust` is rejected (inline
121        /// `#[cfg(test)]` units have no sibling test to go stale).
122        #[arg(long)]
123        base: Option<String>,
124        /// testing-conventions config file providing the `exempt` list. Optional:
125        /// if the file is absent, no files are exempt.
126        #[arg(long, default_value = "testing-conventions.toml")]
127        config: PathBuf,
128    },
129    /// Check that the unit suite meets the configured coverage floor. With
130    /// `--base`, the same configured floor is measured over the `<base>...HEAD`
131    /// diff (the changed lines) instead of the whole tree — a changed line
132    /// below the floor fails, no matter how small the diff.
133    Coverage {
134        /// Directory whose unit suite is run and measured.
135        path: PathBuf,
136        /// Language convention to enforce (required).
137        #[arg(long, value_enum)]
138        language: colocated_test::Language,
139        /// Opt-in diff-scoped coverage: diff `<base>...HEAD` and measure the
140        /// configured floor over only the changed lines, instead of the whole tree.
141        /// Absent means whole-tree — there is no default. This is the patch-scoped
142        /// check the old `unit patch-coverage` command did, re-homed onto the floor
143        /// it shares.
144        #[arg(long)]
145        base: Option<String>,
146        /// testing-conventions config file with the coverage thresholds and
147        /// `exempt` list. Optional: if the file — or its `[<language>].coverage`
148        /// table — is absent, the language's sane default floor is used and
149        /// nothing is exempt.
150        #[arg(long, default_value = "testing-conventions.toml")]
151        config: PathBuf,
152    },
153    /// Check that no source file holds more than one module-scope function whose body
154    /// runs longer than the configured threshold. Trivial functions — at or under the
155    /// threshold — share a file freely.
156    OneFunctionPerFile {
157        /// Directory to scan recursively.
158        path: PathBuf,
159        /// Language convention to enforce (required).
160        #[arg(long, value_enum)]
161        language: colocated_test::Language,
162        /// testing-conventions config file providing the `max_lines` threshold and the
163        /// `exempt` list. Optional: if the file — or its
164        /// `[<language>].one_function_per_file` table — is absent, the default threshold
165        /// of one line applies and nothing is exempt.
166        #[arg(long, default_value = "testing-conventions.toml")]
167        config: PathBuf,
168    },
169    /// Lint unit test files for isolation: mock every collaborator (Python, TypeScript, Rust).
170    Lint {
171        /// Crate root / source dir to scan recursively.
172        path: PathBuf,
173        /// Language convention to enforce (required).
174        #[arg(long, value_enum)]
175        language: isolation::Language,
176        /// testing-conventions config file providing the `exempt` list (waivers).
177        /// Optional: if the file is absent, nothing is waived.
178        #[arg(long, default_value = "testing-conventions.toml")]
179        config: PathBuf,
180    },
181    /// Run mutation testing over the unit suite and fail on any surviving mutant not
182    /// lifted by a `mutation` exemption — the rung above coverage. The check is
183    /// on by default (no report-only mode). All three languages (Python, TypeScript,
184    /// Rust) are at parity and wired into the reusable workflow as a diff-scoped,
185    /// PR-only job.
186    Mutation {
187        /// Crate whose unit suite is mutated.
188        path: PathBuf,
189        /// Language convention to enforce (required): `python`, `typescript`, or `rust`.
190        #[arg(long, value_enum)]
191        language: colocated_test::Language,
192        /// Opt-in diff-scoping: restrict to mutants on lines a `<base>...HEAD`
193        /// diff added or modified, via cargo-mutants' `--in-diff`. Absent means the
194        /// whole crate (slower).
195        #[arg(long)]
196        base: Option<String>,
197        /// testing-conventions config file providing the `exempt` list. Optional:
198        /// absent means nothing is exempt (every survivor must be killed).
199        #[arg(long, default_value = "testing-conventions.toml")]
200        config: PathBuf,
201        /// Path to the bundled TypeScript mutation adapter (`dist/mutation/main.js`), used
202        /// only by `--language typescript`. The npm launcher appends it; hidden because a
203        /// consumer never sets it by hand.
204        #[arg(long = "ts-mutation-adapter", hide = true)]
205        ts_adapter: Option<PathBuf>,
206    },
207}
208
209/// Languages the integration-test lints support.
210#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
211pub enum IntegrationLintLanguage {
212    /// Python test files (`*_test.py`, `test_*.py`, `conftest.py`).
213    #[value(name = "python")]
214    Python,
215    /// TypeScript test files (`*.test.{ts,tsx,mts,cts}`).
216    #[value(name = "typescript")]
217    TypeScript,
218    /// Rust integration crates under `tests/`.
219    #[value(name = "rust")]
220    Rust,
221}
222
223#[derive(Subcommand, Debug)]
224enum IntegrationRule {
225    /// Lint integration test files for mocking mechanism & style (Python, TypeScript, Rust).
226    Lint {
227        /// Directory to scan recursively for test files.
228        path: PathBuf,
229        /// Language convention to enforce (required).
230        #[arg(long, value_enum)]
231        language: IntegrationLintLanguage,
232        /// testing-conventions config file providing the `exempt` list (waivers).
233        /// Optional: if the file is absent, nothing is waived.
234        #[arg(long, default_value = "testing-conventions.toml")]
235        config: PathBuf,
236    },
237}
238
239#[derive(Subcommand, Debug)]
240enum E2eCommand {
241    /// Run the e2e command of your choosing and, when it passes, commit the
242    /// branch's receipt — the command (full suite, targeted subset, or a no-op)
243    /// is the judgment the receipt records. Exits with the command's own code.
244    Attest {
245        /// The e2e command to run (e.g. `pnpm run e2e`), executed via the shell.
246        command: String,
247    },
248    /// Verify a receipt answers this branch's e2e nudge (the CI gate).
249    Verify {
250        /// Directory whose committed receipts (`e2e-attestations/`) are read
251        /// (default: current directory).
252        #[arg(default_value = ".")]
253        path: PathBuf,
254        /// Directory defining what counts as scoped source, if narrower than
255        /// `path` (default: `path` itself). Must be `path` or a descendant of it.
256        #[arg(long)]
257        scope: Option<PathBuf>,
258        /// Base ref for the branch's content diff (`<base>...HEAD`): a branch
259        /// whose diff leaves the scoped source untouched owes no decision, and
260        /// one that changed it passes when its diff adds or updates a receipt —
261        /// the way the changed-line coverage/mutation checks read the diff, and
262        /// indifferent to rebases and squash merges. Absent, presence of a
263        /// committed receipt is the whole check.
264        #[arg(long)]
265        base: Option<String>,
266        /// Extra scopes: repo-root-relative directories outside `path` that
267        /// join the scoped diff — a shared source tree beside the package (a
268        /// native core bound into several bindings) that no `--scope`
269        /// at-or-below `path` can reach. Repeatable.
270        #[arg(long = "extra-scope")]
271        extra_scope: Vec<PathBuf>,
272        /// Feature-gated subtrees carved back out of the `--extra-scope` union:
273        /// repo-root-relative directories (a core `cli/` compiled out of the
274        /// bindings) whose changes owe no decision. Repeatable.
275        #[arg(long = "exclude")]
276        exclude: Vec<PathBuf>,
277        /// The acting branch's name, for a checkout with no branch to read (a
278        /// detached HEAD). Absent, `verify` reads the checked-out branch.
279        #[arg(long)]
280        branch: Option<String>,
281    },
282    /// Print the standardized receipt slug for a branch name — the receipt
283    /// lives at `e2e-attestations/<slug>.json`.
284    Slug {
285        /// Branch name to standardize (default: the checked-out branch).
286        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    // Printed before parsing so a run that dies on an unrecognized flag still names its
296    // version, and on stderr because `e2e slug`'s stdout is read by command substitution.
297    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
375/// The binary's own clap command tree, which the `workflow` guard checks invocations against.
376pub fn command() -> clap::Command {
377    Cli::command()
378}
379
380/// Run the colocated-test presence check over `root`, plus the diff-scoped co-change
381/// check when `base` is set. Returns `0` only when both pass.
382fn 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
406/// Print every source file under `root` missing its colocated unit test; `Ok(false)`
407/// when any were found.
408fn 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
440/// The `colocated-test`-rule exempt paths for `language`; empty when the config is absent.
441fn 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
457/// Print every source under `root` that `<base>...HEAD` changed without its colocated
458/// test; `Ok(false)` when any were found.
459fn 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
484/// The `co-change`-rule exempt paths for `language`; empty when the config is absent.
485fn 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
497/// Split a resolved exempt-scope map into whole-file paths and line-scoped sets.
498fn 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
517/// Run the per-language mutation engine over `root` and fail on any surviving mutant
518/// not lifted by a `mutation` exemption. `base` scopes the run to the diff.
519fn 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
593/// Run the one-function-per-file rule over `root`, printing each violation and returning
594/// `1` when any are found. A language with no configured threshold reports that and exits `0`.
595fn 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
648/// Run the unit-suite isolation lints over `root`, printing each violation and returning
649/// `1` when any are found. The Rust arm derives the crate root above `root` so that a scan
650/// pointed at `src/` still reads the manifest, and exempt paths stay crate-root-relative.
651fn 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
694/// Run the integration-test lints over the package root above `root`, printing each
695/// violation and returning `1` when any are found. A tree with no manifest is scanned at `root`.
696fn 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
746/// Selects a language's `[[<lang>.exempt]]` table from a loaded config.
747type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
748
749/// Drop the violations whose `root`-relative path is exempt for their rule.
750fn 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
791/// Report every scope in `<base>...HEAD` that changed public surface without adding the
792/// fragments recording it. `0` when `root` keeps no fragment directories.
793fn 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
822/// Inspect the built distributions at `path` for test files. With `language`, `path` is that
823/// language's distribution or unpacked artifact root; without it, `path` is searched and every
824/// distribution found takes the language its file name names. `1` when any test file ships.
825fn 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
857/// Name every test file `distribution` ships, and how many there were.
858fn 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        // `#[cfg(test)]` units compile out, so only the crate-root `tests/` dir can ship.
863        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
876/// Flag every `testing-conventions` invocation under `path` naming a subcommand this
877/// binary no longer exposes. `1` when any are found.
878fn 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
922/// Run `command` as the branch's e2e decision and, when it passes, commit the receipt.
923/// Returns `command`'s own exit code.
924fn 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
944/// Verify a receipt under `path` answers the acting branch's e2e nudge. `0` when it does;
945/// otherwise prints the hint and returns `1`. `scope` defaults to `path`, `branch` overrides
946/// the checked-out branch, and `base`, when set, makes the check a `<base>...HEAD` diff.
947fn 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
975/// Print the receipt slug for `branch`, defaulting to the checked-out branch.
976fn 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}