testing-conventions 0.0.68

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
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
//! Workflow guard — keep the reusable workflow in step with the CLI.
//!
//! The reusable workflow (`.github/workflows/testing-conventions.yml`) is the
//! documented `@v0` consumption path: a consumer pins `@v0`, and the workflow runs
//! the *published* `testing-conventions` binary via `npx`. When a CLI subcommand is
//! renamed or removed — e.g. `unit location` → `unit colocated-test` — but a
//! workflow still invokes the old name, every `@v0` consumer breaks with
//! `unrecognized subcommand`, silently: the workflow file is frozen at the tag
//! while `npx` keeps pulling the latest binary.
//!
//! This module is the deterministic guard against that drift. [`invocations`]
//! extracts every `testing-conventions …` call from a workflow file's shell, and
//! [`unknown_subcommands`] checks each one's subcommand chain against the binary's
//! own command tree (the source of truth, [`crate::command`]), flagging any chain
//! the binary no longer exposes. Run in CI against the reusable workflow it fails
//! the build the moment a workflow and the CLI fall out of step — before a release
//! can strand `@v0`.
//!
//! Extraction is a line-based, shell-aware scan, not a full GitHub Actions parser:
//! it tokenizes each non-comment line, finds the `testing-conventions` binary token
//! (the bare command word, optionally version-pinned `…@x` /
//! `…${VERSION:+@$VERSION}` — the `npx` / on-`PATH` form the reusable workflow and
//! the docs use) in *command position*, and reads the tokens after it as the
//! invocation. Command position is the bright-line that separates a real call from
//! the tool named as an argument to another command — `pip install
//! testing-conventions pytest` installs it as a dependency, and is not an
//! invocation. A path-qualified invocation (`./bin/testing-conventions`), a
//! subcommand split across a `\`-continuation, or one named in non-`run:` prose is a
//! documented limit.

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};

use crate::violation::Violation;

/// A single `testing-conventions` invocation found in a workflow file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Invocation {
    /// Workflow file the invocation was found in.
    pub file: PathBuf,
    /// 1-based line of the invocation.
    pub line: usize,
    /// Tokens after the `testing-conventions` binary name, in order — the
    /// subcommand chain first, then flags / values / positionals.
    pub args: Vec<String>,
}

/// Walk `path` — a workflow file, or a directory of them — and return every
/// `testing-conventions` invocation, in file-then-line order.
///
/// Directories are scanned recursively for `*.yml` / `*.yaml` files (sorted, for
/// deterministic output). Returns an error if a file or directory cannot be read.
pub fn invocations(path: impl AsRef<Path>) -> Result<Vec<Invocation>> {
    let path = path.as_ref();
    let mut files = Vec::new();
    collect_workflow_files(path, &mut files)?;
    files.sort();
    let mut out = Vec::new();
    for file in files {
        let text = std::fs::read_to_string(&file)
            .with_context(|| format!("reading workflow `{}`", file.display()))?;
        for (i, line) in text.lines().enumerate() {
            if let Some(args) = line_invocation(line) {
                out.push(Invocation {
                    file: file.clone(),
                    line: i + 1,
                    args,
                });
            }
        }
    }
    Ok(out)
}

/// Collect workflow files under `path` into `out`: `path` itself when it is a
/// file, else every `*.yml` / `*.yaml` under it, recursively.
fn collect_workflow_files(path: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
    if path.is_file() {
        out.push(path.to_path_buf());
        return Ok(());
    }
    let entries = std::fs::read_dir(path)
        .with_context(|| format!("reading directory `{}`", path.display()))?;
    for entry in entries {
        let entry =
            entry.with_context(|| format!("reading an entry under `{}`", path.display()))?;
        let child = entry.path();
        if child.is_dir() {
            collect_workflow_files(&child, out)?;
        } else if is_workflow_file(&child) {
            out.push(child);
        }
    }
    Ok(())
}

/// `true` when `path` has a `.yml` / `.yaml` extension (a GitHub Actions workflow).
fn is_workflow_file(path: &Path) -> bool {
    matches!(
        path.extension().and_then(|e| e.to_str()),
        Some("yml" | "yaml")
    )
}

/// The args of a `testing-conventions` invocation on `line`, or `None` if the
/// line has no such call. Comments are ignored and surrounding quotes stripped.
///
/// The binary token counts only when it is in *command position* — the command
/// being run, not an argument to another command — so a package-install line
/// (`pip install testing-conventions pytest`) is not read as an invocation whose
/// trailing token would be validated as a subcommand.
fn line_invocation(line: &str) -> Option<Vec<String>> {
    let tokens = tokenize(line);
    let pos = tokens.iter().position(|t| is_binary_token(t))?;
    if !is_command_position(&tokens, pos) {
        return None;
    }
    Some(tokens[pos + 1..].to_vec())
}

/// `true` when the binary token at `pos` is in *command position*: the command
/// being run, not an argument to another command. After stepping back over an
/// `npx` launcher and its option flags, the preceding token must mark a command
/// boundary — nothing (start of the shell), the YAML `run:` scalar lead-in, or a
/// shell command separator. A token governed by any other command word
/// (`pip install …`, `cp … testing-conventions`) is an argument, not an invocation.
fn is_command_position(tokens: &[String], pos: usize) -> bool {
    let mut i = pos;
    // Step back over an `npx` launcher — its option flags, then `npx` itself — so
    // the documented `npx -y testing-conventions …` form reads as command position.
    if i > 0 {
        let mut j = i;
        while j > 0 && tokens[j - 1].starts_with('-') {
            j -= 1;
        }
        if j > 0 && tokens[j - 1] == "npx" {
            i = j - 1;
        }
    }
    match i.checked_sub(1) {
        None => true,
        Some(prev) => is_command_boundary(&tokens[prev]),
    }
}

/// `true` when `token` ends a command so the next token starts a new one: the YAML
/// `run:` scalar lead-in, or a shell command separator.
fn is_command_boundary(token: &str) -> bool {
    matches!(token, "run:" | "&&" | "||" | "|" | ";" | "&" | "(" | "{")
}

/// `true` when `token` is the `testing-conventions` binary as a command word: bare,
/// or version-pinned (`testing-conventions@0.1.0`,
/// `testing-conventions${VERSION:+@$VERSION}`).
///
/// Only the bare command word is matched — the `npx` / on-`PATH` form the reusable
/// workflow and the "roll your own" docs use. A path-qualified token
/// (`packages/…/testing-conventions`, a `cp` / `install` argument) is deliberately
/// *not* matched, so a path that merely ends in the binary name isn't read as an
/// invocation.
fn is_binary_token(token: &str) -> bool {
    // Strip any version pin / shell expansion suffix, then require an exact match.
    let end = [token.find('@'), token.find("${")]
        .into_iter()
        .flatten()
        .min()
        .unwrap_or(token.len());
    &token[..end] == "testing-conventions"
}

/// Split `line` into shell-ish tokens: whitespace separates, `'…'` and `"…"`
/// group (and are stripped), and an unquoted `#` starting a token begins a comment
/// that runs to end of line.
fn tokenize(line: &str) -> Vec<String> {
    let mut tokens = Vec::new();
    let mut cur = String::new();
    let mut started = false;
    let mut quote: Option<char> = None;
    for c in line.chars() {
        match quote {
            Some(q) => {
                if c == q {
                    quote = None;
                } else {
                    cur.push(c);
                }
            }
            None => match c {
                '#' if !started => break,
                '\'' | '"' => {
                    quote = Some(c);
                    started = true;
                }
                c if c.is_whitespace() => {
                    if started {
                        tokens.push(std::mem::take(&mut cur));
                        started = false;
                    }
                }
                c => {
                    cur.push(c);
                    started = true;
                }
            },
        }
    }
    if started {
        tokens.push(cur);
    }
    tokens
}

/// Of `invocations`, the ones whose subcommand chain names a subcommand the binary
/// — described by `root`, its clap command tree — no longer exposes.
///
/// Each invocation's leading tokens are walked against the tree: a token in a
/// subcommand position (the current command takes subcommands) must name one of
/// them, else it is flagged. A global flag (`-…`) before the subcommand is skipped
/// — together with its value when the command declares the option as value-taking —
/// so a subcommand after a leading flag is still validated. The walk stops at the
/// first command that takes positionals rather than subcommands, so a path argument
/// is never mistaken for a subcommand.
pub fn unknown_subcommands(invocations: &[Invocation], root: &clap::Command) -> Vec<Violation> {
    let mut out = Vec::new();
    for inv in invocations {
        let mut node = root;
        let mut i = 0;
        while i < inv.args.len() {
            // A command that takes positionals (not subcommands) means the
            // remaining tokens are arguments, not a subcommand chain to validate.
            if !node.has_subcommands() {
                break;
            }
            let tok = &inv.args[i];
            if tok.starts_with('-') {
                // A global flag before the subcommand: skip it (and its value, when
                // the command declares it value-taking) rather than ending the walk.
                i += if flag_takes_value(node, tok) { 2 } else { 1 };
                continue;
            }
            match node.find_subcommand(tok.as_str()) {
                Some(sub) => {
                    node = sub;
                    i += 1;
                }
                None => {
                    out.push(Violation {
                        file: inv.file.clone(),
                        line: inv.line,
                        rule: "no-unknown-subcommand",
                        message: format!(
                            "`{}` is not a `{}` subcommand — the published binary no longer exposes it",
                            tok,
                            node.get_name()
                        ),
                    });
                    break;
                }
            }
        }
    }
    out
}

/// `true` when `token` (a `-…` / `--…` flag) is an option of `node` that consumes a
/// following value, so the subcommand walk must skip that value too. An inline
/// `--flag=value` carries its own value and consumes no following token.
fn flag_takes_value(node: &clap::Command, token: &str) -> bool {
    if token.contains('=') {
        return false;
    }
    let name = token.trim_start_matches('-');
    node.get_arguments().any(|arg| {
        let matches_long = arg.get_long() == Some(name);
        let matches_short = name.len() == 1 && arg.get_short().is_some_and(|c| name.starts_with(c));
        (matches_long || matches_short)
            && matches!(
                arg.get_action(),
                clap::ArgAction::Set | clap::ArgAction::Append
            )
    })
}

/// Check `path` (a workflow file or directory): every `testing-conventions`
/// invocation must name a subcommand `root` still exposes. Returns one
/// [`Violation`] per offending invocation.
pub fn check(path: impl AsRef<Path>, root: &clap::Command) -> Result<Vec<Violation>> {
    Ok(unknown_subcommands(&invocations(path)?, root))
}

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

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

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

        fn path(&self) -> &Path {
            &self.0
        }
    }

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

    #[test]
    fn tokenize_strips_quotes_and_groups() {
        assert_eq!(
            tokenize(r#"npx -y "testing-conventions${VERSION:+@$VERSION}" unit coverage"#),
            vec![
                "npx",
                "-y",
                "testing-conventions${VERSION:+@$VERSION}",
                "unit",
                "coverage",
            ]
        );
    }

    #[test]
    fn tokenize_stops_at_a_comment() {
        assert_eq!(
            tokenize("      # run testing-conventions later"),
            Vec::<String>::new()
        );
        assert_eq!(
            tokenize("testing-conventions check  # trailing note"),
            vec!["testing-conventions", "check"]
        );
    }

    #[test]
    fn is_binary_token_accepts_the_command_word() {
        assert!(is_binary_token("testing-conventions"));
        assert!(is_binary_token("testing-conventions@0.1.0"));
        assert!(is_binary_token("testing-conventions${VERSION:+@$VERSION}"));
    }

    #[test]
    fn is_binary_token_rejects_lookalikes() {
        assert!(!is_binary_token("testing-conventions.toml"));
        assert!(!is_binary_token("testing-conventions.yml@v0"));
        assert!(!is_binary_token("actions/checkout@v6"));
        assert!(!is_binary_token("npx"));
        // Path-qualified tokens — e.g. a `cp` / `install` argument — are not
        // invocations, even when they end in the binary name.
        assert!(!is_binary_token(
            "packages/rust/target/release/testing-conventions"
        ));
        assert!(!is_binary_token("$target/bin/testing-conventions"));
        assert!(!is_binary_token("./target/release/testing-conventions"));
    }

    #[test]
    fn line_invocation_reads_the_args_after_the_binary() {
        assert_eq!(
            line_invocation(
                "- run: npx -y testing-conventions unit location --language python src"
            ),
            Some(vec![
                "unit".to_string(),
                "location".to_string(),
                "--language".to_string(),
                "python".to_string(),
                "src".to_string(),
            ])
        );
        assert_eq!(line_invocation("- uses: actions/checkout@v6"), None);
    }

    #[test]
    fn line_invocation_ignores_a_package_install_line() {
        // `testing-conventions` as an *argument* to a package manager — the tool
        // being installed as a dependency, not run — is not an invocation. A
        // `pip install testing-conventions pytest` line would otherwise read
        // `pytest` as a subcommand and spuriously flag it.
        assert_eq!(
            line_invocation("- run: pip install testing-conventions pytest"),
            None
        );
        assert_eq!(
            line_invocation("- run: npm install -D testing-conventions"),
            None
        );
        assert_eq!(
            line_invocation("- run: cargo install testing-conventions"),
            None
        );
        // The real command-position forms still read as invocations.
        assert_eq!(
            line_invocation("- run: testing-conventions check"),
            Some(vec!["check".to_string()])
        );
        assert_eq!(
            line_invocation("- run: npx -y testing-conventions check"),
            Some(vec!["check".to_string()])
        );
    }

    #[test]
    fn unknown_subcommands_validates_across_leading_global_flags() {
        // A global flag (and its value) before the subcommand must not stop the
        // walk: the subcommand after it is still validated. Modeled with a
        // small command tree carrying a value-taking `--config` global.
        let root = clap::Command::new("tc")
            .arg(
                clap::Arg::new("config")
                    .long("config")
                    .action(clap::ArgAction::Set),
            )
            .subcommand(clap::Command::new("unit").subcommand(clap::Command::new("coverage")));
        // `location` is not a `unit` subcommand — flagged despite the leading
        // `--config x`.
        let flagged = unknown_subcommands(&[inv(1, &["--config", "x", "unit", "location"])], &root);
        assert_eq!(flagged.len(), 1, "{flagged:?}");
        assert!(
            flagged[0].message.contains("location"),
            "{}",
            flagged[0].message
        );
        // A live subcommand after the same flag stays clean — the flag's value `x`
        // is not mistaken for a subcommand.
        assert!(
            unknown_subcommands(&[inv(2, &["--config", "x", "unit", "coverage"])], &root)
                .is_empty()
        );
    }

    #[test]
    fn invocations_scans_a_file_and_a_directory() {
        let tree = TempTree::new(&[
            ("ci.yml", "- run: testing-conventions check\n"),
            (
                "nested/more.yaml",
                "- run: testing-conventions unit lint --language rust .\n",
            ),
            ("notes.txt", "testing-conventions check\n"),
        ]);
        // Directory: both workflow files, not the .txt; sorted file-then-line.
        let dir = invocations(tree.path()).unwrap();
        assert_eq!(dir.len(), 2);
        assert_eq!(dir[0].args, vec!["check"]);
        assert_eq!(dir[0].line, 1);
        // Single file: just that file.
        let file = invocations(tree.path().join("ci.yml")).unwrap();
        assert_eq!(file.len(), 1);
    }

    #[test]
    fn invocations_errors_on_a_missing_path() {
        let missing = std::env::temp_dir().join("tc-workflow-does-not-exist-2b1c");
        assert!(invocations(&missing).is_err());
    }

    /// An [`Invocation`] from a bare token list (file/line are placeholders).
    fn inv(line: usize, args: &[&str]) -> Invocation {
        Invocation {
            file: PathBuf::from("ci.yml"),
            line,
            args: args.iter().map(|s| s.to_string()).collect(),
        }
    }

    #[test]
    fn unknown_subcommands_flags_a_renamed_nested_rule() {
        let v = unknown_subcommands(
            &[inv(9, &["unit", "location", "--language", "python", "src"])],
            &crate::command(),
        );
        assert_eq!(v.len(), 1);
        assert_eq!(v[0].line, 9);
        assert_eq!(v[0].rule, "no-unknown-subcommand");
        // Named under its parent group, not the root.
        assert!(v[0].message.contains("`location`"), "{}", v[0].message);
        assert!(v[0].message.contains("`unit`"), "{}", v[0].message);
    }

    #[test]
    fn unknown_subcommands_flags_a_removed_top_level_command() {
        let v = unknown_subcommands(
            &[inv(1, &["unit-location", "--lang", "python", "src"])],
            &crate::command(),
        );
        assert_eq!(v.len(), 1);
        assert!(v[0].message.contains("`unit-location`"), "{}", v[0].message);
        assert!(
            v[0].message.contains("`testing-conventions`"),
            "{}",
            v[0].message
        );
    }

    #[test]
    fn unknown_subcommands_accepts_every_live_invocation() {
        let invs = [
            inv(
                1,
                &["unit", "colocated-test", "--language", "python", "src"],
            ),
            inv(2, &["unit", "coverage", "--language", "typescript", "src"]),
            inv(3, &["unit", "lint", "--language", "rust", "."]),
            inv(4, &["integration", "lint", "--language", "python", "src"]),
            // A leaf's positional must not be read as a subcommand.
            inv(5, &["packaging", "--language", "python", "dist"]),
            inv(6, &["check"]),
            // Flags-only and empty invocations have no subcommand to check.
            inv(7, &["--version"]),
            inv(8, &[]),
        ];
        assert!(unknown_subcommands(&invs, &crate::command()).is_empty());
    }
}