Skip to main content

amont_runtime/hooks/
tree_parity.rs

1//! pre-commit-tree-parity — a CI step skipped on `tree-<name>` must run exactly
2//! that gate's command (ADR-0024, `ci.skip-needs-the-same-command`).
3//!
4//! An attestation names a gate, and a gate is worth only the command behind
5//! it. If a workflow's gated `run:` drifts from the `tree` line in
6//! `amont.conf`, CI keeps skipping a step whose command nobody proved. This
7//! check compares the two on every commit that touches either, and CI runs it
8//! too (`amont tree-parity`, never skipped), so a `--no-verify` drift still
9//! fails.
10//!
11//! amont has no dependencies, so this is not a YAML parser. It reads the one
12//! shape workflow files take — `jobs:` → a job → `steps:` → `- ` items — line
13//! by line, and **fails closed**: anything it cannot read with certainty on a
14//! gated step is a problem, never a guess. That covers:
15//!
16//! - a block scalar;
17//! - an anchor or alias;
18//! - a quoted form with escapes;
19//! - `${{ }}` interpolation;
20//! - a step-level `env:` or `shell:`, or a `working-directory:` the gate does
21//!   not declare as `cwd=`;
22//! - any `env:` or `defaults:` a gated step would inherit from its job or its
23//!   workflow — with one exception: an inherited `defaults: run: shell: bash`
24//!   is accepted when the gated command has no pipe, list, redirection or
25//!   substitution, because bash's `-e` and `pipefail` cannot change the verdict
26//!   of a simple command (a decision of the person, 2026-09-30: the fleet sets
27//!   it workflow-wide on purpose).
28//!
29//! A gate that needs one of these does not attest.
30
31use std::path::Path;
32
33use crate::check::Outcome;
34use crate::hooks::common::{fail, hl, ok, repo_root};
35use crate::manifest::{normalize_command, tree_gates, TreeGate, MANIFEST};
36
37/// Where workflows live, per forge.
38pub const WORKFLOW_DIRS: &[&str] = &[".github/workflows", ".forgejo/workflows"];
39
40/// One thing wrong, where it is.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Problem {
43    pub file: String,
44    pub line: usize,
45    pub what: String,
46}
47
48impl std::fmt::Display for Problem {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        if self.line == 0 {
51            write!(f, "{}: {}", self.file, self.what)
52        } else {
53            write!(f, "{}:{}: {}", self.file, self.line, self.what)
54        }
55    }
56}
57
58/// A step, as far as parity needs to see one.
59#[derive(Debug, Default)]
60struct Step {
61    line: usize,
62    /// `(key, raw value after the colon, line)` for keys at the step's own
63    /// indentation — never nested ones.
64    keys: Vec<(String, String, usize)>,
65    /// Every line of the step, for finding `tree-` references wherever the
66    /// `if:` puts them (a block scalar included).
67    text: String,
68}
69
70/// What a job or a workflow hands down to its steps.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72enum Inherited {
73    /// `defaults:` holding exactly `run:` → `shell: bash`.
74    ShellBash,
75    /// Any `env:`, or any other `defaults:`.
76    Other,
77}
78
79#[derive(Debug, Default)]
80struct Job {
81    /// `env:` or `defaults:` at the job's own level, with their lines.
82    inherits: Vec<(usize, Inherited)>,
83    steps: Vec<Step>,
84}
85
86#[derive(Debug, Default)]
87struct Workflow {
88    /// `env:` or `defaults:` at the top level.
89    inherits: Vec<(usize, Inherited)>,
90    jobs: Vec<Job>,
91    /// `tree-` references on lines that belong to no step.
92    stray: Vec<usize>,
93}
94
95fn indent_of(line: &str) -> usize {
96    line.len() - line.trim_start_matches(' ').len()
97}
98
99/// `key: value` → `(key, value)`; `None` when the line is not a mapping key.
100fn key_of(s: &str) -> Option<(&str, &str)> {
101    let (k, v) = s.split_once(':')?;
102    let ok = !k.is_empty()
103        && k.chars()
104            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
105    (ok && (v.is_empty() || v.starts_with(' '))).then(|| (k, v.trim()))
106}
107
108fn mentions_tree(line: &str) -> bool {
109    line.contains("'tree-") || line.contains("\"tree-")
110}
111
112/// What the `env:`/`defaults:` key on line `idx` (0-based) hands down.
113/// `defaults:` whose body is exactly `run:` → `shell: bash` is the one shape
114/// that may be accepted; everything else is `Other`.
115fn classify(lines: &[&str], idx: usize) -> Inherited {
116    let key_line = lines[idx];
117    if key_of(key_line.trim()).map(|(k, _)| k) != Some("defaults") {
118        return Inherited::Other;
119    }
120    let level = indent_of(key_line);
121    let body: Vec<&str> = lines[idx + 1..]
122        .iter()
123        .map(|l| (indent_of(l), l.trim()))
124        .filter(|(_, t)| !t.is_empty() && !t.starts_with('#'))
125        .take_while(|(ind, _)| *ind > level)
126        .map(|(_, t)| t)
127        .collect();
128    let bash = ["shell: bash", "shell: 'bash'", "shell: \"bash\""];
129    match body.as_slice() {
130        ["run:", shell] if bash.contains(shell) => Inherited::ShellBash,
131        _ => Inherited::Other,
132    }
133}
134
135/// Whether bash's `-e`/`pipefail` could change this command's verdict: a
136/// pipe, a list, a redirection, a substitution or a subshell.
137fn is_simple(command: &str) -> bool {
138    !command.contains(['|', ';', '&', '>', '<', '`', '(', ')']) && !command.contains("$(")
139}
140
141fn read_workflow(text: &str) -> Workflow {
142    let all: Vec<&str> = text.lines().collect();
143    let mut wf = Workflow::default();
144    let mut in_jobs = false;
145    let mut job_indent: Option<usize> = None;
146    let mut job_key_indent: Option<usize> = None;
147    let mut steps_indent: Option<usize> = None;
148    let mut dash_indent: Option<usize> = None;
149    for (i, raw) in text.lines().enumerate() {
150        let lineno = i + 1;
151        let trimmed = raw.trim();
152        if trimmed.is_empty() || trimmed.starts_with('#') {
153            continue;
154        }
155        let ind = indent_of(raw);
156        if ind == 0 {
157            in_jobs = false;
158            job_indent = None;
159            job_key_indent = None;
160            steps_indent = None;
161            dash_indent = None;
162            if let Some((k, _)) = key_of(trimmed) {
163                if k == "jobs" {
164                    in_jobs = true;
165                } else if k == "env" || k == "defaults" {
166                    wf.inherits.push((lineno, classify(&all, i)));
167                }
168            }
169            if mentions_tree(raw) {
170                wf.stray.push(lineno);
171            }
172            continue;
173        }
174        if !in_jobs {
175            if mentions_tree(raw) {
176                wf.stray.push(lineno);
177            }
178            continue;
179        }
180        let ji = *job_indent.get_or_insert(ind);
181        if ind <= ji {
182            // A new job header (or something at the jobs level).
183            job_key_indent = None;
184            steps_indent = None;
185            dash_indent = None;
186            wf.jobs.push(Job::default());
187            if mentions_tree(raw) {
188                wf.stray.push(lineno);
189            }
190            continue;
191        }
192        let Some(job) = wf.jobs.last_mut() else {
193            if mentions_tree(raw) {
194                wf.stray.push(lineno);
195            }
196            continue;
197        };
198        let jki = *job_key_indent.get_or_insert(ind);
199        if ind <= jki {
200            // A job-level key ends any steps list.
201            steps_indent = None;
202            dash_indent = None;
203            if let Some((k, _)) = key_of(trimmed) {
204                if k == "steps" {
205                    steps_indent = Some(ind);
206                } else if k == "env" || k == "defaults" {
207                    job.inherits.push((lineno, classify(&all, i)));
208                }
209            }
210            if mentions_tree(raw) {
211                wf.stray.push(lineno);
212            }
213            continue;
214        }
215        if steps_indent.is_none() {
216            if mentions_tree(raw) {
217                wf.stray.push(lineno);
218            }
219            continue;
220        }
221        if let Some(item) = trimmed
222            .strip_prefix("- ")
223            .or((trimmed == "-").then_some(""))
224        {
225            let di = *dash_indent.get_or_insert(ind);
226            if ind == di {
227                let mut step = Step {
228                    line: lineno,
229                    ..Step::default()
230                };
231                step.text.push_str(raw);
232                step.text.push('\n');
233                if let Some((k, v)) = key_of(item) {
234                    step.keys.push((k.to_string(), v.to_string(), lineno));
235                }
236                job.steps.push(step);
237                continue;
238            }
239        }
240        let Some(step) = job.steps.last_mut() else {
241            if mentions_tree(raw) {
242                wf.stray.push(lineno);
243            }
244            continue;
245        };
246        step.text.push_str(raw);
247        step.text.push('\n');
248        // A key at the step's own indentation (dash + 2).
249        if Some(ind) == dash_indent.map(|d| d + 2) {
250            if let Some((k, v)) = key_of(trimmed) {
251                step.keys.push((k.to_string(), v.to_string(), lineno));
252            }
253        }
254    }
255    wf
256}
257
258/// The `tree-<name>` tokens a step's text references.
259fn referenced(text: &str) -> Vec<String> {
260    let mut out: Vec<String> = Vec::new();
261    for quote in ['\'', '"'] {
262        let needle = format!("{quote}tree-");
263        let mut rest = text;
264        while let Some(i) = rest.find(&needle) {
265            let after = &rest[i + needle.len()..];
266            let end = after.find(quote).unwrap_or(after.len());
267            let name = &after[..end];
268            if !name.is_empty() && !out.iter().any(|n| n == name) {
269                out.push(name.to_string());
270            }
271            rest = &after[end..];
272        }
273    }
274    out
275}
276
277/// A single-line scalar's value, or why it cannot be read with certainty.
278fn scalar(raw: &str) -> Result<String, &'static str> {
279    let v = raw.trim();
280    if v.is_empty() || v.starts_with('|') || v.starts_with('>') {
281        return Err("a block scalar — write the command on one line");
282    }
283    if v.starts_with('&') || v.starts_with('*') {
284        return Err("an anchor or alias");
285    }
286    let value = if let Some(inner) = v.strip_prefix('"') {
287        let Some(inner) = inner.strip_suffix('"') else {
288            return Err("an unterminated quoted scalar");
289        };
290        if inner.contains(['\\', '"']) {
291            return Err("a quoted scalar with escapes");
292        }
293        inner.to_string()
294    } else if let Some(inner) = v.strip_prefix('\'') {
295        let Some(inner) = inner.strip_suffix('\'') else {
296            return Err("an unterminated quoted scalar");
297        };
298        if inner.contains('\'') {
299            return Err("a quoted scalar with escapes");
300        }
301        inner.to_string()
302    } else {
303        if v.contains(" #") || v.contains(": ") || v.starts_with(['{', '[', '!', '%', '@', '`']) {
304            return Err("a plain scalar YAML may read differently — quote it");
305        }
306        v.to_string()
307    };
308    if value.contains("${{") {
309        return Err("`${{ }}` interpolation");
310    }
311    Ok(value)
312}
313
314/// Every problem in `workflows` (path, text) against `gates`.
315pub fn check_texts(gates: &[TreeGate], workflows: &[(String, String)]) -> Vec<Problem> {
316    let mut problems = Vec::new();
317    let mut seen: Vec<&str> = Vec::new();
318    for (file, text) in workflows {
319        let wf = read_workflow(text);
320        for line in &wf.stray {
321            problems.push(Problem {
322                file: file.clone(),
323                line: *line,
324                what: "a tree gate referenced outside a step — only a step may be skipped on one"
325                    .into(),
326            });
327        }
328        for job in &wf.jobs {
329            for step in &job.steps {
330                let names = referenced(&step.text);
331                if names.is_empty() {
332                    continue;
333                }
334                let at = |what: String| Problem {
335                    file: file.clone(),
336                    line: step.line,
337                    what,
338                };
339                if names.len() > 1 {
340                    problems.push(at(format!(
341                        "one step is gated on several tree gates ({}) — a gate proves one command",
342                        names.join(", ")
343                    )));
344                    continue;
345                }
346                let name = &names[0];
347                let Some(gate) = gates.iter().find(|g| &g.name == name) else {
348                    problems.push(at(format!("`tree-{name}` is not declared in {MANIFEST}")));
349                    continue;
350                };
351                seen.push(&gate.name);
352                let inherited: Vec<(usize, Inherited)> =
353                    wf.inherits.iter().chain(&job.inherits).copied().collect();
354                if let Some((l, _)) = inherited.iter().find(|(_, k)| *k == Inherited::Other) {
355                    problems.push(Problem {
356                        file: file.clone(),
357                        line: *l,
358                        what: format!(
359                            "`tree-{name}` is skipped in a job that inherits `env:` or \
360                             `defaults:` — they change what `run:` does; move them onto \
361                             the steps that need them"
362                        ),
363                    });
364                    continue;
365                }
366                if let Some((l, _)) = inherited.first() {
367                    if !is_simple(&gate.normalized()) {
368                        problems.push(Problem {
369                            file: file.clone(),
370                            line: *l,
371                            what: format!(
372                                "`tree-{name}` inherits `shell: bash`, which is accepted only \
373                                 for a simple command — this one has a pipe, list, \
374                                 redirection or substitution"
375                            ),
376                        });
377                        continue;
378                    }
379                }
380                let mut run = None;
381                let mut bad = None;
382                for (k, v, l) in &step.keys {
383                    match k.as_str() {
384                        "run" => run = Some((v, *l)),
385                        "env" | "shell" => {
386                            bad = Some(format!("a step-level `{k}:` changes what `run:` does"))
387                        }
388                        "working-directory" => match (scalar(v), &gate.cwd) {
389                            (Ok(dir), Some(cwd))
390                                if dir.trim_end_matches('/').trim_start_matches("./")
391                                    == cwd.as_str() => {}
392                            _ => {
393                                bad = Some(
394                                    "`working-directory:` differs from the gate's `cwd=`".into(),
395                                )
396                            }
397                        },
398                        _ => {}
399                    }
400                }
401                if bad.is_none() && gate.cwd.is_some() {
402                    let has_wd = step.keys.iter().any(|(k, _, _)| k == "working-directory");
403                    if !has_wd {
404                        bad = Some(format!(
405                            "the gate runs in `{}` (cwd=) but the step has no \
406                             `working-directory:`",
407                            gate.cwd.as_deref().unwrap_or("")
408                        ));
409                    }
410                }
411                if let Some(why) = bad {
412                    problems.push(at(format!("`tree-{name}`: {why}")));
413                    continue;
414                }
415                let Some((raw, line)) = run else {
416                    problems.push(at(format!("`tree-{name}` gates a step with no `run:`")));
417                    continue;
418                };
419                match scalar(raw) {
420                    Err(why) => problems.push(Problem {
421                        file: file.clone(),
422                        line,
423                        what: format!("`tree-{name}`: `run:` is {why}"),
424                    }),
425                    Ok(value) => {
426                        let got = normalize_command(&value);
427                        let want = gate.normalized();
428                        if got != want {
429                            problems.push(Problem {
430                                file: file.clone(),
431                                line,
432                                what: format!(
433                                    "`tree-{name}` runs `{got}` here but `{want}` in {MANIFEST}:{}",
434                                    gate.lineno
435                                ),
436                            });
437                        }
438                    }
439                }
440            }
441        }
442    }
443    for gate in gates {
444        if !seen.contains(&gate.name.as_str()) {
445            problems.push(Problem {
446                file: MANIFEST.into(),
447                line: gate.lineno,
448                what: format!(
449                    "`tree-{}` is declared but no workflow step is skipped on it",
450                    gate.name
451                ),
452            });
453        }
454    }
455    problems
456}
457
458/// Read `root`'s manifest and workflows, and compare them.
459pub fn check_repo(root: &Path) -> Vec<Problem> {
460    let Ok(manifest) = std::fs::read_to_string(root.join(MANIFEST)) else {
461        return Vec::new();
462    };
463    let gates = tree_gates(&manifest);
464    if gates.is_empty() {
465        return Vec::new();
466    }
467    let mut workflows = Vec::new();
468    for dir in WORKFLOW_DIRS {
469        let Ok(entries) = std::fs::read_dir(root.join(dir)) else {
470            continue;
471        };
472        let mut paths: Vec<_> = entries
473            .filter_map(Result::ok)
474            .map(|e| e.path())
475            .filter(|p| matches!(p.extension().and_then(|e| e.to_str()), Some("yml" | "yaml")))
476            .collect();
477        paths.sort();
478        for p in paths {
479            if let Ok(text) = std::fs::read_to_string(&p) {
480                let rel = p.strip_prefix(root).unwrap_or(&p).display().to_string();
481                workflows.push((rel, text));
482            }
483        }
484    }
485    check_texts(&gates, &workflows)
486}
487
488/// The pre-commit check. `Inert` when the repository declares no tree gate.
489pub fn run(settings: &crate::config::Settings) -> Outcome {
490    let root = repo_root();
491    let root = Path::new(&root);
492    let declares = std::fs::read_to_string(root.join(MANIFEST))
493        .map(|t| !tree_gates(&t).is_empty())
494        .unwrap_or(false);
495    if !declares {
496        return Outcome::Inert;
497    }
498    let problems = check_repo(root);
499    if problems.is_empty() {
500        ok(settings, "tree gates match their CI steps");
501        return Outcome::Passed;
502    }
503    for p in &problems {
504        eprintln!("  {}", crate::ui::sanitize(&p.to_string()));
505    }
506    fail(&format!(
507        "{} tree-gate parity problem(s) — fix the workflow or the {} line; run {} to re-check",
508        problems.len(),
509        MANIFEST,
510        hl("amont tree-parity")
511    ));
512    Outcome::Failed
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518
519    fn gates(text: &str) -> Vec<TreeGate> {
520        let g = tree_gates(text);
521        assert!(!g.is_empty(), "no gate parsed from {text:?}");
522        g
523    }
524
525    fn check(manifest: &str, workflow: &str) -> Vec<Problem> {
526        check_texts(
527            &gates(manifest),
528            &[(
529                ".forgejo/workflows/ci.yaml".to_string(),
530                workflow.to_string(),
531            )],
532        )
533    }
534
535    const ESLINT: &str = "tree eslint eslint * attest npm run lint -- {cache}";
536
537    fn wf(step_run: &str) -> String {
538        format!(
539            "name: ci\non:\n  push:\njobs:\n  checks:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - id: attest\n        uses: fredericrous/attest@v1\n      - name: Lint\n        if: ${{{{ !contains(fromJSON(steps.attest.outputs.gates || '[]'), 'tree-eslint') }}}}\n        {step_run}\n"
540        )
541    }
542
543    #[test]
544    fn parity_matching_run_passes() {
545        assert_eq!(check(ESLINT, &wf("run: npm run lint")), vec![]);
546    }
547
548    #[test]
549    fn parity_quoted_run_passes() {
550        assert_eq!(check(ESLINT, &wf("run: 'npm run lint'")), vec![]);
551    }
552
553    #[test]
554    fn parity_drifted_run_fails() {
555        let p = check(ESLINT, &wf("run: npm run lint -- --max-warnings 0"));
556        assert_eq!(p.len(), 1, "{p:?}");
557        assert!(p[0]
558            .what
559            .contains("runs `npm run lint -- --max-warnings 0` here"));
560    }
561
562    #[test]
563    fn parity_block_scalar_rejected() {
564        let p = check(ESLINT, &wf("run: |\n          npm run lint"));
565        assert_eq!(p.len(), 1, "{p:?}");
566        assert!(p[0].what.contains("block scalar"));
567    }
568
569    #[test]
570    fn parity_anchor_rejected() {
571        let p = check(ESLINT, &wf("run: *lint"));
572        assert!(p[0].what.contains("anchor"), "{p:?}");
573    }
574
575    #[test]
576    fn parity_interpolation_rejected() {
577        let p = check(ESLINT, &wf("run: npm run ${{ matrix.task }}"));
578        assert!(p[0].what.contains("interpolation"), "{p:?}");
579    }
580
581    #[test]
582    fn parity_step_env_rejected() {
583        let p = check(
584            ESLINT,
585            &wf("env:\n          CI: '1'\n        run: npm run lint"),
586        );
587        assert!(p[0].what.contains("step-level `env:`"), "{p:?}");
588    }
589
590    #[test]
591    fn parity_step_shell_rejected() {
592        let p = check(ESLINT, &wf("shell: bash\n        run: npm run lint"));
593        assert!(p[0].what.contains("step-level `shell:`"), "{p:?}");
594    }
595
596    #[test]
597    fn parity_undeclared_working_directory_rejected() {
598        let p = check(
599            ESLINT,
600            &wf("working-directory: web\n        run: npm run lint"),
601        );
602        assert!(p[0].what.contains("working-directory"), "{p:?}");
603    }
604
605    #[test]
606    fn parity_declared_cwd_passes() {
607        let p = check(
608            "tree eslint eslint * attest cwd=web npm run lint",
609            &wf("working-directory: web\n        run: npm run lint"),
610        );
611        assert_eq!(p, vec![]);
612    }
613
614    #[test]
615    fn parity_cwd_without_working_directory_rejected() {
616        let p = check(
617            "tree eslint eslint * attest cwd=web npm run lint",
618            &wf("run: npm run lint"),
619        );
620        assert!(p[0].what.contains("no `working-directory:`"), "{p:?}");
621    }
622
623    #[test]
624    fn parity_job_env_rejected() {
625        let w = wf("run: npm run lint").replace(
626            "    runs-on: ubuntu-latest\n",
627            "    runs-on: ubuntu-latest\n    env:\n      NODE_ENV: test\n",
628        );
629        let p = check(ESLINT, &w);
630        assert!(p[0].what.contains("inherits"), "{p:?}");
631    }
632
633    #[test]
634    fn parity_job_defaults_rejected() {
635        let w = wf("run: npm run lint").replace(
636            "    runs-on: ubuntu-latest\n",
637            "    runs-on: ubuntu-latest\n    defaults:\n      run:\n        working-directory: web\n",
638        );
639        let p = check(ESLINT, &w);
640        assert!(p[0].what.contains("inherits"), "{p:?}");
641    }
642
643    #[test]
644    fn parity_workflow_shell_bash_accepted_for_a_simple_command() {
645        let w = wf("run: npm run lint")
646            .replace("jobs:\n", "defaults:\n  run:\n    shell: bash\njobs:\n");
647        assert_eq!(check(ESLINT, &w), vec![]);
648    }
649
650    #[test]
651    fn parity_job_shell_bash_accepted_for_a_simple_command() {
652        let w = wf("run: npm run lint").replace(
653            "    runs-on: ubuntu-latest\n",
654            "    runs-on: ubuntu-latest\n    defaults:\n      run:\n        shell: bash\n",
655        );
656        assert_eq!(check(ESLINT, &w), vec![]);
657    }
658
659    #[test]
660    fn parity_shell_bash_rejected_for_a_pipeline() {
661        let w = wf("run: npm run lint | tee lint.log")
662            .replace("jobs:\n", "defaults:\n  run:\n    shell: bash\njobs:\n");
663        let p = check(
664            "tree eslint eslint * attest npm run lint | tee lint.log",
665            &w,
666        );
667        assert!(p[0].what.contains("simple command"), "{p:?}");
668    }
669
670    #[test]
671    fn parity_other_shell_rejected() {
672        let w =
673            wf("run: npm run lint").replace("jobs:\n", "defaults:\n  run:\n    shell: sh\njobs:\n");
674        let p = check(ESLINT, &w);
675        assert!(p[0].what.contains("inherits `env:` or"), "{p:?}");
676    }
677
678    #[test]
679    fn parity_shell_bash_plus_working_directory_rejected() {
680        let w = wf("run: npm run lint").replace(
681            "jobs:\n",
682            "defaults:\n  run:\n    shell: bash\n    working-directory: web\njobs:\n",
683        );
684        let p = check(ESLINT, &w);
685        assert!(p[0].what.contains("inherits `env:` or"), "{p:?}");
686    }
687
688    #[test]
689    fn parity_workflow_env_still_rejected_beside_shell_bash() {
690        let w = wf("run: npm run lint").replace(
691            "jobs:\n",
692            "defaults:\n  run:\n    shell: bash\nenv:\n  CI: '1'\njobs:\n",
693        );
694        let p = check(ESLINT, &w);
695        assert!(p[0].what.contains("inherits `env:` or"), "{p:?}");
696    }
697
698    #[test]
699    fn parity_undeclared_gate_rejected() {
700        let p = check(
701            "tree prettier prettier * attest npx prettier --check .",
702            &wf("run: npm run lint"),
703        );
704        assert!(
705            p.iter()
706                .any(|p| p.what.contains("`tree-eslint` is not declared")),
707            "{p:?}"
708        );
709        assert!(
710            p.iter().any(|p| p.what.contains("no workflow step")),
711            "{p:?}"
712        );
713    }
714
715    #[test]
716    fn parity_declared_gate_without_step_rejected() {
717        let p = check(
718            ESLINT,
719            "name: ci\njobs:\n  a:\n    steps:\n      - run: true\n",
720        );
721        assert_eq!(p.len(), 1, "{p:?}");
722        assert!(p[0].what.contains("no workflow step is skipped on it"));
723    }
724
725    #[test]
726    fn parity_job_level_reference_rejected() {
727        let w = "name: ci\njobs:\n  lint:\n    if: ${{ !contains(fromJSON(needs.a.outputs.gates), 'tree-eslint') }}\n    steps:\n      - run: npm run lint\n";
728        let p = check(ESLINT, w);
729        assert!(p.iter().any(|p| p.what.contains("outside a step")), "{p:?}");
730    }
731
732    #[test]
733    fn parity_two_gates_on_one_step_rejected() {
734        let w = wf("run: npm run lint").replace(
735            "'tree-eslint') }}",
736            "'tree-eslint') && !contains(fromJSON(steps.attest.outputs.gates), 'tree-prettier') }}",
737        );
738        let p = check(
739            "tree eslint eslint * attest npm run lint\ntree prettier prettier * attest npx prettier --check .",
740            &w,
741        );
742        assert!(
743            p.iter().any(|p| p.what.contains("several tree gates")),
744            "{p:?}"
745        );
746    }
747}