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