Skip to main content

testing_conventions/
lib.rs

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