Skip to main content

testing_conventions/
lib.rs

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