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