testing-conventions 0.0.26

Enforce testing conventions in libraries (Python, TypeScript, and Rust).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
//! The testing-conventions config schema and loader.
//!
//! One config file is read into the in-memory [`Config`] below. The loader
//! parses *and* validates the config itself (the "self-guard" from issue #12):
//! a malformed or unknown-key config is an error, never a silently-accepted
//! default. Validation also covers the per-file [`Exemption`] list (issue #32):
//! every exemption must name at least one rule and carry a non-empty reason.

use std::collections::BTreeSet;
use std::path::Path;

use anyhow::{bail, Context, Result};
use serde::Deserialize;

/// A fully-parsed testing-conventions config file.
///
/// Holds the per-language coverage thresholds — the `[python]` / `[typescript]`
/// / `[rust]` tables from the README's "Configuration" section — and the
/// per-language `exempt` lists. Each table is optional so a repo can configure
/// only the languages it ships. Test locations follow convention, not config, so
/// there are no location keys here.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
    pub python: Option<PythonConfig>,
    pub typescript: Option<TypeScriptConfig>,
    pub rust: Option<RustConfig>,
}

/// The `[python]` table. Both keys are optional, so a repo can configure just
/// coverage, just exemptions, or both. `Default` (no coverage table, no
/// exemptions) backs the zero-config path: an absent `[python]` table means the
/// rule runs against the default floor with nothing exempt (#80).
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PythonConfig {
    pub coverage: Option<PythonCoverage>,
    #[serde(default)]
    pub exempt: Vec<Exemption>,
}

/// The `[typescript]` table.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TypeScriptConfig {
    pub coverage: Option<TypeScriptCoverage>,
    #[serde(default)]
    pub exempt: Vec<Exemption>,
}

/// The `[rust]` table.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RustConfig {
    pub coverage: Option<RustCoverage>,
    #[serde(default)]
    pub exempt: Vec<Exemption>,
}

/// `[python].coverage`.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PythonCoverage {
    pub branch: bool,
    pub fail_under: u8,
}

/// The sane default Python floor used when coverage isn't configured (#80):
/// branch coverage on, `fail_under = 85`. Per `internals/python/testing.md`,
/// "85 is a reasonable floor; aiming for 100 forces tests for trivia." A config
/// `[python].coverage` table overrides it.
impl Default for PythonCoverage {
    fn default() -> Self {
        Self {
            branch: true,
            fail_under: 85,
        }
    }
}

/// `[typescript].coverage`.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TypeScriptCoverage {
    pub lines: u8,
    pub branches: u8,
    pub functions: u8,
    pub statements: u8,
}

/// The sane default TypeScript floors used when coverage isn't configured (#80),
/// matching `internals/typescript/testing.md`: lines/functions/statements 80,
/// branches 75. A config `[typescript].coverage` table overrides it.
impl Default for TypeScriptCoverage {
    fn default() -> Self {
        Self {
            lines: 80,
            branches: 75,
            functions: 80,
            statements: 80,
        }
    }
}

/// `[rust].coverage`. Branch coverage is still experimental, so only
/// regions/lines are configurable.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RustCoverage {
    pub regions: u8,
    pub lines: u8,
}

/// A rule a file can be exempted from (issue #32).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Rule {
    /// The unit-test colocated-test check ([`crate::colocated_test`]).
    ColocatedTest,
    /// The unit-test coverage floor ([`crate::coverage`]).
    Coverage,
    /// The commit-scoped `co-change` check ([`crate::co_change`], #33) — a
    /// changed source whose colocated test needn't co-change.
    CoChange,
    /// `integration lint` — a test/fixture takes pytest's `monkeypatch` fixture ([`crate::lint`], #49).
    NoMonkeypatch,
    /// `integration lint` — a `patch(...)` called inline in a Python test body ([`crate::lint`], #50).
    NoInlinePatch,
    /// `integration lint` — direct mutation of `os.environ` in a Python test ([`crate::lint`], #51).
    NoEnvironMutation,
    /// The `no-constant-patch` lint ([`crate::lint`], issue #52).
    NoConstantPatch,
    /// `integration lint` — patching a first-party target in a Python integration test ([`crate::lint`], #42).
    NoFirstPartyPatch,
    /// `unit isolation` — a call out of a Rust unit's own module ([`crate::isolation`], #44).
    NoOutOfModuleCall,
    /// `unit isolation` — a foreign `use` in a Rust unit test ([`crate::isolation`], #44).
    NoOutOfModuleImport,
    /// `integration lint` — doubling a first-party item in a Rust integration test (#44).
    NoFirstPartyDouble,
    /// `unit isolation` — an un-mocked first-party/external import in a TS unit test ([`crate::ts`], #76).
    UnmockedCollaborator,
    /// `unit isolation` — a `vi.mock` without a typed anchor in a TS unit test (#77).
    UntypedMock,
    /// `integration lint` — a `vi.mock` of a first-party module in a TS integration test (#75).
    NoFirstPartyMock,
}

impl Rule {
    /// The rule's kebab-case id — the string used in a `Violation` and in a config
    /// `rules` value. Mirrors the `serde(rename_all = "kebab-case")` encoding.
    pub fn id(self) -> &'static str {
        match self {
            Rule::ColocatedTest => "colocated-test",
            Rule::Coverage => "coverage",
            Rule::CoChange => "co-change",
            Rule::NoMonkeypatch => "no-monkeypatch",
            Rule::NoInlinePatch => "no-inline-patch",
            Rule::NoEnvironMutation => "no-environ-mutation",
            Rule::NoConstantPatch => "no-constant-patch",
            Rule::NoFirstPartyPatch => "no-first-party-patch",
            Rule::NoOutOfModuleCall => "no-out-of-module-call",
            Rule::NoOutOfModuleImport => "no-out-of-module-import",
            Rule::NoFirstPartyDouble => "no-first-party-double",
            Rule::UnmockedCollaborator => "unmocked-collaborator",
            Rule::UntypedMock => "untyped-mock",
            Rule::NoFirstPartyMock => "no-first-party-mock",
        }
    }

    /// The [`Rule`] for a lint id, or `None` for an unknown / non-waivable id.
    pub fn from_id(id: &str) -> Option<Rule> {
        [
            Rule::ColocatedTest,
            Rule::Coverage,
            Rule::CoChange,
            Rule::NoMonkeypatch,
            Rule::NoInlinePatch,
            Rule::NoEnvironMutation,
            Rule::NoConstantPatch,
            Rule::NoFirstPartyPatch,
            Rule::NoOutOfModuleCall,
            Rule::NoOutOfModuleImport,
            Rule::NoFirstPartyDouble,
            Rule::UnmockedCollaborator,
            Rule::UntypedMock,
            Rule::NoFirstPartyMock,
        ]
        .into_iter()
        .find(|rule| rule.id() == id)
    }
}

/// One auditable per-file exemption — a `[[<language>.exempt]]` entry.
///
/// The opposite of a silent ignore-glob: an exemption is declared in the one
/// config file, names the rules it lifts, and **must say why**. Empty
/// (comment-only) files need no entry — they carry no logic and are not
/// subjects — so this is for deliberate omissions the tool can't infer (a
/// launcher shim, generated code, a re-export barrel).
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Exemption {
    /// Path to the exempt file, relative to the scanned root.
    pub path: String,
    /// Which rules the exemption lifts (`colocated-test`, `coverage`).
    pub rules: Vec<Rule>,
    /// Why the omission is deliberate — required, and never empty.
    pub reason: String,
}

/// Read one config file at `path` into a [`Config`], validating it on the way.
///
/// The validation is the config's self-guard: `serde`'s `deny_unknown_fields`
/// rejects keys that aren't part of the schema, missing required keys and
/// wrong-typed values are type errors, malformed TOML fails to parse, and every
/// `exempt` entry must name a rule and carry a non-empty reason. Any of these
/// surfaces as an `Err` rather than a silently-accepted default.
pub fn load_config(path: impl AsRef<Path>) -> Result<Config> {
    let path = path.as_ref();
    let contents = std::fs::read_to_string(path)
        .with_context(|| format!("reading config file `{}`", path.display()))?;
    let config: Config = toml::from_str(&contents)
        .with_context(|| format!("parsing config file `{}`", path.display()))?;
    config
        .validate()
        .with_context(|| format!("validating config file `{}`", path.display()))?;
    Ok(config)
}

impl Config {
    /// The `exempt` list for `language` (empty when the table is absent).
    pub fn exemptions(&self, language: crate::colocated_test::Language) -> &[Exemption] {
        match language {
            crate::colocated_test::Language::Python => {
                self.python.as_ref().map_or(&[], |c| &c.exempt)
            }
            crate::colocated_test::Language::TypeScript => {
                self.typescript.as_ref().map_or(&[], |c| &c.exempt)
            }
            crate::colocated_test::Language::Rust => self.rust_exemptions(),
        }
    }

    /// The `[[rust.exempt]]` list (empty when the table is absent). The named
    /// accessor the Rust isolation rules (#44) waive through; equivalent to
    /// [`Self::exemptions`]`(Language::Rust)`.
    pub fn rust_exemptions(&self) -> &[Exemption] {
        self.rust.as_ref().map_or(&[], |c| &c.exempt)
    }

    /// Reject any `exempt` entry that names no rule or carries an empty reason —
    /// a reasonless or scopeless exemption can never be a silent pass.
    fn validate(&self) -> Result<()> {
        let tables = [
            ("python", self.python.as_ref().map(|c| &c.exempt)),
            ("typescript", self.typescript.as_ref().map(|c| &c.exempt)),
            ("rust", self.rust.as_ref().map(|c| &c.exempt)),
        ];
        for (table, exempt) in tables.into_iter().filter_map(|(t, e)| e.map(|e| (t, e))) {
            for entry in exempt {
                if entry.rules.is_empty() {
                    bail!(
                        "[{table}].exempt entry for `{}` names no rules — set \
                         `rules = [\"colocated-test\"]` and/or `\"coverage\"`",
                        entry.path
                    );
                }
                if entry.reason.trim().is_empty() {
                    bail!(
                        "[{table}].exempt entry for `{}` has an empty reason — \
                         every exemption must say why the file is exempt",
                        entry.path
                    );
                }
            }
        }
        Ok(())
    }
}

/// Resolve the set of exempt paths for `rule` from `exemptions`, validating that
/// each still points to a file under `root`.
///
/// A stale entry — a path that no longer exists — is an error, so the exempt
/// list can't silently rot (the auditable counterpart to an ignore-glob, which
/// would just stop matching). Returns the matching paths as `/`-joined,
/// `root`-relative strings, sorted and de-duplicated.
pub fn resolve_exempt(
    root: &Path,
    exemptions: &[Exemption],
    rule: Rule,
) -> Result<BTreeSet<String>> {
    let mut paths = BTreeSet::new();
    for entry in exemptions {
        if !entry.rules.contains(&rule) {
            continue;
        }
        if !root.join(&entry.path).is_file() {
            bail!(
                "exempt entry `{}` matches no file under `{}` — remove the stale \
                 entry or fix the path",
                entry.path,
                root.display()
            );
        }
        paths.insert(entry.path.replace('\\', "/"));
    }
    Ok(paths)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::{AtomicU64, Ordering};

    fn parse(toml_src: &str) -> Result<Config> {
        let config: Config = toml::from_str(toml_src)?;
        config.validate()?;
        Ok(config)
    }

    #[test]
    fn an_exemption_with_no_rules_is_rejected() {
        let err = parse(
            "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
             [[python.exempt]]\npath = \"cli.py\"\nrules = []\nreason = \"shim\"\n",
        )
        .unwrap_err();
        assert!(err.to_string().contains("names no rules"), "got: {err}");
    }

    #[test]
    fn an_exemption_with_an_empty_reason_is_rejected() {
        let err = parse(
            "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
             [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\nreason = \"  \"\n",
        )
        .unwrap_err();
        assert!(err.to_string().contains("empty reason"), "got: {err}");
    }

    #[test]
    fn an_unknown_rule_is_rejected() {
        assert!(parse(
            "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
             [[python.exempt]]\npath = \"cli.py\"\nrules = [\"packaging\"]\nreason = \"x\"\n",
        )
        .is_err());
    }

    #[test]
    fn default_python_coverage_is_the_reasonable_floor() {
        // The zero-config floor (#80) is the internals' reasonable one: branch on,
        // 85. Locked here so it can't silently drift from internals/python/testing.md.
        assert_eq!(
            PythonCoverage::default(),
            PythonCoverage {
                branch: true,
                fail_under: 85,
            }
        );
    }

    #[test]
    fn default_typescript_coverage_matches_internals() {
        // Matches internals/typescript/testing.md: lines/functions/statements 80,
        // branches 75 (#80).
        assert_eq!(
            TypeScriptCoverage::default(),
            TypeScriptCoverage {
                lines: 80,
                branches: 75,
                functions: 80,
                statements: 80,
            }
        );
    }

    #[test]
    fn a_valid_exemption_parses() {
        let config = parse(
            "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
             [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\", \"coverage\"]\n\
             reason = \"thin launcher\"\n",
        )
        .unwrap();
        let exempt = &config.python.unwrap().exempt;
        assert_eq!(exempt.len(), 1);
        assert_eq!(exempt[0].rules, vec![Rule::ColocatedTest, Rule::Coverage]);
    }

    #[test]
    fn exemptions_reads_the_rust_table() {
        let config = parse(
            "[[rust.exempt]]\npath = \"build.rs\"\nrules = [\"no-out-of-module-call\"]\n\
             reason = \"generated\"\n",
        )
        .unwrap();
        let rust = config.exemptions(crate::colocated_test::Language::Rust);
        assert_eq!(rust.len(), 1);
        assert_eq!(rust[0].path, "build.rs");
    }

    /// A throwaway directory tree, removed on drop.
    struct TempTree(std::path::PathBuf);

    impl TempTree {
        fn new(files: &[&str]) -> Self {
            static COUNTER: AtomicU64 = AtomicU64::new(0);
            let root = std::env::temp_dir().join(format!(
                "tc-exempt-{}-{}",
                std::process::id(),
                COUNTER.fetch_add(1, Ordering::Relaxed),
            ));
            for rel in files {
                let path = root.join(rel);
                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
                std::fs::write(path, "x = 1\n").unwrap();
            }
            TempTree(root)
        }
    }

    impl Drop for TempTree {
        fn drop(&mut self) {
            let _ = std::fs::remove_dir_all(&self.0);
        }
    }

    fn exemption(path: &str, rules: &[Rule]) -> Exemption {
        Exemption {
            path: path.to_string(),
            rules: rules.to_vec(),
            reason: "deliberate".to_string(),
        }
    }

    #[test]
    fn resolve_keeps_only_the_requested_rule_and_returns_sorted_paths() {
        let tree = TempTree::new(&["cli.py", "pkg/gen.py", "loc_only.py"]);
        let exemptions = [
            exemption("cli.py", &[Rule::ColocatedTest, Rule::Coverage]),
            exemption("pkg/gen.py", &[Rule::Coverage]),
            exemption("loc_only.py", &[Rule::ColocatedTest]),
        ];
        let coverage = resolve_exempt(&tree.0, &exemptions, Rule::Coverage).unwrap();
        assert_eq!(
            coverage.into_iter().collect::<Vec<_>>(),
            vec!["cli.py".to_string(), "pkg/gen.py".to_string()],
        );
        let colocated_test = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap();
        assert_eq!(
            colocated_test.into_iter().collect::<Vec<_>>(),
            vec!["cli.py".to_string(), "loc_only.py".to_string()],
        );
    }

    #[test]
    fn a_stale_exempt_path_is_an_error() {
        let tree = TempTree::new(&["cli.py"]);
        let exemptions = [exemption("ghost.py", &[Rule::ColocatedTest])];
        let err = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap_err();
        assert!(err.to_string().contains("matches no file"), "got: {err}");
    }
}