Skip to main content

release_kit/setup/
workflow_jobs.rs

1//! Whether the job `--required-check` names is shaped to report a blocking
2//! answer.
3//!
4//! The trunk protection requires exactly two status-check contexts: the
5//! named check and the title check. Which other jobs a project means to
6//! block a merge is intent, and no file states it, so this reader makes no
7//! claim about them: `gate.needs` is the voting list by convention, and
8//! `forges/github.md` owns that convention. What this reader judges is the
9//! gate itself, in five ways it can fail to report: no job reports the
10//! context, more than one does, the condition is not proven to survive a
11//! failed dependency, the `needs` value is not a literal list, and the
12//! trigger filters the request away. It reads the workflow text line by
13//! line, in the same spirit as the landing invariants: where a value sits
14//! somewhere this reader does not follow, it says so rather than guessing.
15//!
16//! It does not prove that the gate holds a merge. A gate under a proven
17//! condition with a literal `needs` still passes if its steps never inspect
18//! the results, and that is script semantics this reader does not run.
19
20use camino::Utf8Path;
21
22use crate::landing::invariants::before_comment;
23
24/// What the workflows say about the required check.
25#[derive(Debug, PartialEq, Eq)]
26pub enum GateReading {
27    /// No workflow runs on a pull request, so no job reports the check.
28    NoRequestWorkflows,
29    /// Every request-reporting context is the check, the title check, or a
30    /// job the check needs.
31    Gated,
32    /// No job reports the required check's context on a pull request.
33    NoSuchJob {
34        /// The contexts that do report, in file order.
35        contexts: Vec<String>,
36    },
37    /// A job carries the check's id, but names itself by an expression or
38    /// runs a reusable workflow, so the context it reports is not in the
39    /// file.
40    UnprovenGateName {
41        /// The job id.
42        job: String,
43    },
44    /// The check's `needs` value is one this reader does not follow.
45    OpaqueNeeds {
46        /// The workflow file that carries it.
47        workflow: String,
48    },
49}
50
51/// The gate job's `if` condition, as far as the reader proves it.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum Condition {
54    /// No `if` key: the job is skipped when a needed job fails.
55    Absent,
56    /// `always()`, or `!cancelled()` written so that YAML reads it as text:
57    /// the job runs when a needed job fails, which is the property the gate
58    /// rests on. `rust-lang/cargo` uses the second deliberately, so that a
59    /// manual cancel does not turn the gate red.
60    Proven,
61    /// A scalar opening with `!`, which YAML reads as a tag rather than as
62    /// text, carried verbatim. The forge never sees the expression, so the
63    /// workflow does not parse and the check never reports.
64    UnquotedTag(String),
65    /// Any other expression, carried verbatim: not proven to run on a
66    /// failed dependency.
67    Other(String),
68}
69
70/// The reading and what the reader judges beside it.
71#[derive(Debug, PartialEq, Eq)]
72pub struct GateReport {
73    /// What the workflows say.
74    pub reading: GateReading,
75    /// The gate job's condition, where a gate job was found.
76    pub gate_condition: Option<Condition>,
77    /// What the gate's workflow filters its pull-request trigger by.
78    pub gate_trigger: Trigger,
79    /// How many jobs report the required context on a pull request. Where
80    /// a name is required, every reporter of it must pass, so a second one
81    /// takes the merge decision out of the gate's hands.
82    pub reporting: usize,
83    /// Workflow files that could not be read, so their jobs are unjudged.
84    pub unreadable: Vec<String>,
85}
86
87/// One job as the line reader sees it.
88#[derive(Debug, PartialEq, Eq)]
89struct Job {
90    id: String,
91    name: Name,
92    /// The job calls a reusable workflow, whose jobs report their own
93    /// contexts, named after both the caller and the callee.
94    reusable: bool,
95    needs: Needs,
96    condition: Condition,
97}
98
99/// How a job's status-check context is known.
100#[derive(Debug, PartialEq, Eq)]
101enum Name {
102    /// No `name` key: the context is the id.
103    Id,
104    /// A literal `name` value.
105    Fixed(String),
106    /// A name built from an expression: not in this file.
107    Unproven,
108}
109
110impl Job {
111    /// The status-check context the job reports, where the file states it.
112    fn context(&self) -> Option<&str> {
113        if self.reusable {
114            return None;
115        }
116        match &self.name {
117            Name::Id => Some(&self.id),
118            Name::Fixed(name) => Some(name),
119            Name::Unproven => None,
120        }
121    }
122}
123
124/// A job's `needs` value.
125#[derive(Debug, PartialEq, Eq)]
126enum Needs {
127    /// The key is absent.
128    None,
129    /// The job ids named, in a scalar, a flow list, or a block list.
130    Listed(Vec<String>),
131    /// An expression, an anchor, or a folded scalar: not followed.
132    Opaque,
133}
134
135/// What a workflow's pull-request trigger filters by.
136///
137/// Every filter can keep the gate from reporting on a request the trunk
138/// protection covers, and a required context that never appears leaves
139/// the merge hanging.
140#[derive(Debug, Clone, Default, PartialEq, Eq)]
141pub struct Trigger {
142    /// The trigger carries `paths` or `paths-ignore`.
143    pub paths_filtered: bool,
144    /// The trigger's branch filter leaves the trunk out, quoted.
145    pub misses_trunk: Option<String>,
146    /// The trigger's activity types leave out an opened, reopened, or
147    /// synchronized request, quoted.
148    pub types_filtered: Option<String>,
149}
150
151impl Trigger {
152    /// Read the filters one request event carries.
153    fn from_filters(filters: &[(String, Vec<String>)], trunk: &str) -> Self {
154        let mut trigger = Self::default();
155        for (key, items) in filters {
156            match key.as_str() {
157                "paths" | "paths-ignore" => trigger.paths_filtered = true,
158                // A negative pattern later in the list can take the trunk
159                // back out, so a list carrying one is not proven either way.
160                "branches" => {
161                    let negated = items.iter().any(|item| item.starts_with('!'));
162                    if negated || !items.iter().any(|item| covers_trunk(item, trunk)) {
163                        trigger.misses_trunk = Some(format!("branches: [{}]", items.join(", ")));
164                    }
165                }
166                // A glob here may match the trunk, and the reader does not
167                // run the forge's matcher, so only a literal other name is
168                // proven harmless.
169                "branches-ignore" => {
170                    if items
171                        .iter()
172                        .any(|item| covers_trunk(item, trunk) || is_glob(item))
173                    {
174                        trigger.misses_trunk =
175                            Some(format!("branches-ignore: [{}]", items.join(", ")));
176                    }
177                }
178                "types" => {
179                    let needed = ["opened", "synchronize", "reopened"];
180                    if !needed
181                        .iter()
182                        .all(|kind| items.iter().any(|item| item == kind))
183                    {
184                        trigger.types_filtered = Some(format!("types: [{}]", items.join(", ")));
185                    }
186                }
187                _ => {}
188            }
189        }
190        trigger
191    }
192
193    /// Fold a second request event's filters in: a filter on either event
194    /// is reported.
195    fn merge(&mut self, other: Self) {
196        self.paths_filtered |= other.paths_filtered;
197        if self.misses_trunk.is_none() {
198            self.misses_trunk = other.misses_trunk;
199        }
200        if self.types_filtered.is_none() {
201            self.types_filtered = other.types_filtered;
202        }
203    }
204}
205
206/// Whether a branch pattern names the trunk: its exact name, or a glob
207/// that matches every branch. Any other glob is not proven to.
208fn covers_trunk(pattern: &str, trunk: &str) -> bool {
209    pattern == trunk || pattern == "*" || pattern == "**"
210}
211
212/// Whether a branch pattern carries a glob or negation character, so its
213/// matches are the forge's to decide, not this reader's.
214fn is_glob(pattern: &str) -> bool {
215    pattern.contains(['*', '?', '[', ']', '+', '!'])
216}
217
218/// One workflow file that runs on a pull request.
219struct Workflow {
220    name: String,
221    /// The literal top-level `name:` value, where the file states one.
222    /// This is what a `workflow_run` trigger names, and it is not the
223    /// filename: a trigger that names the file matches nothing.
224    declared_name: Option<String>,
225    trigger: Trigger,
226    jobs: Vec<Job>,
227}
228
229/// Read every workflow under the target's `.github/workflows` and judge
230/// the named check against the jobs that report on a pull request.
231#[must_use]
232pub fn read_gate(target: &Utf8Path, required_check: &str, trunk: &str) -> GateReport {
233    let (workflows, unreadable) = read_workflows(&target.join(".github/workflows"), trunk);
234    let mut report = GateReport {
235        reading: GateReading::NoRequestWorkflows,
236        gate_condition: None,
237        gate_trigger: Trigger::default(),
238        reporting: 0,
239        unreadable,
240    };
241    if workflows.iter().all(|workflow| workflow.jobs.is_empty()) {
242        return report;
243    }
244    judge(&mut report, &workflows, required_check);
245    report
246}
247
248/// Every request-running workflow under the directory, in name order, and
249/// every path that could not be read. A missing directory is neither: a
250/// target with no workflows reads as none, not as unreadable.
251fn read_workflows(dir: &Utf8Path, trunk: &str) -> (Vec<Workflow>, Vec<String>) {
252    let mut unreadable: Vec<String> = Vec::new();
253    let mut workflows: Vec<Workflow> = Vec::new();
254    match std::fs::read_dir(dir) {
255        Ok(entries) => {
256            let mut names: Vec<String> = Vec::new();
257            for entry in entries {
258                match entry {
259                    Ok(entry) => names.push(entry.file_name().to_string_lossy().into_owned()),
260                    Err(_) => unreadable.push(dir.to_string()),
261                }
262            }
263            names.sort();
264            for name in names {
265                let is_workflow = std::path::Path::new(&name)
266                    .extension()
267                    .is_some_and(|ext| ext == "yml" || ext == "yaml");
268                if !is_workflow {
269                    continue;
270                }
271                let Ok(text) = std::fs::read_to_string(dir.join(&name)) else {
272                    unreadable.push(name);
273                    continue;
274                };
275                if let Some(trigger) = request_trigger(&text, trunk) {
276                    workflows.push(Workflow {
277                        name,
278                        declared_name: declared_name(&text),
279                        trigger,
280                        jobs: jobs(&text),
281                    });
282                }
283            }
284        }
285        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
286        Err(_) => unreadable.push(dir.to_string()),
287    }
288    (workflows, unreadable)
289}
290
291/// The gate judgment over workflows that declare at least one job.
292///
293/// The judgment is about the gate alone. Which other jobs a project means
294/// to block a merge is intent, and no file states it, so a job outside the
295/// gate's `needs` is neither counted nor named here.
296fn judge(report: &mut GateReport, workflows: &[Workflow], required_check: &str) {
297    // Every job reporting the required context, across every request
298    // workflow: one is the gate, and a second makes the required check
299    // ambiguous.
300    report.reporting = workflows
301        .iter()
302        .flat_map(|workflow| &workflow.jobs)
303        .filter(|job| job.context() == Some(required_check))
304        .count();
305    let Some((workflow, gate)) = workflows.iter().find_map(|workflow| {
306        workflow
307            .jobs
308            .iter()
309            .find(|job| job.context() == Some(required_check))
310            .map(|job| (workflow, job))
311    }) else {
312        let unproven = workflows
313            .iter()
314            .flat_map(|workflow| &workflow.jobs)
315            .find(|job| job.id == required_check && job.context().is_none());
316        report.reading = unproven.map_or_else(
317            || GateReading::NoSuchJob {
318                // The contexts that do report, which is the remediation an
319                // operator acts on: one of these is the name to require.
320                contexts: workflows
321                    .iter()
322                    .flat_map(|workflow| workflow.jobs.iter().filter_map(Job::context))
323                    .map(str::to_owned)
324                    .collect(),
325            },
326            |job| GateReading::UnprovenGateName {
327                job: job.id.clone(),
328            },
329        );
330        return;
331    };
332    report.gate_condition = Some(gate.condition.clone());
333    report.gate_trigger = workflow.trigger.clone();
334    // A `needs` value the reader does not follow is refused rather than
335    // interpreted: an anchor, an alias, or an expression names a voting
336    // list nobody can read from the file.
337    report.reading = match &gate.needs {
338        Needs::Opaque => GateReading::OpaqueNeeds {
339            workflow: workflow.name.clone(),
340        },
341        Needs::None | Needs::Listed(_) => GateReading::Gated,
342    };
343}
344
345/// The ways the gate is shaped so that it cannot report a blocking answer,
346/// or nothing where its shape is sound.
347///
348/// Every part is a fault on `protect-trunk`, not a limitation: a required
349/// check that cannot report is a broken trunk protection.
350#[must_use]
351pub fn faults(report: &GateReport, required_check: &str, trunk: &str) -> Option<String> {
352    let mut parts: Vec<String> = Vec::new();
353    match &report.reading {
354        GateReading::Gated => {}
355        GateReading::NoRequestWorkflows => parts.push(format!(
356            "no workflow in .github/workflows runs on a pull request, so the required check {required_check} never reports and every merge hangs; name a job that runs on a pull request, or remove the required context"
357        )),
358        GateReading::NoSuchJob { contexts } => parts.push(format!(
359            "no job in .github/workflows reports the context {required_check} on a pull request, so the required check never reports and every merge hangs; the contexts that do report are [{}]",
360            contexts.join(", ")
361        )),
362        GateReading::UnprovenGateName { job } => parts.push(format!(
363            "the job {job} names itself by an expression or runs a reusable workflow, so the context it reports is not in the file and {required_check} is not proven to exist; give the job a literal name equal to the required context"
364        )),
365        GateReading::OpaqueNeeds { workflow } => parts.push(format!(
366            "the needs value of {required_check} in {workflow} is an anchor, an alias, or an expression, which this reader refuses rather than interprets; write it as a literal list of job ids"
367        )),
368    }
369    if report.reporting > 1 {
370        parts.push(format!(
371            "the context {required_check} is reported by {} jobs on a pull request, so the required check no longer stands for the gate alone: every reporter of a required name must pass, and a job outside the gate can hold or release the merge; rename all but one",
372            report.reporting
373        ));
374    }
375    match &report.gate_condition {
376        None | Some(Condition::Proven) => {}
377        Some(Condition::Absent) => parts.push(format!(
378            "the job {required_check} runs under no if condition, so a needed job that fails skips it and the forge reads a skip as success; use if: always(), or if: ${{{{ !cancelled() }}}}"
379        )),
380        Some(Condition::UnquotedTag(raw)) => parts.push(format!(
381            "the condition of {required_check} reads {raw}, and an unquoted scalar opening with ! is a YAML tag rather than text, so the workflow does not parse and the check never reports; write it as ${{{{ !cancelled() }}}} or quote it"
382        )),
383        Some(Condition::Other(expression)) => parts.push(format!(
384            "the job {required_check} runs under the condition {expression}, which this reader cannot prove holds when a needed job fails; always() or ${{{{ !cancelled() }}}} is the proven form"
385        )),
386    }
387    if report.gate_trigger.paths_filtered {
388        parts.push(format!(
389            "the pull_request trigger of the workflow carrying {required_check} filters by paths, so a request outside them never reports the check and its merge hangs"
390        ));
391    }
392    if let Some(filter) = &report.gate_trigger.misses_trunk {
393        parts.push(format!(
394            "the pull_request trigger of the workflow carrying {required_check} reads {filter}, which does not prove it runs for a request against {trunk}, so the check would never report there"
395        ));
396    }
397    if let Some(filter) = &report.gate_trigger.types_filtered {
398        parts.push(format!(
399            "the pull_request trigger of the workflow carrying {required_check} reads {filter}, which leaves out one of opened, reopened, and synchronize, so a request in that state never reports the check"
400        ));
401    }
402    if !report.unreadable.is_empty() {
403        parts.push(format!(
404            "[{}] could not be read, so no job there is judged and the context is not proven unique",
405            report.unreadable.join(", ")
406        ));
407    }
408    (!parts.is_empty()).then(|| parts.join("; "))
409}
410
411/// The workflow's literal top-level `name:` value, where it states one
412/// that is not an expression.
413///
414/// A `workflow_run` trigger names a workflow by this value and by nothing
415/// else. A file with no `name:` reports its path instead, which no trigger
416/// can name, so absence here is an answer rather than a gap.
417fn declared_name(workflow: &str) -> Option<String> {
418    for line in workflow.lines() {
419        if is_blank(line) || indent(line) > 0 {
420            continue;
421        }
422        let (key, value) = key_value(line)?;
423        if key != "name" {
424            continue;
425        }
426        let value = scalar(before_comment(value).trim());
427        if value.is_empty() || value.contains("${{") {
428            return None;
429        }
430        return Some(value);
431    }
432    None
433}
434
435/// Why the recorded waking workflow cannot hold the release request, or
436/// `None` where the pair is sound.
437///
438/// The release gate under local integration wakes on one workflow
439/// completing and then judges one named check. A trigger cannot name a
440/// check and a required context cannot name a workflow, so the two
441/// answers are separate and nothing but this reader proves they describe
442/// one file. A mismatch is a setup fault rather than a landing defect: it
443/// wakes before the check reports, or never wakes after it succeeds, and
444/// the release request stays open with nothing saying why.
445#[must_use]
446pub fn waking_workflow_fault(
447    target: &Utf8Path,
448    required_workflow: &str,
449    required_check: &str,
450    trunk: &str,
451) -> Option<String> {
452    let (workflows, _) = read_workflows(&target.join(".github/workflows"), trunk);
453    let Some(waking) = workflows
454        .iter()
455        .find(|workflow| workflow.declared_name.as_deref() == Some(required_workflow))
456    else {
457        let named: Vec<&str> = workflows
458            .iter()
459            .filter_map(|workflow| workflow.declared_name.as_deref())
460            .collect();
461        return Some(format!(
462            "no workflow in .github/workflows declares name: {required_workflow} and runs on a pull request, so the release gate never wakes and the release request stays open; a workflow_run trigger names the name: value and never the filename, and the names that do run on a request are [{}]",
463            named.join(", ")
464        ));
465    };
466    if !waking
467        .jobs
468        .iter()
469        .any(|job| job.context() == Some(required_check))
470    {
471        return Some(format!(
472            "the workflow {required_workflow} carries no job reporting the context {required_check}, so the gate would wake on one workflow and judge a check another workflow reports; name one workflow that carries the check"
473        ));
474    }
475    if let Some(filter) = &waking.trigger.misses_trunk {
476        return Some(format!(
477            "the pull_request trigger of {required_workflow} reads {filter}, which does not prove it runs for a request against {trunk}, so the gate never wakes for a release request"
478        ));
479    }
480    None
481}
482
483/// The workflow's pull-request trigger, in the block, the flow, the
484/// scalar, or the block-list form of `on`, where it has one, with the
485/// filters a block-form event carries under it.
486///
487/// The landing invariant reads it too, to ask whether a generated
488/// workflow reports on a request at all: one reader owns the forms `on`
489/// takes, so a form one of them learns is a form both know.
490pub(crate) fn request_trigger(workflow: &str, trunk: &str) -> Option<Trigger> {
491    let mut in_on = false;
492    let mut event_indent: Option<usize> = None;
493    let mut in_request_event = false;
494    let mut filter_indent: Option<usize> = None;
495    let mut filters: Vec<(String, Vec<String>)> = Vec::new();
496    let mut found: Option<Trigger> = None;
497    let close_event = |filters: &mut Vec<(String, Vec<String>)>, found: &mut Option<Trigger>| {
498        if let Some(trigger) = found {
499            trigger.merge(Trigger::from_filters(filters, trunk));
500        }
501        filters.clear();
502    };
503    for line in workflow.lines() {
504        if is_blank(line) {
505            continue;
506        }
507        let depth = indent(line);
508        if depth == 0 {
509            if in_request_event {
510                close_event(&mut filters, &mut found);
511            }
512            in_on = false;
513            in_request_event = false;
514            event_indent = None;
515            let Some((key, value)) = key_value(line) else {
516                continue;
517            };
518            if key != "on" {
519                continue;
520            }
521            if value.is_empty() {
522                in_on = true;
523                continue;
524            }
525            if list_items(value).iter().any(|item| is_request_event(item)) {
526                found.get_or_insert_with(Trigger::default);
527            }
528            continue;
529        }
530        if !in_on {
531            continue;
532        }
533        let event_depth = *event_indent.get_or_insert(depth);
534        if depth == event_depth {
535            if in_request_event {
536                close_event(&mut filters, &mut found);
537            }
538            filter_indent = None;
539            let item = line.trim_start();
540            let item = item.strip_prefix("- ").map_or(item, str::trim_start);
541            let key = key_value(item).map_or_else(|| before_comment(item).trim(), |(key, _)| key);
542            in_request_event = is_request_event(key);
543            if in_request_event {
544                found.get_or_insert_with(Trigger::default);
545            }
546            continue;
547        }
548        if !in_request_event || depth <= event_depth {
549            continue;
550        }
551        let filter_depth = *filter_indent.get_or_insert(depth);
552        if depth == filter_depth {
553            if let Some((key, value)) = key_value(line) {
554                let items = if value.is_empty() {
555                    Vec::new()
556                } else {
557                    list_items(value).into_iter().map(str::to_owned).collect()
558                };
559                filters.push((key.to_owned(), items));
560            }
561            continue;
562        }
563        // A block-list item under the last filter key.
564        if let Some(item) = line.trim_start().strip_prefix("- ")
565            && let Some((_, items)) = filters.last_mut()
566        {
567            items.push(unquote(before_comment(item).trim()).to_owned());
568        }
569    }
570    if in_request_event {
571        close_event(&mut filters, &mut found);
572    }
573    found
574}
575
576fn is_request_event(name: &str) -> bool {
577    matches!(name, "pull_request" | "pull_request_target")
578}
579
580/// The jobs the workflow declares under its top-level `jobs` key, with
581/// the properties the gate judgment reads. Steps and every deeper mapping
582/// are passed over, so a `jobs` key nested in a reusable-workflow call or
583/// a matrix opens no job.
584fn jobs(workflow: &str) -> Vec<Job> {
585    let mut found: Vec<Job> = Vec::new();
586    let mut in_jobs = false;
587    let mut job_indent: Option<usize> = None;
588    let mut property_indent: Option<usize> = None;
589    let mut reading_needs_list = false;
590    for line in workflow.lines() {
591        if is_blank(line) {
592            continue;
593        }
594        let depth = indent(line);
595        if depth == 0 {
596            in_jobs = key_value(line).is_some_and(|(key, value)| key == "jobs" && value.is_empty());
597            job_indent = None;
598            property_indent = None;
599            reading_needs_list = false;
600            continue;
601        }
602        if !in_jobs {
603            continue;
604        }
605        let job_depth = *job_indent.get_or_insert(depth);
606        if depth == job_depth {
607            reading_needs_list = false;
608            property_indent = None;
609            if let Some((id, _)) = key_value(line) {
610                found.push(Job {
611                    id: id.to_owned(),
612                    name: Name::Id,
613                    reusable: false,
614                    needs: Needs::None,
615                    condition: Condition::Absent,
616                });
617            }
618            continue;
619        }
620        if depth < job_depth {
621            continue;
622        }
623        let Some(job) = found.last_mut() else {
624            continue;
625        };
626        let property_depth = *property_indent.get_or_insert(depth);
627        if reading_needs_list
628            && depth > property_depth
629            && let Some(item) = line.trim_start().strip_prefix("- ")
630        {
631            if let Needs::Listed(ids) = &mut job.needs {
632                ids.push(unquote(before_comment(item).trim()).to_owned());
633            }
634            continue;
635        }
636        reading_needs_list = false;
637        if depth != property_depth {
638            continue;
639        }
640        let Some((key, value)) = key_value(line) else {
641            continue;
642        };
643        match key {
644            "name" => {
645                let value = scalar(before_comment(value).trim());
646                // A name built from an expression resolves per run, so
647                // the context it reports is not in the file.
648                if value.contains("${{") || value.is_empty() {
649                    job.name = Name::Unproven;
650                } else {
651                    job.name = Name::Fixed(value);
652                }
653            }
654            // A reusable-workflow call reports the called jobs' contexts,
655            // named after both the caller and the callee, whatever name
656            // the caller sets and in whatever key order.
657            "uses" => job.reusable = true,
658            "if" => job.condition = condition(value),
659            "needs" => {
660                let value = before_comment(value).trim();
661                if value.is_empty() {
662                    job.needs = Needs::Listed(Vec::new());
663                    reading_needs_list = true;
664                } else if value.starts_with(['|', '>', '*', '&', '$']) {
665                    job.needs = Needs::Opaque;
666                } else {
667                    job.needs =
668                        Needs::Listed(list_items(value).into_iter().map(str::to_owned).collect());
669                }
670            }
671            _ => {}
672        }
673    }
674    found
675}
676
677/// A job's `if` value: `always()` and `!cancelled()` are the two
678/// expressions proven to run on a failed dependency. Anything else is
679/// carried verbatim, because `always() && x` skips when `x` is false and a
680/// skipped job reports success.
681///
682/// `!cancelled()` is here because `rust-lang/cargo` uses it deliberately,
683/// so that a manual cancel does not turn the gate red. It runs on a failed
684/// dependency exactly as `always()` does, which is the property the gate
685/// rests on.
686///
687/// The `!` needs the expression braces or quotes to survive YAML: an
688/// unquoted scalar opening with `!` is a tag, not text, so `if:
689/// !cancelled()` does not parse and the forge never runs the workflow. The
690/// raw scalar is therefore read before it is unquoted, and the bare form is
691/// its own fault rather than a pass.
692fn condition(value: &str) -> Condition {
693    let raw = before_comment(value).trim();
694    let value = unquote(raw);
695    let inner = value
696        .strip_prefix("${{")
697        .and_then(|rest| rest.strip_suffix("}}"))
698        .map_or(value, str::trim);
699    if raw.starts_with('!') {
700        return Condition::UnquotedTag(raw.to_owned());
701    }
702    if inner == "always()" || inner == "!cancelled()" {
703        Condition::Proven
704    } else if inner.is_empty() {
705        Condition::Other("(a value carried on another line)".to_owned())
706    } else {
707        Condition::Other(inner.to_owned())
708    }
709}
710
711/// A scalar or a flow list, as its items: `a`, `[a, b]`, or `"a"`. The
712/// outer brackets alone delimit the list, and a comma inside a quoted
713/// scalar separates nothing, so a bracketed glob such as `'ma[as]ter'`
714/// stays one item and reaches the judgment whole.
715fn list_items(value: &str) -> Vec<&str> {
716    let value = before_comment(value).trim();
717    let inner = value
718        .strip_prefix('[')
719        .and_then(|rest| rest.strip_suffix(']'))
720        .unwrap_or(value);
721    let mut items = Vec::new();
722    let mut quote: Option<char> = None;
723    let mut escaped = false;
724    let mut start = 0;
725    for (index, character) in inner.char_indices() {
726        if let Some(open) = quote {
727            // A double-quoted scalar escapes with a backslash, so the
728            // quote after one is content, not the close.
729            if escaped {
730                escaped = false;
731            } else if open == QUOTES[0] && character == '\\' {
732                escaped = true;
733            } else if character == open {
734                quote = None;
735            }
736        } else if QUOTES.contains(&character) {
737            quote = Some(character);
738        } else if character == ',' {
739            items.push(&inner[start..index]);
740            start = index + 1;
741        }
742    }
743    items.push(&inner[start..]);
744    items
745        .into_iter()
746        .map(|item| unquote(item.trim()))
747        .filter(|item| !item.is_empty())
748        .collect()
749}
750
751/// A `key: value` line split at its first mapping colon, the key bare or
752/// quoted as YAML permits for an implicit key.
753fn key_value(line: &str) -> Option<(&str, &str)> {
754    let line = line.trim();
755    let (key, rest) = if let Some(quoted) = line.strip_prefix(QUOTES) {
756        let quote = line.chars().next()?;
757        let end = quoted.find(quote)?;
758        (&quoted[..end], quoted[end + 1..].trim_start())
759    } else {
760        let end = line.find(':')?;
761        (&line[..end], &line[end..])
762    };
763    let value = rest.strip_prefix(':')?;
764    if !(value.is_empty() || value.starts_with([' ', '\t'])) {
765        return None;
766    }
767    let key = key.trim();
768    if key.is_empty() || key.contains([' ', '\t']) {
769        return None;
770    }
771    Some((key, value.trim()))
772}
773
774/// The two quote characters a YAML scalar is written with, named by code
775/// point because the artifact-body scan reads a lone quote in these
776/// sources as a literal opening.
777const QUOTES: [char; 2] = ['\u{22}', '\u{27}'];
778
779fn unquote(value: &str) -> &str {
780    value
781        .strip_prefix(QUOTES[0])
782        .and_then(|rest| rest.strip_suffix(QUOTES[0]))
783        .or_else(|| {
784            value
785                .strip_prefix('\'')
786                .and_then(|rest| rest.strip_suffix('\''))
787        })
788        .unwrap_or(value)
789}
790
791/// One YAML scalar as text for the forms this reader accepts.
792///
793/// The projection emits JSON strings, which are YAML double-quoted scalars,
794/// so decoding JSON here preserves escaped quotes and backslashes in a
795/// generated workflow or job name. Authored single-quoted and bare values
796/// retain the reader's existing behavior.
797fn scalar(value: &str) -> String {
798    if value.starts_with(QUOTES[0])
799        && let Ok(decoded) = serde_json::from_str::<String>(value)
800    {
801        return decoded;
802    }
803    unquote(value).replace("''", "'")
804}
805
806fn indent(line: &str) -> usize {
807    line.len() - line.trim_start_matches(' ').len()
808}
809
810fn is_blank(line: &str) -> bool {
811    let trimmed = line.trim();
812    trimmed.is_empty() || trimmed.starts_with('#') || trimmed == "---"
813}
814
815#[cfg(test)]
816mod tests {
817    use super::*;
818
819    fn report(text: &str, check: &str) -> GateReport {
820        let dir = tempfile::tempdir().expect("a tempdir");
821        let workflows = dir.path().join(".github/workflows");
822        std::fs::create_dir_all(&workflows).expect("the workflows dir");
823        std::fs::write(workflows.join("ci.yml"), text).expect("the workflow writes");
824        read_gate(
825            Utf8Path::from_path(dir.path()).expect("utf-8 tempdir"),
826            check,
827            "master",
828        )
829    }
830
831    fn unfiltered() -> Trigger {
832        Trigger::default()
833    }
834
835    #[test]
836    fn the_trigger_is_read_in_every_on_form() {
837        assert_eq!(
838            request_trigger(
839                "on:\n  push:\n  pull_request:\n    branches: [master]\n",
840                "master"
841            ),
842            Some(unfiltered())
843        );
844        assert_eq!(
845            request_trigger(
846                "on:\n  push:\n  pull_request:\n    branches: [main]\n",
847                "master"
848            ),
849            Some(Trigger {
850                misses_trunk: Some("branches: [main]".to_owned()),
851                ..Trigger::default()
852            })
853        );
854        assert_eq!(
855            request_trigger(
856                "on:\n  pull_request:\n    branches-ignore:\n      - master\n    types: [opened]\n",
857                "master"
858            ),
859            Some(Trigger {
860                misses_trunk: Some("branches-ignore: [master]".to_owned()),
861                types_filtered: Some("types: [opened]".to_owned()),
862                ..Trigger::default()
863            })
864        );
865        assert_eq!(
866            request_trigger(
867                "on:\n  pull_request:\n    branches: ['**']\n    types: [opened, synchronize, reopened]\n",
868                "master"
869            ),
870            Some(unfiltered())
871        );
872        assert_eq!(
873            request_trigger(
874                "on:\n  pull_request:\n    branches: ['**', '!master']\n",
875                "master"
876            ),
877            Some(Trigger {
878                misses_trunk: Some("branches: [**, !master]".to_owned()),
879                ..Trigger::default()
880            })
881        );
882        assert_eq!(
883            request_trigger(
884                "on:\n  pull_request:\n    branches: ['!master', '**']\n",
885                "master"
886            ),
887            Some(Trigger {
888                misses_trunk: Some("branches: [!master, **]".to_owned()),
889                ..Trigger::default()
890            })
891        );
892        assert_eq!(
893            request_trigger(
894                "on:\n  pull_request:\n    branches-ignore: ['mast*']\n",
895                "master"
896            ),
897            Some(Trigger {
898                misses_trunk: Some("branches-ignore: [mast*]".to_owned()),
899                ..Trigger::default()
900            })
901        );
902        assert_eq!(
903            request_trigger(
904                "on:\n  pull_request:\n    branches-ignore: [dependabot]\n",
905                "master"
906            ),
907            Some(unfiltered())
908        );
909        assert_eq!(
910            request_trigger(
911                "on:\n  pull_request:\n    branches-ignore: ['ma[as]ter']\n",
912                "master"
913            ),
914            Some(Trigger {
915                misses_trunk: Some("branches-ignore: [ma[as]ter]".to_owned()),
916                ..Trigger::default()
917            })
918        );
919        assert_eq!(
920            request_trigger(
921                "on:\n  pull_request:\n    branches: [\"release/**\", 'a,b', master]\n",
922                "master"
923            ),
924            Some(unfiltered())
925        );
926        assert_eq!(
927            request_trigger(
928                "on:\n  pull_request:\n    branches: [\"topic\\\",master,tail\"]\n",
929                "master"
930            ),
931            Some(Trigger {
932                misses_trunk: Some("branches: [topic\\\",master,tail]".to_owned()),
933                ..Trigger::default()
934            })
935        );
936        assert_eq!(
937            request_trigger("on: [push, pull_request]\n", "master"),
938            Some(unfiltered())
939        );
940        assert_eq!(
941            request_trigger("on: pull_request_target\n", "master"),
942            Some(unfiltered())
943        );
944        assert_eq!(
945            request_trigger("on:\n  - push\n  - pull_request\n", "master"),
946            Some(unfiltered())
947        );
948        assert_eq!(
949            request_trigger("\"on\":\n  pull_request:\n", "master"),
950            Some(unfiltered())
951        );
952        assert_eq!(request_trigger("on: push\n", "master"), None);
953        assert_eq!(
954            request_trigger(
955                "on:\n  push:\n  workflow_dispatch:\njobs:\n  pull_request:\n",
956                "master"
957            ),
958            None
959        );
960        assert_eq!(
961            request_trigger(
962                "on:\n  pull_request:\n    paths:\n      - 'docs/**'\n  push:\n",
963                "master"
964            ),
965            Some(Trigger {
966                paths_filtered: true,
967                ..Trigger::default()
968            })
969        );
970        assert_eq!(
971            request_trigger(
972                "on:\n  push:\n    paths: [x]\n  pull_request:\n    branches: [master]\n",
973                "master"
974            ),
975            Some(unfiltered())
976        );
977    }
978
979    #[test]
980    fn jobs_read_names_needs_and_conditions_in_every_form() {
981        let text = "\
982jobs:
983  lint:
984    runs-on: ubuntu-latest
985  build:
986    name: \"Build it\" # the context
987    needs: lint
988  docs:
989    needs: [lint, build]
990  gate:
991    name: gate-${{ matrix.os }}
992    if: ${{ always() }}
993    needs:
994      - lint
995      - 'docs'
996    steps:
997      - uses: x@y
998        with:
999          needs: nothing
1000  odd:
1001    if: always() && needs.lint.result == 'success'
1002    needs: ${{ fromJSON(x) }}
1003  called:
1004    uses: org/repo/.github/workflows/x.yml@main
1005    name: called
1006  named-first:
1007    name: gate
1008    uses: org/repo/.github/workflows/x.yml@main
1009";
1010        let found = jobs(text);
1011        let ids: Vec<&str> = found.iter().map(|job| job.id.as_str()).collect();
1012        assert_eq!(
1013            ids,
1014            [
1015                "lint",
1016                "build",
1017                "docs",
1018                "gate",
1019                "odd",
1020                "called",
1021                "named-first"
1022            ]
1023        );
1024        assert_eq!(found[0].needs, Needs::None);
1025        assert_eq!(found[0].condition, Condition::Absent);
1026        assert_eq!(found[1].context(), Some("Build it"));
1027        assert_eq!(found[1].needs, Needs::Listed(vec!["lint".to_owned()]));
1028        assert_eq!(
1029            found[2].needs,
1030            Needs::Listed(vec!["lint".to_owned(), "build".to_owned()])
1031        );
1032        assert_eq!(found[3].name, Name::Unproven);
1033        assert_eq!(found[3].context(), None);
1034        assert_eq!(found[3].condition, Condition::Proven);
1035        assert_eq!(
1036            found[3].needs,
1037            Needs::Listed(vec!["lint".to_owned(), "docs".to_owned()])
1038        );
1039        assert_eq!(
1040            found[4].condition,
1041            Condition::Other("always() && needs.lint.result == 'success'".to_owned())
1042        );
1043        assert_eq!(found[4].needs, Needs::Opaque);
1044        assert!(found[5].reusable);
1045        assert_eq!(found[5].context(), None);
1046        assert!(found[6].reusable);
1047        assert_eq!(found[6].context(), None);
1048    }
1049
1050    #[test]
1051    fn flow_lists_keep_quoted_scalars_whole() {
1052        assert_eq!(list_items("[a, b]"), ["a", "b"]);
1053        assert_eq!(list_items("a"), ["a"]);
1054        assert_eq!(list_items("\"a\" # c"), ["a"]);
1055        assert_eq!(
1056            list_items("['ma[as]ter', \"x,y\", z]"),
1057            ["ma[as]ter", "x,y", "z"]
1058        );
1059        assert_eq!(list_items("[]"), Vec::<&str>::new());
1060        assert_eq!(
1061            list_items("[\"topic\\\",master,tail\", x]"),
1062            ["topic\\\",master,tail", "x"]
1063        );
1064    }
1065
1066    #[test]
1067    fn a_nested_jobs_key_opens_no_region() {
1068        let text = "\
1069jobs:
1070  call:
1071    uses: org/repo/.github/workflows/x.yml@main
1072    with:
1073      jobs: 3
1074  other:
1075    strategy:
1076      matrix:
1077        jobs: [a, b]
1078";
1079        let ids: Vec<String> = jobs(text).into_iter().map(|job| job.id).collect();
1080        assert_eq!(ids, ["call", "other"]);
1081    }
1082
1083    #[test]
1084    fn a_condition_is_proven_only_as_always_or_a_readable_not_cancelled() {
1085        assert_eq!(condition("always()"), Condition::Proven);
1086        assert_eq!(condition("${{ always() }}"), Condition::Proven);
1087        assert_eq!(condition("'${{always()}}'"), Condition::Proven);
1088        // The `!` survives YAML only inside the braces or inside quotes.
1089        assert_eq!(condition("${{ !cancelled() }}"), Condition::Proven);
1090        assert_eq!(condition("'!cancelled()'"), Condition::Proven);
1091        assert_eq!(condition("\"!cancelled()\""), Condition::Proven);
1092        // Unquoted, it is a YAML tag: the workflow does not parse at all.
1093        assert_eq!(
1094            condition("!cancelled()"),
1095            Condition::UnquotedTag("!cancelled()".to_owned())
1096        );
1097        assert_eq!(
1098            condition("${{ always() && false }}"),
1099            Condition::Other("always() && false".to_owned())
1100        );
1101        assert_eq!(
1102            condition("'!cancelled() && x'"),
1103            Condition::Other("!cancelled() && x".to_owned())
1104        );
1105        assert_eq!(
1106            condition("!always()"),
1107            Condition::UnquotedTag("!always()".to_owned())
1108        );
1109        assert_eq!(
1110            condition(""),
1111            Condition::Other("(a value carried on another line)".to_owned())
1112        );
1113    }
1114
1115    #[test]
1116    fn read_gate_judges_the_gates_shape() {
1117        let gated = report(
1118            "on: [pull_request]\njobs:\n  lint:\n  test:\n    if: always()\n    needs: [lint]\n",
1119            "test",
1120        );
1121        assert_eq!(gated.reading, GateReading::Gated);
1122        assert_eq!(gated.gate_condition, Some(Condition::Proven));
1123        assert_eq!(gated.gate_trigger, Trigger::default());
1124        assert_eq!(gated.reporting, 1);
1125        assert!(gated.unreadable.is_empty());
1126
1127        // A gate that needs one job of five is sound: which of the others
1128        // votes is the project's convention, and no file states it.
1129        let subset = report(
1130            "on: [pull_request]\njobs:\n  lint:\n  build:\n  docs:\n  pr-title:\n  test:\n    if: always()\n    needs: lint\n",
1131            "test",
1132        );
1133        assert_eq!(subset.reading, GateReading::Gated);
1134        assert_eq!(faults(&subset, "test", "master"), None);
1135
1136        let missing = report("on: [pull_request]\njobs:\n  lint:\n  unit:\n", "test");
1137        assert_eq!(
1138            missing.reading,
1139            GateReading::NoSuchJob {
1140                contexts: vec!["lint".to_owned(), "unit".to_owned()]
1141            }
1142        );
1143        assert_eq!(missing.gate_condition, None);
1144
1145        let dynamic = report(
1146            "on: [pull_request]\njobs:\n  lint:\n  test:\n    name: test-${{ matrix.os }}\n    needs: [lint]\n",
1147            "test",
1148        );
1149        assert_eq!(
1150            dynamic.reading,
1151            GateReading::UnprovenGateName {
1152                job: "test".to_owned()
1153            }
1154        );
1155
1156        let opaque = report(
1157            "on: [pull_request]\njobs:\n  lint:\n  test:\n    needs: *all\n",
1158            "test",
1159        );
1160        assert_eq!(
1161            opaque.reading,
1162            GateReading::OpaqueNeeds {
1163                workflow: "ci.yml".to_owned()
1164            }
1165        );
1166
1167        let filtered = report(
1168            "on:\n  pull_request:\n    paths: ['src/**']\njobs:\n  test:\n    if: always()\n",
1169            "test",
1170        );
1171        assert_eq!(filtered.reading, GateReading::Gated);
1172        assert!(filtered.gate_trigger.paths_filtered);
1173
1174        let off_trunk = report(
1175            "on:\n  pull_request:\n    branches: [main]\njobs:\n  test:\n    if: always()\n",
1176            "test",
1177        );
1178        assert_eq!(
1179            off_trunk.gate_trigger.misses_trunk,
1180            Some("branches: [main]".to_owned())
1181        );
1182
1183        let reusable = report(
1184            "on: [pull_request]\njobs:\n  test:\n    uses: org/repo/.github/workflows/x.yml@main\n    name: test\n",
1185            "test",
1186        );
1187        assert_eq!(
1188            reusable.reading,
1189            GateReading::UnprovenGateName {
1190                job: "test".to_owned()
1191            }
1192        );
1193
1194        let push_only = report("on: push\njobs:\n  lint:\n  test:\n", "test");
1195        assert_eq!(push_only.reading, GateReading::NoRequestWorkflows);
1196
1197        // Two jobs reporting one context leave the protection unable to say
1198        // which one it is holding for.
1199        let duplicated = report(
1200            "on: [pull_request]\njobs:\n  test:\n    if: always()\n  other:\n    name: test\n",
1201            "test",
1202        );
1203        assert_eq!(duplicated.reporting, 2);
1204        let text = faults(&duplicated, "test", "master").expect("a fault");
1205        assert!(
1206            text.contains("no longer stands for the gate alone"),
1207            "{text}"
1208        );
1209
1210        let dir = tempfile::tempdir().expect("a tempdir");
1211        let empty = read_gate(
1212            Utf8Path::from_path(dir.path()).expect("utf-8"),
1213            "test",
1214            "master",
1215        );
1216        assert_eq!(empty.reading, GateReading::NoRequestWorkflows);
1217        assert!(empty.unreadable.is_empty());
1218    }
1219
1220    /// The gate wakes on a workflow and judges a check, and nothing but
1221    /// this reader proves the two answers describe one file.
1222    ///
1223    /// SATISFIES target-config:a-setup-fact-is-committed-once
1224
1225    #[test]
1226    fn the_waking_workflow_contains_the_named_check() {
1227        fn target(files: &[(&str, &str)]) -> tempfile::TempDir {
1228            let dir = tempfile::tempdir().expect("a tempdir");
1229            let workflows = dir.path().join(".github/workflows");
1230            std::fs::create_dir_all(&workflows).expect("the directory exists");
1231            for (name, text) in files {
1232                std::fs::write(workflows.join(name), text).expect("the workflow writes");
1233            }
1234            dir
1235        }
1236        fn fault(dir: &tempfile::TempDir, workflow: &str, check: &str) -> Option<String> {
1237            waking_workflow_fault(
1238                Utf8Path::from_path(dir.path()).expect("utf-8"),
1239                workflow,
1240                check,
1241                "master",
1242            )
1243        }
1244
1245        let sound = target(&[(
1246            "ci.yml",
1247            "name: ci\non: [pull_request]\njobs:\n  gate:\n    if: always()\n",
1248        )]);
1249        assert_eq!(fault(&sound, "ci", "gate"), None);
1250
1251        // The trigger names the name: value and never the filename, so a
1252        // file with no name: reports its path and no trigger can name it.
1253        let unnamed = target(&[(
1254            "ci.yml",
1255            "on: [pull_request]\njobs:\n  gate:\n    if: always()\n",
1256        )]);
1257        let text = fault(&unnamed, "ci", "gate").expect("a fault");
1258        assert!(text.contains("never the filename"), "{text}");
1259
1260        // The gate would wake on one workflow and judge a check another
1261        // workflow reports, so it wakes before the check can report.
1262        let split = target(&[
1263            ("ci.yml", "name: ci\non: [pull_request]\njobs:\n  build:\n"),
1264            (
1265                "checks.yml",
1266                "name: checks\non: [pull_request]\njobs:\n  gate:\n    if: always()\n",
1267            ),
1268        ]);
1269        let text = fault(&split, "ci", "gate").expect("a fault");
1270        assert!(text.contains("carries no job reporting"), "{text}");
1271
1272        // A trigger that never reaches the trunk never wakes for a
1273        // release request.
1274        let off_trunk = target(&[(
1275            "ci.yml",
1276            "name: ci\non:\n  pull_request:\n    branches: [main]\njobs:\n  gate:\n    if: always()\n",
1277        )]);
1278        let text = fault(&off_trunk, "ci", "gate").expect("a fault");
1279        assert!(text.contains("against master"), "{text}");
1280    }
1281
1282    #[test]
1283    fn an_unreadable_workflow_is_named_not_skipped() {
1284        let dir = tempfile::tempdir().expect("a tempdir");
1285        let workflows = dir.path().join(".github/workflows");
1286        std::fs::create_dir_all(workflows.join("broken.yml")).expect("a directory named as a file");
1287        std::fs::write(
1288            workflows.join("ci.yml"),
1289            "on: [pull_request]\njobs:\n  test:\n    if: always()\n",
1290        )
1291        .expect("the workflow writes");
1292        let report = read_gate(
1293            Utf8Path::from_path(dir.path()).expect("utf-8"),
1294            "test",
1295            "master",
1296        );
1297        assert_eq!(report.reading, GateReading::Gated);
1298        assert_eq!(report.unreadable, vec!["broken.yml".to_owned()]);
1299        let text = faults(&report, "test", "master").expect("a fault");
1300        assert!(text.contains("[broken.yml] could not be read"), "{text}");
1301        // The unreadable file leaves uniqueness unproven; the text must not
1302        // convert that into a claim of uniqueness.
1303        assert!(!text.contains("stands for the gate alone"), "{text}");
1304    }
1305
1306    #[test]
1307    fn fault_texts_are_one_line_each() {
1308        let base = || GateReport {
1309            reading: GateReading::Gated,
1310            gate_condition: Some(Condition::Proven),
1311            gate_trigger: Trigger::default(),
1312            reporting: 1,
1313            unreadable: Vec::new(),
1314        };
1315        assert_eq!(faults(&base(), "test", "master"), None);
1316        let cases = [
1317            GateReport {
1318                reading: GateReading::NoRequestWorkflows,
1319                gate_condition: None,
1320                ..base()
1321            },
1322            GateReport {
1323                reading: GateReading::NoSuchJob {
1324                    contexts: vec!["lint".to_owned()],
1325                },
1326                gate_condition: None,
1327                ..base()
1328            },
1329            GateReport {
1330                reading: GateReading::UnprovenGateName {
1331                    job: "test".to_owned(),
1332                },
1333                gate_condition: None,
1334                ..base()
1335            },
1336            GateReport {
1337                reading: GateReading::OpaqueNeeds {
1338                    workflow: "ci.yml".to_owned(),
1339                },
1340                gate_condition: Some(Condition::Absent),
1341                ..base()
1342            },
1343            GateReport {
1344                gate_condition: Some(Condition::Other("always() && x".to_owned())),
1345                ..base()
1346            },
1347            GateReport {
1348                gate_condition: Some(Condition::UnquotedTag("!cancelled()".to_owned())),
1349                ..base()
1350            },
1351            GateReport {
1352                reporting: 2,
1353                ..base()
1354            },
1355            GateReport {
1356                gate_trigger: Trigger {
1357                    paths_filtered: true,
1358                    misses_trunk: Some("branches: [main]".to_owned()),
1359                    types_filtered: Some("types: [opened]".to_owned()),
1360                },
1361                ..base()
1362            },
1363            GateReport {
1364                unreadable: vec!["x.yml".to_owned()],
1365                ..base()
1366            },
1367        ];
1368        for case in &cases {
1369            let text = faults(case, "test", "master").expect("a fault");
1370            assert!(!text.contains('\n'), "{text}");
1371            assert!(
1372                text.starts_with(|c: char| c.is_lowercase() || c == '['),
1373                "{text}"
1374            );
1375        }
1376    }
1377}