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