Skip to main content

testing_conventions/
workflow.rs

1//! Workflow guard — keep the reusable workflow in step with the CLI.
2//!
3//! The reusable workflow (`.github/workflows/testing-conventions.yml`) is the
4//! documented `@v0` consumption path: a consumer pins `@v0`, and the workflow runs
5//! the *published* `testing-conventions` binary via `npx`. When a CLI subcommand is
6//! renamed or removed — e.g. `unit location` → `unit colocated-test` — but a
7//! workflow still invokes the old name, every `@v0` consumer breaks with
8//! `unrecognized subcommand`, silently: the workflow file is frozen at the tag
9//! while `npx` keeps pulling the latest binary.
10//!
11//! This module is the deterministic guard against that drift. [`invocations`]
12//! extracts every `testing-conventions …` call from a workflow file's shell, and
13//! [`unknown_subcommands`] checks each one's subcommand chain against the binary's
14//! own command tree (the source of truth, [`crate::command`]), flagging any chain
15//! the binary no longer exposes. Run in CI against the reusable workflow it fails
16//! the build the moment a workflow and the CLI fall out of step — before a release
17//! can strand `@v0`.
18//!
19//! Extraction is a line-based, shell-aware scan, not a full GitHub Actions parser:
20//! it tokenizes each non-comment line, finds the `testing-conventions` binary token
21//! (the bare command word, optionally version-pinned `…@x` /
22//! `…${VERSION:+@$VERSION}` — the `npx` / on-`PATH` form the reusable workflow and
23//! the docs use) in *command position*, and reads the tokens after it as the
24//! invocation. Command position is the bright-line that separates a real call from
25//! the tool named as an argument to another command — `pip install
26//! testing-conventions pytest` installs it as a dependency, and is not an
27//! invocation. A path-qualified invocation (`./bin/testing-conventions`), a
28//! subcommand split across a `\`-continuation, or one named in non-`run:` prose is a
29//! documented limit.
30
31use std::path::{Path, PathBuf};
32
33use anyhow::{Context, Result};
34
35use crate::violation::Violation;
36
37/// A single `testing-conventions` invocation found in a workflow file.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Invocation {
40    /// Workflow file the invocation was found in.
41    pub file: PathBuf,
42    /// 1-based line of the invocation.
43    pub line: usize,
44    /// Tokens after the `testing-conventions` binary name, in order — the
45    /// subcommand chain first, then flags / values / positionals.
46    pub args: Vec<String>,
47}
48
49/// Walk `path` — a workflow file, or a directory of them — and return every
50/// `testing-conventions` invocation, in file-then-line order.
51///
52/// Directories are scanned recursively for `*.yml` / `*.yaml` files (sorted, for
53/// deterministic output). Returns an error if a file or directory cannot be read.
54pub fn invocations(path: impl AsRef<Path>) -> Result<Vec<Invocation>> {
55    let path = path.as_ref();
56    let mut files = Vec::new();
57    collect_workflow_files(path, &mut files)?;
58    files.sort();
59    let mut out = Vec::new();
60    for file in files {
61        let text = std::fs::read_to_string(&file)
62            .with_context(|| format!("reading workflow `{}`", file.display()))?;
63        for (i, line) in text.lines().enumerate() {
64            if let Some(args) = line_invocation(line) {
65                out.push(Invocation {
66                    file: file.clone(),
67                    line: i + 1,
68                    args,
69                });
70            }
71        }
72    }
73    Ok(out)
74}
75
76/// Collect workflow files under `path` into `out`: `path` itself when it is a
77/// file, else every `*.yml` / `*.yaml` under it, recursively.
78fn collect_workflow_files(path: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
79    if path.is_file() {
80        out.push(path.to_path_buf());
81        return Ok(());
82    }
83    let entries = std::fs::read_dir(path)
84        .with_context(|| format!("reading directory `{}`", path.display()))?;
85    for entry in entries {
86        let entry =
87            entry.with_context(|| format!("reading an entry under `{}`", path.display()))?;
88        let child = entry.path();
89        if child.is_dir() {
90            collect_workflow_files(&child, out)?;
91        } else if is_workflow_file(&child) {
92            out.push(child);
93        }
94    }
95    Ok(())
96}
97
98/// `true` when `path` has a `.yml` / `.yaml` extension (a GitHub Actions workflow).
99fn is_workflow_file(path: &Path) -> bool {
100    matches!(
101        path.extension().and_then(|e| e.to_str()),
102        Some("yml" | "yaml")
103    )
104}
105
106/// The args of a `testing-conventions` invocation on `line`, or `None` if the
107/// line has no such call. Comments are ignored and surrounding quotes stripped.
108///
109/// The binary token counts only when it is in *command position* — the command
110/// being run, not an argument to another command — so a package-install line
111/// (`pip install testing-conventions pytest`) is not read as an invocation whose
112/// trailing token would be validated as a subcommand.
113fn line_invocation(line: &str) -> Option<Vec<String>> {
114    let tokens = tokenize(line);
115    let pos = tokens.iter().position(|t| is_binary_token(t))?;
116    if !is_command_position(&tokens, pos) {
117        return None;
118    }
119    Some(tokens[pos + 1..].to_vec())
120}
121
122/// `true` when the binary token at `pos` is in *command position*: the command
123/// being run, not an argument to another command. After stepping back over an
124/// `npx` launcher and its option flags, the preceding token must mark a command
125/// boundary — nothing (start of the shell), the YAML `run:` scalar lead-in, or a
126/// shell command separator. A token governed by any other command word
127/// (`pip install …`, `cp … testing-conventions`) is an argument, not an invocation.
128fn is_command_position(tokens: &[String], pos: usize) -> bool {
129    let mut i = pos;
130    // Step back over an `npx` launcher — its option flags, then `npx` itself — so
131    // the documented `npx -y testing-conventions …` form reads as command position.
132    if i > 0 {
133        let mut j = i;
134        while j > 0 && tokens[j - 1].starts_with('-') {
135            j -= 1;
136        }
137        if j > 0 && tokens[j - 1] == "npx" {
138            i = j - 1;
139        }
140    }
141    match i.checked_sub(1) {
142        None => true,
143        Some(prev) => is_command_boundary(&tokens[prev]),
144    }
145}
146
147/// `true` when `token` ends a command so the next token starts a new one: the YAML
148/// `run:` scalar lead-in, or a shell command separator.
149fn is_command_boundary(token: &str) -> bool {
150    matches!(token, "run:" | "&&" | "||" | "|" | ";" | "&" | "(" | "{")
151}
152
153/// `true` when `token` is the `testing-conventions` binary as a command word: bare,
154/// or version-pinned (`testing-conventions@0.1.0`,
155/// `testing-conventions${VERSION:+@$VERSION}`).
156///
157/// Only the bare command word is matched — the `npx` / on-`PATH` form the reusable
158/// workflow and the "roll your own" docs use. A path-qualified token
159/// (`packages/…/testing-conventions`, a `cp` / `install` argument) is deliberately
160/// *not* matched, so a path that merely ends in the binary name isn't read as an
161/// invocation.
162fn is_binary_token(token: &str) -> bool {
163    // Strip any version pin / shell expansion suffix, then require an exact match.
164    let end = [token.find('@'), token.find("${")]
165        .into_iter()
166        .flatten()
167        .min()
168        .unwrap_or(token.len());
169    &token[..end] == "testing-conventions"
170}
171
172/// Split `line` into shell-ish tokens: whitespace separates, `'…'` and `"…"`
173/// group (and are stripped), and an unquoted `#` starting a token begins a comment
174/// that runs to end of line.
175fn tokenize(line: &str) -> Vec<String> {
176    let mut tokens = Vec::new();
177    let mut cur = String::new();
178    let mut started = false;
179    let mut quote: Option<char> = None;
180    for c in line.chars() {
181        match quote {
182            Some(q) => {
183                if c == q {
184                    quote = None;
185                } else {
186                    cur.push(c);
187                }
188            }
189            None => match c {
190                '#' if !started => break,
191                '\'' | '"' => {
192                    quote = Some(c);
193                    started = true;
194                }
195                c if c.is_whitespace() => {
196                    if started {
197                        tokens.push(std::mem::take(&mut cur));
198                        started = false;
199                    }
200                }
201                c => {
202                    cur.push(c);
203                    started = true;
204                }
205            },
206        }
207    }
208    if started {
209        tokens.push(cur);
210    }
211    tokens
212}
213
214/// Of `invocations`, the ones whose subcommand chain names a subcommand the binary
215/// — described by `root`, its clap command tree — no longer exposes.
216///
217/// Each invocation's leading tokens are walked against the tree: a token in a
218/// subcommand position (the current command takes subcommands) must name one of
219/// them, else it is flagged. A global flag (`-…`) before the subcommand is skipped
220/// — together with its value when the command declares the option as value-taking —
221/// so a subcommand after a leading flag is still validated. The walk stops at the
222/// first command that takes positionals rather than subcommands, so a path argument
223/// is never mistaken for a subcommand.
224pub fn unknown_subcommands(invocations: &[Invocation], root: &clap::Command) -> Vec<Violation> {
225    let mut out = Vec::new();
226    for inv in invocations {
227        let mut node = root;
228        let mut i = 0;
229        while i < inv.args.len() {
230            // A command that takes positionals (not subcommands) means the
231            // remaining tokens are arguments, not a subcommand chain to validate.
232            if !node.has_subcommands() {
233                break;
234            }
235            let tok = &inv.args[i];
236            if tok.starts_with('-') {
237                // A global flag before the subcommand: skip it (and its value, when
238                // the command declares it value-taking) rather than ending the walk.
239                i += if flag_takes_value(node, tok) { 2 } else { 1 };
240                continue;
241            }
242            match node.find_subcommand(tok.as_str()) {
243                Some(sub) => {
244                    node = sub;
245                    i += 1;
246                }
247                None => {
248                    out.push(Violation {
249                        file: inv.file.clone(),
250                        line: inv.line,
251                        rule: "no-unknown-subcommand",
252                        message: format!(
253                            "`{}` is not a `{}` subcommand — the published binary no longer exposes it",
254                            tok,
255                            node.get_name()
256                        ),
257                    });
258                    break;
259                }
260            }
261        }
262    }
263    out
264}
265
266/// `true` when `token` (a `-…` / `--…` flag) is an option of `node` that consumes a
267/// following value, so the subcommand walk must skip that value too. An inline
268/// `--flag=value` carries its own value and consumes no following token.
269fn flag_takes_value(node: &clap::Command, token: &str) -> bool {
270    if token.contains('=') {
271        return false;
272    }
273    let name = token.trim_start_matches('-');
274    node.get_arguments().any(|arg| {
275        let matches_long = arg.get_long() == Some(name);
276        let matches_short = name.len() == 1 && arg.get_short().is_some_and(|c| name.starts_with(c));
277        (matches_long || matches_short)
278            && matches!(
279                arg.get_action(),
280                clap::ArgAction::Set | clap::ArgAction::Append
281            )
282    })
283}
284
285/// Check `path` (a workflow file or directory): every `testing-conventions`
286/// invocation must name a subcommand `root` still exposes. Returns one
287/// [`Violation`] per offending invocation.
288pub fn check(path: impl AsRef<Path>, root: &clap::Command) -> Result<Vec<Violation>> {
289    Ok(unknown_subcommands(&invocations(path)?, root))
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use std::sync::atomic::{AtomicU64, Ordering};
296
297    /// A throwaway directory tree, removed on drop.
298    struct TempTree(PathBuf);
299
300    impl TempTree {
301        fn new(files: &[(&str, &str)]) -> Self {
302            static COUNTER: AtomicU64 = AtomicU64::new(0);
303            let root = std::env::temp_dir().join(format!(
304                "tc-workflow-{}-{}",
305                std::process::id(),
306                COUNTER.fetch_add(1, Ordering::Relaxed),
307            ));
308            for (rel, content) in files {
309                let path = root.join(rel);
310                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
311                std::fs::write(path, content).unwrap();
312            }
313            TempTree(root)
314        }
315
316        fn path(&self) -> &Path {
317            &self.0
318        }
319    }
320
321    impl Drop for TempTree {
322        fn drop(&mut self) {
323            let _ = std::fs::remove_dir_all(&self.0);
324        }
325    }
326
327    #[test]
328    fn tokenize_strips_quotes_and_groups() {
329        assert_eq!(
330            tokenize(r#"npx -y "testing-conventions${VERSION:+@$VERSION}" unit coverage"#),
331            vec![
332                "npx",
333                "-y",
334                "testing-conventions${VERSION:+@$VERSION}",
335                "unit",
336                "coverage",
337            ]
338        );
339    }
340
341    #[test]
342    fn tokenize_stops_at_a_comment() {
343        assert_eq!(
344            tokenize("      # run testing-conventions later"),
345            Vec::<String>::new()
346        );
347        assert_eq!(
348            tokenize("testing-conventions check  # trailing note"),
349            vec!["testing-conventions", "check"]
350        );
351    }
352
353    #[test]
354    fn is_binary_token_accepts_the_command_word() {
355        assert!(is_binary_token("testing-conventions"));
356        assert!(is_binary_token("testing-conventions@0.1.0"));
357        assert!(is_binary_token("testing-conventions${VERSION:+@$VERSION}"));
358    }
359
360    #[test]
361    fn is_binary_token_rejects_lookalikes() {
362        assert!(!is_binary_token("testing-conventions.toml"));
363        assert!(!is_binary_token("testing-conventions.yml@v0"));
364        assert!(!is_binary_token("actions/checkout@v6"));
365        assert!(!is_binary_token("npx"));
366        // Path-qualified tokens — e.g. a `cp` / `install` argument — are not
367        // invocations, even when they end in the binary name.
368        assert!(!is_binary_token(
369            "packages/rust/target/release/testing-conventions"
370        ));
371        assert!(!is_binary_token("$target/bin/testing-conventions"));
372        assert!(!is_binary_token("./target/release/testing-conventions"));
373    }
374
375    #[test]
376    fn line_invocation_reads_the_args_after_the_binary() {
377        assert_eq!(
378            line_invocation(
379                "- run: npx -y testing-conventions unit location --language python src"
380            ),
381            Some(vec![
382                "unit".to_string(),
383                "location".to_string(),
384                "--language".to_string(),
385                "python".to_string(),
386                "src".to_string(),
387            ])
388        );
389        assert_eq!(line_invocation("- uses: actions/checkout@v6"), None);
390    }
391
392    #[test]
393    fn line_invocation_ignores_a_package_install_line() {
394        // `testing-conventions` as an *argument* to a package manager — the tool
395        // being installed as a dependency, not run — is not an invocation. A
396        // `pip install testing-conventions pytest` line would otherwise read
397        // `pytest` as a subcommand and spuriously flag it.
398        assert_eq!(
399            line_invocation("- run: pip install testing-conventions pytest"),
400            None
401        );
402        assert_eq!(
403            line_invocation("- run: npm install -D testing-conventions"),
404            None
405        );
406        assert_eq!(
407            line_invocation("- run: cargo install testing-conventions"),
408            None
409        );
410        // The real command-position forms still read as invocations.
411        assert_eq!(
412            line_invocation("- run: testing-conventions check"),
413            Some(vec!["check".to_string()])
414        );
415        assert_eq!(
416            line_invocation("- run: npx -y testing-conventions check"),
417            Some(vec!["check".to_string()])
418        );
419    }
420
421    #[test]
422    fn unknown_subcommands_validates_across_leading_global_flags() {
423        // A global flag (and its value) before the subcommand must not stop the
424        // walk: the subcommand after it is still validated. Modeled with a
425        // small command tree carrying a value-taking `--config` global.
426        let root = clap::Command::new("tc")
427            .arg(
428                clap::Arg::new("config")
429                    .long("config")
430                    .action(clap::ArgAction::Set),
431            )
432            .subcommand(clap::Command::new("unit").subcommand(clap::Command::new("coverage")));
433        // `location` is not a `unit` subcommand — flagged despite the leading
434        // `--config x`.
435        let flagged = unknown_subcommands(&[inv(1, &["--config", "x", "unit", "location"])], &root);
436        assert_eq!(flagged.len(), 1, "{flagged:?}");
437        assert!(
438            flagged[0].message.contains("location"),
439            "{}",
440            flagged[0].message
441        );
442        // A live subcommand after the same flag stays clean — the flag's value `x`
443        // is not mistaken for a subcommand.
444        assert!(
445            unknown_subcommands(&[inv(2, &["--config", "x", "unit", "coverage"])], &root)
446                .is_empty()
447        );
448    }
449
450    #[test]
451    fn invocations_scans_a_file_and_a_directory() {
452        let tree = TempTree::new(&[
453            ("ci.yml", "- run: testing-conventions check\n"),
454            (
455                "nested/more.yaml",
456                "- run: testing-conventions unit lint --language rust .\n",
457            ),
458            ("notes.txt", "testing-conventions check\n"),
459        ]);
460        // Directory: both workflow files, not the .txt; sorted file-then-line.
461        let dir = invocations(tree.path()).unwrap();
462        assert_eq!(dir.len(), 2);
463        assert_eq!(dir[0].args, vec!["check"]);
464        assert_eq!(dir[0].line, 1);
465        // Single file: just that file.
466        let file = invocations(tree.path().join("ci.yml")).unwrap();
467        assert_eq!(file.len(), 1);
468    }
469
470    #[test]
471    fn invocations_errors_on_a_missing_path() {
472        let missing = std::env::temp_dir().join("tc-workflow-does-not-exist-2b1c");
473        assert!(invocations(&missing).is_err());
474    }
475
476    /// An [`Invocation`] from a bare token list (file/line are placeholders).
477    fn inv(line: usize, args: &[&str]) -> Invocation {
478        Invocation {
479            file: PathBuf::from("ci.yml"),
480            line,
481            args: args.iter().map(|s| s.to_string()).collect(),
482        }
483    }
484
485    #[test]
486    fn unknown_subcommands_flags_a_renamed_nested_rule() {
487        let v = unknown_subcommands(
488            &[inv(9, &["unit", "location", "--language", "python", "src"])],
489            &crate::command(),
490        );
491        assert_eq!(v.len(), 1);
492        assert_eq!(v[0].line, 9);
493        assert_eq!(v[0].rule, "no-unknown-subcommand");
494        // Named under its parent group, not the root.
495        assert!(v[0].message.contains("`location`"), "{}", v[0].message);
496        assert!(v[0].message.contains("`unit`"), "{}", v[0].message);
497    }
498
499    #[test]
500    fn unknown_subcommands_flags_a_removed_top_level_command() {
501        let v = unknown_subcommands(
502            &[inv(1, &["unit-location", "--lang", "python", "src"])],
503            &crate::command(),
504        );
505        assert_eq!(v.len(), 1);
506        assert!(v[0].message.contains("`unit-location`"), "{}", v[0].message);
507        assert!(
508            v[0].message.contains("`testing-conventions`"),
509            "{}",
510            v[0].message
511        );
512    }
513
514    #[test]
515    fn unknown_subcommands_accepts_every_live_invocation() {
516        let invs = [
517            inv(
518                1,
519                &["unit", "colocated-test", "--language", "python", "src"],
520            ),
521            inv(2, &["unit", "coverage", "--language", "typescript", "src"]),
522            inv(3, &["unit", "lint", "--language", "rust", "."]),
523            inv(4, &["integration", "lint", "--language", "python", "src"]),
524            // A leaf's positional must not be read as a subcommand.
525            inv(5, &["packaging", "--language", "python", "dist"]),
526            inv(6, &["check"]),
527            // Flags-only and empty invocations have no subcommand to check.
528            inv(7, &["--version"]),
529            inv(8, &[]),
530        ];
531        assert!(unknown_subcommands(&invs, &crate::command()).is_empty());
532    }
533}