Skip to main content

yah_qed/
transform.rs

1//! Assisted one-way GHA→QED transformer (R533-F4, W224).
2//!
3//! W224 ("import, don't emulate") makes a GitHub Actions workflow an *import
4//! source*, not a foreign runtime QED reproduces forever. The onboarding path
5//! is a **one-way, assisted, lossy-with-warnings** transform: map the tier-1/2
6//! ~80% mechanically, and **flag** the tier-3 steps it deliberately declines to
7//! imitate — proposing the native QED replacement for each rather than guessing.
8//!
9//! This module is that transform. It sits on top of the [`yah_qed_gha`] parser +
10//! the R533-F2 [tier classifier](yah_qed_gha::classify_step) and emits native
11//! [`QedStep`]s for the runnable compute, paired with a list of human-facing
12//! flags for everything that needs a decision. It is **pure** — it operates on
13//! an already-parsed [`Workflow`] and performs no I/O — so the runner / the
14//! `eject` materializer (R533-F6) own the file read and the TOML write.
15//!
16//! ## What maps mechanically, what gets flagged
17//!
18//! | Parsed step | Tier (F2) | Result |
19//! |---|---|---|
20//! | `run:` bash | 1/2 compute | **native** [`StepKind::Subprocess`] step |
21//! | `run:` bash reaching `gh`/`api.github.com`/`GITHUB_TOKEN` | 1/2 + service touch | native step **and** an [`FlagKind::EmbeddedServiceTouch`] flag |
22//! | `run:` carrying `${{ … }}` | 1/2 compute | native step **and** an [`FlagKind::UnresolvedExpression`] flag |
23//! | `uses: org/setup-*`, `dtolnay/rust-toolchain`, … | 1/2 toolkit | [`FlagKind::ToolkitAction`] — runs via the R533-T7 toolkit executor, no subprocess emitted yet |
24//! | `uses: actions/checkout`, `cache`, `upload-artifact`, `gh-release`, `build-push`, … | 3 service | [`FlagKind::ReplaceWithNative`] carrying the native stanza |
25//! | `uses:` unrecognized | unknown | [`FlagKind::Unknown`] — surfaced for review |
26//!
27//! ## Job DAG → flat pipeline
28//!
29//! A native [`Pipeline`](crate::types::Pipeline) is a flat `Vec<QedStep>` run in
30//! declaration order; GHA workflows are a job DAG. The transform flattens jobs
31//! into a **topological linearization** ([`yah_qed_gha::topo_sort`]) — every job's
32//! `needs:` predecessors emit before it — so execution order is honest even
33//! though inter-job parallelism collapses to sequential. Matrix expansion and
34//! the `workflow_call` port contract are *not* handled here: target lifting out
35//! of `strategy.matrix` is R533-F9 (it layers onto the steps emitted here) and
36//! the down/up-port mapping is R533-F5.
37
38use crate::matrix::MatrixSpec;
39use crate::platform::PlatformSpec;
40use crate::types::{OnFail, QedStep, StepActivation, StepKind};
41use indexmap::IndexMap;
42use yah_qed_gha::{
43    classify_step, topo_sort, Disposition, ExprString, ExprToken, Job, NativeReplacement,
44    ServiceTouch, Step, StepAction, Workflow,
45};
46
47/// The result of transforming one workflow — the native steps that mapped
48/// mechanically, interleaved (in execution order) with the flags the human must
49/// resolve. "Assisted / lossy-with-warnings" made concrete: nothing tier-3 is
50/// silently run, and nothing un-mappable is silently dropped.
51#[derive(Debug, Clone)]
52pub struct TransformReport {
53    /// Native pipeline name — the workflow `name:` sanitized to a slug, or
54    /// `"imported-workflow"` when the source declares none.
55    pub name: String,
56    /// Human-readable label — the workflow `name:` verbatim, else the slug.
57    pub label: String,
58    /// One entry per parsed step, in flattened (topo-job then step) order. Each
59    /// carries the mechanically-mapped native step (when one could be emitted)
60    /// and/or the flags raised for it.
61    pub steps: Vec<TransformedStep>,
62}
63
64impl TransformReport {
65    /// The mechanically-mapped native steps, in order — the runnable spine F6
66    /// materializes and F9 lifts platform targets into.
67    pub fn native_steps(&self) -> impl Iterator<Item = &QedStep> {
68        self.steps.iter().filter_map(|s| s.native.as_ref())
69    }
70
71    /// Owned copy of the native steps, ready to drop into a
72    /// [`Pipeline::steps`](crate::types::Pipeline::steps).
73    pub fn collect_native(&self) -> Vec<QedStep> {
74        self.native_steps().cloned().collect()
75    }
76
77    /// Every flag raised across all steps, paired with the step that raised it.
78    pub fn flags(&self) -> impl Iterator<Item = (&TransformedStep, &FlagKind)> {
79        self.steps.iter().flat_map(|s| s.flags.iter().map(move |f| (s, f)))
80    }
81
82    /// `true` when every step mapped to clean native compute with no flag — the
83    /// workflow imported losslessly (rare; most real workflows touch tier 3).
84    pub fn is_clean(&self) -> bool {
85        self.steps.iter().all(|s| s.flags.is_empty() && s.native.is_some())
86    }
87}
88
89/// One parsed workflow step after transformation. A clean tier-1/2 `run:` step
90/// has `native = Some(..)` and `flags = []`; a tier-3 step has `native = None`
91/// and a [`FlagKind::ReplaceWithNative`]; a `run:` step reaching the service has
92/// **both** a native step (it runs) and a flag (the reach won't resolve on QED).
93#[derive(Debug, Clone)]
94pub struct TransformedStep {
95    /// Owning GHA job id.
96    pub job: String,
97    /// 0-based index within the job's `steps:` list.
98    pub step_index: usize,
99    /// The step's `name:` rendered to text, or `None` when unnamed.
100    pub step_name: Option<String>,
101    /// The mechanically-mapped native step, when one could be emitted. `None`
102    /// for purely-flagged steps (tier-3, toolkit `uses:`, unknown) — there is
103    /// nothing to run natively yet.
104    pub native: Option<QedStep>,
105    /// Why this step needs human attention. Empty for clean compute.
106    pub flags: Vec<FlagKind>,
107}
108
109/// Why a parsed step couldn't be imported as clean native compute — the
110/// assisted half of the transform. Each variant proposes what to do instead.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum FlagKind {
113    /// Tier-3 GitHub-the-service action. Replace with the named native QED
114    /// facility; [`stanza_hint`](FlagKind::stanza_hint) carries the guidance.
115    ReplaceWithNative(NativeReplacement),
116    /// A clean-compute `run:` step that reaches GitHub-the-service from inside
117    /// its bash. It still runs on the executor, but the service call won't
118    /// resolve on QED — replace the reach with a native facility.
119    EmbeddedServiceTouch(Vec<ServiceTouch>),
120    /// A tier-1/2 `uses:` toolkit action. Runs via the toolkit-contract
121    /// executor (R533-T7); no native subprocess is emitted until that executor's
122    /// step surface lands.
123    ToolkitAction { slug: String, git_ref: Option<String> },
124    /// An unrecognized `uses:` slug — surfaced for review rather than guessed.
125    Unknown { slug: String },
126    /// A mechanically-mapped `run:` step whose script still carries GHA
127    /// `${{ … }}` expressions, which QED's `{{key}}` subprocess substitution
128    /// won't expand. Convert to QED params / native outputs, or lift via
129    /// import-time target lifting (R533-F9).
130    UnresolvedExpression,
131}
132
133/// How loud a [`FlagKind`] is, for preflight summaries and reports.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum FlagSeverity {
136    /// Tier-3 surface QED declines to imitate — the import *cannot* run this as
137    /// authored; a native replacement is required.
138    Replace,
139    /// A human decision is needed (unrecognized action, embedded service reach).
140    Review,
141    /// Informational — handled by other relay tickets (T7 executor, F9 lifting).
142    Info,
143}
144
145impl FlagSeverity {
146    pub fn label(self) -> &'static str {
147        match self {
148            FlagSeverity::Replace => "replace",
149            FlagSeverity::Review => "review",
150            FlagSeverity::Info => "info",
151        }
152    }
153}
154
155impl FlagKind {
156    /// Severity bucket for this flag.
157    pub fn severity(&self) -> FlagSeverity {
158        match self {
159            FlagKind::ReplaceWithNative(_) => FlagSeverity::Replace,
160            FlagKind::EmbeddedServiceTouch(_) | FlagKind::Unknown { .. } => FlagSeverity::Review,
161            FlagKind::ToolkitAction { .. } | FlagKind::UnresolvedExpression => FlagSeverity::Info,
162        }
163    }
164
165    /// The "here's the native stanza" guidance surfaced alongside the flag — the
166    /// lossy-with-warnings payload W224 calls for.
167    pub fn stanza_hint(&self) -> String {
168        match self {
169            FlagKind::ReplaceWithNative(nr) => nr.stanza_hint().to_string(),
170            FlagKind::EmbeddedServiceTouch(touches) => {
171                let names: Vec<&str> = touches.iter().map(|t| t.label()).collect();
172                format!(
173                    "Run step reaches GitHub-the-service ({}); it runs on the executor but the \
174                     call won't resolve on QED — replace with a native facility \
175                     (content-addressed artifacts / a W208 publisher).",
176                    names.join(", ")
177                )
178            }
179            FlagKind::ToolkitAction { slug, .. } => format!(
180                "Tier-1/2 toolkit action `{slug}` — runs via the toolkit-contract executor \
181                 (R533-T7); no native subprocess emitted yet."
182            ),
183            FlagKind::Unknown { slug } => format!(
184                "Unrecognized action `{slug}` — map it by hand or extend the tier catalog \
185                 (qed-gha `classify_uses`); not run silently."
186            ),
187            FlagKind::UnresolvedExpression => {
188                "Script carries GHA `${{ … }}` expressions QED's subprocess substitution won't \
189                 expand; convert to QED params (`{{key}}`) / native outputs, or lift the build \
190                 target at import time (R533-F9)."
191                    .to_string()
192            }
193        }
194    }
195}
196
197/// Transform a parsed workflow into native QED steps + assisted flags.
198///
199/// Jobs are flattened in [`topo_sort`] order so `needs:` predecessors precede
200/// their dependents; an unresolvable graph (cycle / unknown `needs`) falls back
201/// to declaration order rather than failing the import — the operator still gets
202/// the per-step transform to work from.
203pub fn transform_workflow(wf: &Workflow) -> TransformReport {
204    let label = wf
205        .name
206        .clone()
207        .unwrap_or_else(|| "imported workflow".to_string());
208    let name = slugify(&label, "imported-workflow");
209
210    // Topo-linearize the job DAG; on an unresolvable graph, keep declaration
211    // order so the import still produces something to edit.
212    let order: Vec<String> = topo_sort(wf)
213        .map(|waves| waves.into_iter().flatten().collect())
214        .unwrap_or_else(|_| wf.jobs.keys().cloned().collect());
215
216    let mut steps = Vec::new();
217    for job_id in &order {
218        let Some(job) = wf.jobs.get(job_id) else { continue };
219        for (step_index, step) in job.steps.iter().enumerate() {
220            steps.push(transform_step(job_id, job, step_index, step));
221        }
222    }
223
224    TransformReport { name, label, steps }
225}
226
227/// Convenience: parse raw workflow YAML and transform it in one call. Still
228/// pure (no file I/O) — the caller supplies the bytes. Used by `eject`
229/// (R533-F6) and the tests here.
230pub fn transform_workflow_src(src: &str) -> Result<TransformReport, yah_qed_gha::ParseError> {
231    Ok(transform_workflow(&yah_qed_gha::parse_workflow(src)?))
232}
233
234/// Transform a single classified step.
235fn transform_step(job_id: &str, job: &Job, step_index: usize, step: &Step) -> TransformedStep {
236    let step_name = step.name.as_ref().map(render_exprstring).map(|s| s.trim().to_string());
237    let class = classify_step(step);
238    let mut native = None;
239    let mut flags = Vec::new();
240
241    match (&step.action, &class.disposition) {
242        // Tier-1/2 `run:` compute → mechanical native subprocess. Embedded
243        // service touches / surviving expressions ride along as flags.
244        (StepAction::Run { body, shell }, Disposition::Compute) => {
245            let (step_native, lifted_key) =
246                map_run_step(job_id, job, step_index, step, body, shell.as_deref());
247            native = Some(step_native);
248            if !class.service_touches.is_empty() {
249                flags.push(FlagKind::EmbeddedServiceTouch(class.service_touches.clone()));
250            }
251            // A `${{ matrix.<key> }}` reference that R533-F9 lifted (target
252            // dimension carried as a step matrix) resolves natively, so it is
253            // *not* an unresolved expression; any other `${{ … }}` still is.
254            let key = lifted_key.as_deref();
255            if has_unresolved_expression(body, key)
256                || step.env.values().any(|v| has_unresolved_expression(v, key))
257            {
258                flags.push(FlagKind::UnresolvedExpression);
259            }
260        }
261        // Tier-1/2 `uses:` toolkit action → flagged for the T7 executor.
262        (StepAction::Uses { slug, git_ref, .. }, Disposition::Compute) => {
263            flags.push(FlagKind::ToolkitAction { slug: slug.clone(), git_ref: git_ref.clone() });
264        }
265        // Tier-3 → replace with the named native facility.
266        (_, Disposition::ReplaceWithNative(nr)) => {
267            flags.push(FlagKind::ReplaceWithNative(*nr));
268        }
269        // Unrecognized `uses:` → surface for review.
270        (StepAction::Uses { slug, .. }, Disposition::Unknown) => {
271            flags.push(FlagKind::Unknown { slug: slug.clone() });
272        }
273        // A `run:` step is always Compute in the classifier, so this is
274        // unreachable in practice; map it natively rather than dropping it.
275        (StepAction::Run { body, shell }, Disposition::Unknown) => {
276            native = Some(map_run_step(job_id, job, step_index, step, body, shell.as_deref()).0);
277        }
278    }
279
280    TransformedStep { job: job_id.to_string(), step_index, step_name, native, flags }
281}
282
283/// Map a tier-1/2 `run:` step to a native [`StepKind::Subprocess`] step,
284/// lifting any build target (R533-F9) into the structured `platform` field.
285///
286/// Returns the step plus the matrix key whose target dimension was lifted (so
287/// the caller can suppress the unresolved-expression flag for that resolved
288/// reference). `None` when no matrix-driven target was carried.
289fn map_run_step(
290    job_id: &str,
291    job: &Job,
292    step_index: usize,
293    step: &Step,
294    body: &ExprString,
295    shell: Option<&str>,
296) -> (QedStep, Option<String>) {
297    let name = match step.name.as_ref().map(render_exprstring) {
298        Some(n) if !n.trim().is_empty() => format!("{job_id}: {}", n.trim()),
299        _ => format!("{job_id}: step {step_index}"),
300    };
301    let script = render_exprstring(body);
302    let argv = shell_argv(shell, &script);
303    let env = step
304        .env
305        .iter()
306        .map(|(k, v)| (k.clone(), render_exprstring(v)))
307        .collect();
308    let cwd = step
309        .working_directory
310        .as_ref()
311        .map(render_exprstring)
312        .filter(|s| !s.is_empty());
313    let timeout = step.timeout_minutes.map(|m| u64::from(m) * 60);
314    let on_fail = if step.continue_on_error == Some(true) {
315        OnFail::Continue
316    } else {
317        OnFail::Abort
318    };
319    let if_cond = step.if_cond.as_ref().map(render_exprstring);
320
321    // R533-F9: lift the build target out of `--target <triple>` into the
322    // structured platform field, so F3's native resolve() reasons about it
323    // instead of a runtime bash scrape.
324    let (platform, matrix, lifted_key) = lift_target(job, &script);
325
326    let step = QedStep {
327        background: false,
328        background_until: None,
329        wait_for: None,
330        argv,
331        cwd,
332        env,
333        timeout,
334        on_fail,
335        if_cond,
336        platform,
337        matrix,
338        ..base_step(name)
339    };
340    (step, lifted_key)
341}
342
343/// Lift a `--target <triple>` token from a step's script into a [`PlatformSpec`]
344/// (R533-F9). When the target is a `${{ matrix.<key> }}` reference whose job
345/// matrix dimension holds concrete triples, the dimension is carried as a
346/// step-level [`MatrixSpec`] so QED fans the step out, one native build per
347/// target — and the `platform.target` reference concretizes per row.
348///
349/// Returns `(platform, step_matrix, lifted_matrix_key)`; all `None` when the
350/// step declares no `--target`.
351fn lift_target(job: &Job, script: &str) -> (Option<PlatformSpec>, Option<MatrixSpec>, Option<String>) {
352    let Some(raw_target) = extract_target(script) else {
353        return (None, None, None);
354    };
355    let platform = Some(PlatformSpec {
356        target: Some(raw_target.clone()),
357        container_platform: None,
358        native: false,
359    });
360
361    // A concrete triple needs no matrix; a matrix reference whose dimension we
362    // can resolve carries the target values so QED expands them natively.
363    if let Some(key) = matrix_ref_key(&raw_target) {
364        let values = matrix_target_values(job, &key);
365        if !values.is_empty() {
366            return (platform, Some(target_matrix(&key, &values)), Some(key));
367        }
368    }
369    (platform, None, None)
370}
371
372/// Extract the value of the first `--target <X>` / `--target=<X>` flag in a
373/// script. `X` is either a concrete triple or a `${{ matrix.<key> }}` reference
374/// (returned with normalized spacing). Returns `None` when absent — and is
375/// careful not to mistake `--target-dir` for `--target`.
376fn extract_target(script: &str) -> Option<String> {
377    const FLAG: &str = "--target";
378    let mut from = 0;
379    while let Some(rel) = script[from..].find(FLAG) {
380        let pos = from + rel;
381        let after = &script[pos + FLAG.len()..];
382        from = pos + FLAG.len();
383        let mut chars = after.chars();
384        match chars.next() {
385            // `--target=<value>`
386            Some('=') => {
387                if let Some(v) = read_target_value(&after[1..]) {
388                    return Some(v);
389                }
390            }
391            // `--target <value>`
392            Some(c) if c.is_whitespace() => {
393                if let Some(v) = read_target_value(after) {
394                    return Some(v);
395                }
396            }
397            // `--target-dir`, `--targets`, … — not the flag we want.
398            _ => {}
399        }
400    }
401    None
402}
403
404/// Read a target value at the start of `s` (already past `--target`/`=`): a
405/// `${{ … }}` block (preserved with normalized spacing) or a non-whitespace run.
406fn read_target_value(s: &str) -> Option<String> {
407    let s = s.trim_start();
408    if let Some(rest) = s.strip_prefix("${{") {
409        let end = rest.find("}}")?;
410        return Some(format!("${{{{ {} }}}}", rest[..end].trim()));
411    }
412    let val: String = s.chars().take_while(|c| !c.is_whitespace()).collect();
413    (!val.is_empty()).then_some(val)
414}
415
416/// The matrix dimension key of a bare `${{ matrix.<key> }}` reference, or `None`
417/// for a concrete value or a more complex expression.
418fn matrix_ref_key(value: &str) -> Option<String> {
419    let inner = value.trim().strip_prefix("${{")?.strip_suffix("}}")?.trim();
420    let key = inner.strip_prefix("matrix.")?.trim();
421    (!key.is_empty() && !key.contains(char::is_whitespace)).then(|| key.to_string())
422}
423
424/// Concrete triple values for a job's `strategy.matrix.<key>` dimension —
425/// gathered from both the dimension list and any `include:` rows that carry the
426/// key (release-shaped include-only matrices put the target on include rows).
427fn matrix_target_values(job: &Job, key: &str) -> Vec<String> {
428    let mut out: Vec<String> = Vec::new();
429    let mut push = |s: String| {
430        if !out.contains(&s) {
431            out.push(s);
432        }
433    };
434    if let Some(matrix) = job.strategy.as_ref().and_then(|s| s.matrix.as_ref()) {
435        if let Some(values) = matrix.dimensions.get(key) {
436            for v in values {
437                if let Some(s) = v.as_str() {
438                    push(s.to_string());
439                }
440            }
441        }
442        for inc in &matrix.include {
443            if let Some(s) = inc.get(key).and_then(|v| v.as_str()) {
444                push(s.to_string());
445            }
446        }
447    }
448    out
449}
450
451/// A single-dimension step matrix over the lifted target triples.
452fn target_matrix(key: &str, values: &[String]) -> MatrixSpec {
453    let mut dimensions: IndexMap<String, Vec<toml::Value>> = IndexMap::new();
454    dimensions.insert(
455        key.to_string(),
456        values.iter().map(|s| toml::Value::String(s.clone())).collect(),
457    );
458    MatrixSpec { dimensions, include: Vec::new(), exclude: Vec::new() }
459}
460
461/// A `QedStep` with every non-`Subprocess` field at its default — the spine
462/// `map_run_step` overlays argv/env/etc. onto. (QedStep has no `Default`; its
463/// literal sites construct all fields explicitly.)
464fn base_step(name: String) -> QedStep {
465    QedStep {
466        background: false,
467        background_until: None,
468        wait_for: None,
469        manifest_stitch: None,
470        name,
471        argv: Vec::new(),
472        cwd: None,
473        env: std::collections::HashMap::new(),
474        timeout: None,
475        on_fail: OnFail::Abort,
476        produces: Vec::new(),
477        runtime: None,
478        kind: StepKind::Subprocess,
479        image: None,
480        tag: None,
481        push: false,
482        platforms: Vec::new(),
483        binary_path: None,
484        triple: None,
485        package: None,
486        context: None,
487        load: false,
488        sub_pipeline: None,
489        outputs: Vec::new(),
490        gha_workflow: None,
491        import: None,
492        matrix: None,
493        enabled: true,
494        activation: StepActivation::Active,
495        if_cond: None,
496        platform: None,
497        toolchain: None,
498    }
499}
500
501/// Wrap a rendered script body in its shell's argv. GHA's default `bash`/`sh`
502/// run with fail-fast (`set -eo pipefail` / `set -e`); preserve that so an
503/// imported step fails on the same line it would on GitHub rather than silently
504/// swallowing a mid-script error.
505fn shell_argv(shell: Option<&str>, script: &str) -> Vec<String> {
506    let argv = |prog: &str, flag: &str, body: String| {
507        vec![prog.to_string(), flag.to_string(), body]
508    };
509    match shell.unwrap_or("bash") {
510        "bash" => argv("bash", "-c", format!("set -eo pipefail\n{script}")),
511        "sh" => argv("sh", "-c", format!("set -e\n{script}")),
512        "pwsh" | "powershell" => argv("pwsh", "-Command", script.to_string()),
513        "python" | "python3" => argv("python3", "-c", script.to_string()),
514        other => argv(other, "-c", script.to_string()),
515    }
516}
517
518/// Render an [`ExprString`] back to text, reconstructing `${{ … }}` around each
519/// expression token. Literal segments pass through verbatim.
520pub(crate) fn render_exprstring(s: &ExprString) -> String {
521    let mut out = String::new();
522    for t in &s.tokens {
523        match t {
524            ExprToken::Literal(x) => out.push_str(x),
525            ExprToken::Expr(x) => {
526                out.push_str("${{ ");
527                out.push_str(x);
528                out.push_str(" }}");
529            }
530        }
531    }
532    out
533}
534
535/// True when the string carries a `${{ … }}` expression QED won't expand. A
536/// `${{ matrix.<key> }}` reference to `resolved_matrix_key` (the target
537/// dimension R533-F9 carried as a step matrix) *does* resolve natively, so it
538/// is not counted; every other expression — `github.*`, an unlifted matrix key
539/// — is unresolved.
540fn has_unresolved_expression(s: &ExprString, resolved_matrix_key: Option<&str>) -> bool {
541    s.tokens.iter().any(|t| match t {
542        ExprToken::Literal(_) => false,
543        ExprToken::Expr(raw) => match (raw.trim().strip_prefix("matrix."), resolved_matrix_key) {
544            (Some(k), Some(rk)) => k.trim() != rk,
545            _ => true,
546        },
547    })
548}
549
550/// Sanitize a workflow name into a pipeline-name slug: lowercase, non-alnum runs
551/// collapsed to a single `-`, trimmed. Empty → `fallback`.
552fn slugify(name: &str, fallback: &str) -> String {
553    let mut out = String::new();
554    let mut prev_dash = false;
555    for ch in name.chars() {
556        if ch.is_ascii_alphanumeric() {
557            out.push(ch.to_ascii_lowercase());
558            prev_dash = false;
559        } else if !prev_dash {
560            out.push('-');
561            prev_dash = true;
562        }
563    }
564    let slug = out.trim_matches('-').to_string();
565    if slug.is_empty() {
566        fallback.to_string()
567    } else {
568        slug
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575
576    /// Parse + transform an inline workflow.
577    fn xf(src: &str) -> TransformReport {
578        transform_workflow_src(src).expect("parse")
579    }
580
581    /// The native step emitted for `job`'s step at `idx`.
582    fn native_at<'a>(r: &'a TransformReport, job: &str, idx: usize) -> &'a QedStep {
583        r.steps
584            .iter()
585            .find(|s| s.job == job && s.step_index == idx)
586            .and_then(|s| s.native.as_ref())
587            .unwrap_or_else(|| panic!("no native step at {job}[{idx}]"))
588    }
589
590    /// The flags on `job`'s step at `idx`.
591    fn flags_at<'a>(r: &'a TransformReport, job: &str, idx: usize) -> &'a [FlagKind] {
592        &r.steps
593            .iter()
594            .find(|s| s.job == job && s.step_index == idx)
595            .unwrap_or_else(|| panic!("no step at {job}[{idx}]"))
596            .flags
597    }
598
599    const RUN_JOB: &str = r#"
600name: ci
601on: push
602jobs:
603  build:
604    runs-on: ubuntu-latest
605    steps:
606      - name: Compile
607        run: cargo build --release
608        env:
609          RUSTFLAGS: "-D warnings"
610        working-directory: app
611        timeout-minutes: 20
612        continue-on-error: true
613"#;
614
615    #[test]
616    fn run_step_maps_to_native_subprocess() {
617        let r = xf(RUN_JOB);
618        let step = native_at(&r, "build", 0);
619        assert_eq!(step.name, "build: Compile");
620        assert_eq!(step.kind, StepKind::Subprocess);
621        assert_eq!(step.argv[0], "bash");
622        assert_eq!(step.argv[1], "-c");
623        assert!(step.argv[2].starts_with("set -eo pipefail\n"));
624        assert!(step.argv[2].contains("cargo build --release"));
625        assert_eq!(step.env.get("RUSTFLAGS").map(String::as_str), Some("-D warnings"));
626        assert_eq!(step.cwd.as_deref(), Some("app"));
627        assert_eq!(step.timeout, Some(20 * 60));
628        assert!(matches!(step.on_fail, OnFail::Continue));
629        assert!(flags_at(&r, "build", 0).is_empty(), "clean compute → no flags");
630    }
631
632    #[test]
633    fn pipeline_name_is_slugified_from_workflow_name() {
634        let r = xf("name: My Release Flow!\non: push\njobs:\n  a:\n    runs-on: x\n    steps:\n      - run: true\n");
635        assert_eq!(r.name, "my-release-flow");
636        assert_eq!(r.label, "My Release Flow!");
637    }
638
639    #[test]
640    fn unnamed_workflow_falls_back() {
641        let r = xf("on: push\njobs:\n  a:\n    runs-on: x\n    steps:\n      - run: true\n");
642        assert_eq!(r.name, "imported-workflow");
643    }
644
645    #[test]
646    fn tier3_checkout_is_flagged_not_mapped() {
647        let r = xf("on: push\njobs:\n  a:\n    runs-on: x\n    steps:\n      - uses: actions/checkout@v4\n");
648        let step = &r.steps[0];
649        assert!(step.native.is_none(), "tier-3 emits no native step");
650        assert_eq!(
651            step.flags,
652            vec![FlagKind::ReplaceWithNative(NativeReplacement::Checkout)]
653        );
654        assert_eq!(step.flags[0].severity(), FlagSeverity::Replace);
655        assert!(step.flags[0].stanza_hint().contains("checkout is implicit"));
656    }
657
658    #[test]
659    fn tier3_upload_artifact_proposes_content_addressed_output() {
660        let r = xf("on: push\njobs:\n  a:\n    runs-on: x\n    steps:\n      - uses: actions/upload-artifact@v4\n");
661        assert_eq!(
662            r.steps[0].flags,
663            vec![FlagKind::ReplaceWithNative(NativeReplacement::UploadArtifact)]
664        );
665        assert!(r.steps[0].flags[0].stanza_hint().contains("content-addressed output"));
666    }
667
668    #[test]
669    fn compute_uses_is_a_toolkit_action_flag() {
670        let r = xf("on: push\njobs:\n  a:\n    runs-on: x\n    steps:\n      - uses: actions/setup-node@v4\n");
671        let step = &r.steps[0];
672        assert!(step.native.is_none(), "no subprocess until the T7 executor");
673        assert_eq!(
674            step.flags,
675            vec![FlagKind::ToolkitAction {
676                slug: "actions/setup-node".into(),
677                git_ref: Some("v4".into()),
678            }]
679        );
680        assert_eq!(step.flags[0].severity(), FlagSeverity::Info);
681    }
682
683    #[test]
684    fn unknown_uses_is_flagged_for_review() {
685        let r = xf("on: push\njobs:\n  a:\n    runs-on: x\n    steps:\n      - uses: some-org/exotic@v1\n");
686        let step = &r.steps[0];
687        assert!(step.native.is_none());
688        assert_eq!(step.flags, vec![FlagKind::Unknown { slug: "some-org/exotic".into() }]);
689        assert_eq!(step.flags[0].severity(), FlagSeverity::Review);
690    }
691
692    #[test]
693    fn run_step_with_gh_cli_runs_but_is_flagged() {
694        let r = xf("on: push\njobs:\n  a:\n    runs-on: x\n    steps:\n      - run: gh release create v1 ./dist/*\n");
695        let step = &r.steps[0];
696        assert!(step.native.is_some(), "still runs on the executor");
697        assert_eq!(
698            step.flags,
699            vec![FlagKind::EmbeddedServiceTouch(vec![ServiceTouch::GhCli])]
700        );
701        assert_eq!(step.flags[0].severity(), FlagSeverity::Review);
702    }
703
704    #[test]
705    fn run_step_with_expression_is_flagged_unresolved() {
706        let r = xf("on: push\njobs:\n  a:\n    runs-on: x\n    steps:\n      - run: echo ${{ github.sha }}\n");
707        let step = &r.steps[0];
708        assert!(step.native.is_some());
709        // The rendered script preserves the GHA expression verbatim.
710        assert!(step.native.as_ref().unwrap().argv[2].contains("${{ github.sha }}"));
711        assert!(step.flags.contains(&FlagKind::UnresolvedExpression));
712    }
713
714    #[test]
715    fn jobs_flatten_in_topological_order() {
716        let src = r#"
717on: push
718jobs:
719  publish:
720    needs: build
721    runs-on: x
722    steps:
723      - run: echo publish
724  build:
725    runs-on: x
726    steps:
727      - run: echo build
728"#;
729        let r = xf(src);
730        // `build` (no needs) must precede `publish` (needs: build) even though
731        // it is declared second.
732        let jobs: Vec<&str> = r.steps.iter().map(|s| s.job.as_str()).collect();
733        assert_eq!(jobs, vec!["build", "publish"]);
734    }
735
736    #[test]
737    fn shell_variants_select_the_right_interpreter() {
738        let r = xf("on: push\njobs:\n  a:\n    runs-on: x\n    steps:\n      - run: print(1)\n        shell: python\n");
739        let step = native_at(&r, "a", 0);
740        assert_eq!(step.argv[0], "python3");
741        assert_eq!(step.argv[1], "-c");
742        assert_eq!(step.argv[2], "print(1)");
743    }
744
745    #[test]
746    fn report_accessors_partition_native_and_flagged() {
747        let src = r#"
748on: push
749jobs:
750  a:
751    runs-on: x
752    steps:
753      - run: cargo test
754      - uses: actions/checkout@v4
755"#;
756        let r = xf(src);
757        assert_eq!(r.collect_native().len(), 1, "only the run step is native");
758        assert_eq!(r.flags().count(), 1, "only checkout flags");
759        assert!(!r.is_clean(), "a tier-3 step is present");
760    }
761
762    #[test]
763    fn unnamed_run_step_gets_positional_name() {
764        let r = xf("on: push\njobs:\n  b:\n    runs-on: x\n    steps:\n      - run: make\n");
765        assert_eq!(native_at(&r, "b", 0).name, "b: step 0");
766    }
767
768    // ── R533-F9: import-time target lifting ───────────────────────────────
769
770    #[test]
771    fn concrete_target_lifts_into_platform_no_matrix() {
772        let r = xf("on: push\njobs:\n  a:\n    runs-on: x\n    steps:\n      - run: cargo build --target x86_64-unknown-linux-musl --release\n");
773        let step = native_at(&r, "a", 0);
774        let p = step.platform.as_ref().expect("platform lifted");
775        assert_eq!(p.target.as_deref(), Some("x86_64-unknown-linux-musl"));
776        assert!(step.matrix.is_none(), "a concrete target needs no matrix");
777    }
778
779    #[test]
780    fn target_equals_form_is_recognized() {
781        let r = xf("on: push\njobs:\n  a:\n    runs-on: x\n    steps:\n      - run: cross build --target=aarch64-unknown-linux-gnu\n");
782        let p = native_at(&r, "a", 0).platform.as_ref().expect("platform");
783        assert_eq!(p.target.as_deref(), Some("aarch64-unknown-linux-gnu"));
784    }
785
786    #[test]
787    fn target_dir_is_not_mistaken_for_target() {
788        let r = xf("on: push\njobs:\n  a:\n    runs-on: x\n    steps:\n      - run: cargo build --target-dir /tmp/out\n");
789        assert!(native_at(&r, "a", 0).platform.is_none(), "--target-dir is not --target");
790    }
791
792    #[test]
793    fn step_without_target_has_no_platform() {
794        let r = xf(RUN_JOB);
795        assert!(native_at(&r, "build", 0).platform.is_none());
796    }
797
798    #[test]
799    fn matrix_target_dimension_lifts_and_carries_step_matrix() {
800        let src = r#"
801on: push
802jobs:
803  build:
804    runs-on: x
805    strategy:
806      matrix:
807        target:
808          - x86_64-unknown-linux-musl
809          - aarch64-unknown-linux-musl
810    steps:
811      - run: cross build --target ${{ matrix.target }}
812"#;
813        let r = xf(src);
814        let step = native_at(&r, "build", 0);
815        // platform.target holds the (QED-native) matrix reference …
816        assert_eq!(
817            step.platform.as_ref().unwrap().target.as_deref(),
818            Some("${{ matrix.target }}")
819        );
820        // … and the target dimension rides along as a step matrix so QED fans it.
821        let m = step.matrix.as_ref().expect("step matrix carried");
822        let vals = m.dimensions.get("target").expect("target dimension");
823        assert_eq!(vals.len(), 2);
824        // The matrix.target reference is *resolved* natively → no unresolved flag.
825        assert!(flags_at(&r, "build", 0).is_empty());
826    }
827
828    #[test]
829    fn matrix_target_from_include_rows() {
830        // Release-shaped include-only matrix: targets live on include rows.
831        let src = r#"
832on: push
833jobs:
834  cli:
835    runs-on: x
836    strategy:
837      matrix:
838        include:
839          - target: x86_64-apple-darwin
840          - target: aarch64-apple-darwin
841    steps:
842      - run: cargo build --target ${{ matrix.target }}
843"#;
844        let r = xf(src);
845        let m = native_at(&r, "cli", 0).matrix.as_ref().expect("matrix from include rows");
846        let vals = m.dimensions.get("target").expect("target dimension");
847        assert_eq!(vals.len(), 2);
848    }
849
850    // (Native step-matrix expansion concretizing platform.target end-to-end is
851    // covered by matrix::tests::step_matrix_substitutes_lifted_platform_target.)
852
853    /// Locate yah's live `release.yml` by ascending to the `.github/workflows`
854    /// marker. Absent in the standalone export mirror → the fixture test skips.
855    fn release_yml() -> Option<String> {
856        let mut dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
857        loop {
858            let cand = dir.join(".github/workflows/release.yml");
859            if cand.is_file() {
860                return std::fs::read_to_string(cand).ok();
861            }
862            if !dir.pop() {
863                return None;
864            }
865        }
866    }
867
868    #[test]
869    fn release_yml_transforms_end_to_end() {
870        let Some(src) = release_yml() else {
871            eprintln!("skip: yah workflow fixtures not present");
872            return;
873        };
874        let r = xf(&src);
875        assert!(!r.steps.is_empty(), "release.yml has steps");
876
877        // checkout → flagged tier-3, never a native step.
878        let checkout = r
879            .steps
880            .iter()
881            .find(|s| s.job == "smoke" && s.step_index == 0)
882            .expect("smoke step 0");
883        assert!(checkout.native.is_none());
884        assert!(checkout
885            .flags
886            .contains(&FlagKind::ReplaceWithNative(NativeReplacement::Checkout)));
887
888        // The image jobs' build-push → registry-publish replacement.
889        assert!(r.flags().any(|(_, f)| matches!(
890            f,
891            FlagKind::ReplaceWithNative(NativeReplacement::RegistryPublish)
892        )));
893
894        // The cargo build `run:` steps map to native bash subprocesses.
895        assert!(
896            r.native_steps().any(|s| s.argv.first().map(String::as_str) == Some("bash")
897                && s.argv.last().is_some_and(|c| c.contains("cargo"))),
898            "at least one native cargo build step",
899        );
900
901        // Topo linearization: cli-build precedes smoke, smoke precedes publish.
902        let first_idx = |job: &str| r.steps.iter().position(|s| s.job == job);
903        let (build, smoke, publish) =
904            (first_idx("cli-build"), first_idx("smoke"), first_idx("publish-cli"));
905        if let (Some(b), Some(s), Some(p)) = (build, smoke, publish) {
906            assert!(b < s, "cli-build before smoke");
907            assert!(s < p, "smoke before publish-cli");
908        }
909    }
910}