Skip to main content

testing_conventions/
lib.rs

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