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