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