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