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