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}