Skip to main content

proef_core/
analyze.rs

1//! Collect-all suite analysis: the LSP's whole-suite recompute in one function.
2//!
3//! Where `front::run` fails fast and emits artifacts, `analyze_suite`
4//! accumulates every diagnostic and emits the relations editors need
5//! (`bindings` for go-to-def/references, `macros` for completion/def targets).
6//! A parse-failed unit reports its own diagnostic and is skipped downstream —
7//! no cascade of bogus follow-on errors.
8
9use std::collections::BTreeMap;
10use std::sync::Arc;
11
12use crate::bind;
13use crate::diag::{Diag, Span};
14use crate::emit;
15use crate::engine::StepKindSpec;
16use crate::feature;
17use crate::lower::{self, LowerCtx};
18use crate::pack::{self, MacroBody, MacroStepKind, PackSet, PackSource};
19use crate::provider::SourceProvider;
20use crate::world::{GlobalStore, World};
21
22/// One prose step bound to a macro — powers go-to-definition and references.
23#[derive(Debug, Clone)]
24pub struct Binding {
25    /// Source name of the feature file the step lives in.
26    pub feature: String,
27    /// Byte span of the step text in the *normalized* feature source.
28    pub step_span: Span,
29    /// The macro this step resolved to.
30    pub macro_name: String,
31}
32
33/// A macro definition — powers completion and is the go-to-definition target.
34#[derive(Debug, Clone)]
35pub struct MacroRef {
36    /// Macro name (globally unique across the loaded packs).
37    pub name: String,
38    /// The `match:` pattern (`None` for `use:`-only macros).
39    pub pattern: Option<String>,
40    /// Declared params, in declaration order.
41    pub params: Vec<String>,
42    /// Source name of the pack the macro is defined in.
43    pub pack: String,
44    /// Byte span of the macro's name key in the *normalized* pack source, when
45    /// locatable. This is the definition anchor go-to-definition jumps to.
46    pub def_span: Option<Span>,
47    /// Byte span of the macro's `match:` line, when locatable — the preferred
48    /// go-to-definition landing anchor (falls back to `def_span`).
49    pub match_span: Option<Span>,
50}
51
52/// One `use:` reference inside a pack → the macro it resolves to. Powers
53/// go-to-definition from a `use:` line to the target macro's definition.
54#[derive(Debug, Clone)]
55pub struct UseRef {
56    /// Source name of the pack the `use:` line lives in.
57    pub pack: String,
58    /// Byte span of the `use:` line in the *normalized* pack source.
59    pub span: Span,
60    /// The macro the reference resolves to (globally unique name).
61    pub target_macro: String,
62}
63
64/// The product of one wholesale recompute: every feature's read from here.
65#[derive(Debug, Default)]
66pub struct SuiteAnalysis {
67    /// source name → its diagnostics (features and packs alike).
68    pub diagnostics: BTreeMap<String, Vec<Diag>>,
69    /// Every prose-step-to-macro binding across the suite.
70    pub bindings: Vec<Binding>,
71    /// Every macro definition across the loaded packs.
72    pub macros: Vec<MacroRef>,
73    /// Every `use:` reference across the loaded packs, resolved to its target.
74    pub use_refs: Vec<UseRef>,
75}
76
77/// Everything `analyze_suite` needs, injected at the IO edge (sans-IO core).
78pub struct AnalyzeCtx<'a> {
79    /// The source of feature and pack bytes (the IO edge lives behind it).
80    pub provider: &'a dyn SourceProvider,
81    /// Registered engine step kinds (drives pack validation and artifact probes).
82    pub kinds: &'a [StepKindSpec],
83    /// Step-kind prefix → engine id, the lowering routing table.
84    pub kind_to_engine: &'a BTreeMap<String, String>,
85    /// Injected environment snapshot (`${env:…}`).
86    pub env: &'a BTreeMap<String, String>,
87    /// Injected `proef.toml` config scope (`${url:…}` / `${vars:…}`), with the
88    /// active `[env.<name>]` already deep-merged in.
89    pub config_vars: &'a BTreeMap<String, String>,
90    /// Injected run identifier (`${run:id}`).
91    pub run_id: &'a str,
92}
93
94impl SuiteAnalysis {
95    fn push_diags(&mut self, name: &str, diags: impl IntoIterator<Item = Diag>) {
96        // Ensure the primary source has a bucket even with no diagnostics, so a
97        // now-clean file still surfaces an empty set that clears stale marks.
98        self.diagnostics.entry(name.to_owned()).or_default();
99        for d in diags {
100            // Prefer the diagnostic's own source name when it carries one, so a
101            // pack error raised while analyzing a feature lands on the pack.
102            let target = d.source_name.clone().unwrap_or_else(|| name.to_owned());
103            self.diagnostics.entry(target).or_default().push(d);
104        }
105    }
106}
107
108/// Recompute the whole suite in one pass: read every pack and feature through
109/// the provider, accumulate every diagnostic per source name, and record the
110/// binding and macro relations editors need. A broken pack contributes its own
111/// diagnostic and is excluded from the loaded set, but does not stop the rest
112/// of the suite from binding; a parse-failed feature is skipped, not fatal.
113pub fn analyze_suite(ctx: &AnalyzeCtx<'_>) -> SuiteAnalysis {
114    let mut out = SuiteAnalysis::default();
115
116    // Packs first: a broken pack contributes its own diagnostic below and is
117    // excluded from the loaded set, but its siblings still load.
118    let mut sources = pack::builtin_sources();
119    let pack_names = ctx.provider.discover_packs().unwrap_or_default();
120    for name in &pack_names {
121        match ctx.provider.read(name) {
122            Ok(text) => sources.push(PackSource {
123                name: name.clone(),
124                text,
125            }),
126            Err(e) => out.push_diags(name, [read_error_diag(name, &e.0)]),
127        }
128    }
129
130    // Collect-all load: a broken pack contributes its diagnostic and is
131    // excluded from the set, but its siblings still load — the editor keeps
132    // binding against the good packs instead of going dark (v0.5.1 fix).
133    let (loaded, pack_diags) = pack::load_collecting(&sources, ctx.kinds);
134    for d in pack_diags {
135        let name = d.source_name.clone().unwrap_or_default();
136        out.push_diags(&name, [d]);
137    }
138    let packs: Arc<PackSet> = Arc::new(loaded);
139
140    // Macro vocabulary for completion / go-to-def targets.
141    for m in packs.macros.values() {
142        out.macros.push(MacroRef {
143            name: m.name.clone(),
144            pattern: m.pattern.clone(),
145            params: m.params.clone(),
146            pack: m.pack.clone(),
147            def_span: m.span,
148            match_span: m.match_span,
149        });
150    }
151
152    out.use_refs = index_use_refs(&packs);
153
154    let world = World::new(GlobalStore::default());
155
156    let feature_names = ctx.provider.discover_features().unwrap_or_default();
157    for name in &feature_names {
158        let text = match ctx.provider.read(name) {
159            Ok(t) => t,
160            Err(e) => {
161                out.push_diags(name, [read_error_diag(name, &e.0)]);
162                continue;
163            }
164        };
165        let file = match feature::parse(name, &text) {
166            Ok(f) => f,
167            Err(errs) => {
168                out.push_diags(name, errs);
169                continue; // parse failed → skip downstream, no cascade
170            }
171        };
172
173        let (bound, bind_diags) = bind::bind_collect(&file, &packs);
174        out.push_diags(name, bind_diags);
175
176        for scenario in &bound {
177            for step in &scenario.steps {
178                out.bindings.push(Binding {
179                    feature: name.clone(),
180                    step_span: step.defn.span,
181                    macro_name: step.macro_name.clone(),
182                });
183            }
184        }
185
186        let ctx_lower = LowerCtx {
187            feature: &file,
188            packs: &packs,
189            kind_to_engine: ctx.kind_to_engine,
190            env: ctx.env,
191            config_vars: ctx.config_vars,
192            run_id: ctx.run_id,
193            world: &world,
194            mode: crate::resolve::ResolveMode::DryRun,
195        };
196        for scenario in &bound {
197            match lower::lower(scenario, &ctx_lower) {
198                Ok(lowered) => {
199                    out.push_diags(name, lowered.warnings.iter().cloned());
200                    // Emit + artifact validation is executed for its diagnostics
201                    // only; the artifact text is discarded.
202                    let stem = feature_stem(name);
203                    if let Some(artifact) = emit::emit(&lowered, &stem, &world) {
204                        let mut diags = Vec::new();
205                        validate_artifact(&artifact, &lowered, ctx.kinds, &mut diags);
206                        out.push_diags(name, diags);
207                    }
208                }
209                Err(errs) => out.push_diags(name, errs),
210            }
211        }
212    }
213
214    out
215}
216
217/// Index every `use:` reference → its resolved target, for go-to-def from a
218/// `use:` line. Each macro's parsed `MacroStepKind::Use` targets pair positionally
219/// with the `use:` line spans `pack::locate::use_line_spans` finds. Because both
220/// counts come from the macro's own steps and source, a mismatch means the line
221/// scanner missed a step it can't see (e.g. a flow-style `- {use: base}`), so the
222/// pairing is unreliable and that macro is skipped entirely rather than risk a
223/// wrong `Some`.
224fn index_use_refs(packs: &PackSet) -> Vec<UseRef> {
225    let mut use_refs = Vec::new();
226    for m in packs.macros.values() {
227        let MacroBody::Steps(steps) = &m.body else {
228            continue;
229        };
230        let targets: Vec<&str> = steps
231            .iter()
232            .filter_map(|step| match &step.kind {
233                MacroStepKind::Use { target, .. } => Some(target.as_str()),
234                MacroStepKind::Payload { .. } => None,
235            })
236            .collect();
237        let spans = crate::pack::locate::use_line_spans(&m.source, &m.name);
238        if spans.len() != targets.len() {
239            continue; // line scan and parsed steps disagree → pairing unreliable, skip
240        }
241        for (span, target) in spans.into_iter().zip(targets) {
242            if let Some(target_macro) = packs.find_use_target(target) {
243                use_refs.push(UseRef {
244                    pack: m.pack.clone(),
245                    span,
246                    target_macro: target_macro.name.clone(),
247                });
248            }
249        }
250    }
251    use_refs
252}
253
254fn feature_stem(name: &str) -> String {
255    std::path::Path::new(name).file_stem().map_or_else(
256        || "feature".to_owned(),
257        |s| s.to_string_lossy().into_owned(),
258    )
259}
260
261fn read_error_diag(name: &str, msg: &str) -> Diag {
262    Diag::error(
263        "proef::source::unreadable",
264        format!("cannot read {name}: {msg}"),
265    )
266    .with_source(name.to_owned(), Arc::from(""))
267}
268
269/// Parse-validate the exact emitted artifact text with the claiming engine's
270/// real parser (`--dry-run` = §4.1–4.5 including artifact parse-validation).
271/// The diagnostic's source is the emitted text itself, span at the broken line.
272///
273/// This is the single implementation of artifact parse-validation, shared by the
274/// CLI's fail-fast `front::run` and the LSP's collect-all `analyze_suite`. It
275/// reaches the hurl parser only through the injected [`StepKindSpec::validate`]
276/// function pointer, so it stays engine-agnostic and lives in the sans-IO core.
277pub fn validate_artifact(
278    artifact: &emit::Artifact,
279    lowered: &lower::LoweredScenario,
280    kinds: &[StepKindSpec],
281    diags: &mut Vec<Diag>,
282) {
283    let Some(kind) = lowered
284        .batches
285        .iter()
286        .flat_map(|b| b.steps.iter())
287        .find(|s| matches!(s.payload, crate::step::StepPayload::HurlEntries(_)))
288        .map(|s| s.kind.as_str().to_owned())
289    else {
290        return;
291    };
292    let Some(validate) = kinds
293        .iter()
294        .find(|k| k.prefix == kind)
295        .and_then(|k| k.validate)
296    else {
297        return;
298    };
299    if let Err(err) = validate(&artifact.hurl_text) {
300        let offset: usize = artifact
301            .hurl_text
302            .split_inclusive('\n')
303            .take(err.line.saturating_sub(1))
304            .map(str::len)
305            .sum();
306        let line_len = artifact.hurl_text[offset..]
307            .lines()
308            .next()
309            .unwrap_or("")
310            .len();
311        diags.push(
312            Diag::error(
313                "proef::emit::invalid_artifact",
314                format!(
315                    "emitted artifact `{}.hurl` does not parse: {} (line {}, column {})",
316                    artifact.slug, err.message, err.line, err.column
317                ),
318            )
319            .with_source(
320                format!("{}.hurl (emitted)", artifact.slug),
321                std::sync::Arc::from(artifact.hurl_text.as_str()),
322            )
323            .with_span(Span::clamped(
324                offset,
325                offset + line_len.max(1),
326                artifact.hurl_text.len(),
327            )),
328        );
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    #![allow(clippy::expect_used)]
335
336    use super::*;
337    use crate::provider::{ProviderError, SourceProvider};
338    use std::collections::BTreeMap;
339    use std::sync::Arc;
340
341    /// A provider backed by an in-memory map — keeps the test sans-IO.
342    struct MemProvider {
343        features: Vec<String>,
344        packs: Vec<String>,
345        files: BTreeMap<String, Arc<str>>,
346    }
347    impl SourceProvider for MemProvider {
348        fn discover_features(&self) -> Result<Vec<String>, ProviderError> {
349            Ok(self.features.clone())
350        }
351        fn discover_packs(&self) -> Result<Vec<String>, ProviderError> {
352            Ok(self.packs.clone())
353        }
354        fn read(&self, name: &str) -> Result<Arc<str>, ProviderError> {
355            self.files
356                .get(name)
357                .cloned()
358                .ok_or_else(|| ProviderError(format!("no source {name}")))
359        }
360    }
361
362    // Pack validation (step-kind claim, pass 8) and lowering's routing invariant
363    // both require `hurl` to be a registered kind: with an empty registry a
364    // `hurl:` step is `unknown_step_kind` and an unrouted lowered step. The
365    // analyzer needs no *live* engine, though — a spec with no `validate` probe
366    // (artifact parse-validation is skipped) plus a `hurl → hurl` route is enough
367    // to bind, lower, and extract spans.
368    const KINDS: &[StepKindSpec] = &[StepKindSpec {
369        prefix: "hurl",
370        schema: "true",
371        validate: None,
372    }];
373
374    fn hurl_kind_map() -> &'static BTreeMap<String, String> {
375        use std::sync::OnceLock;
376        static M: OnceLock<BTreeMap<String, String>> = OnceLock::new();
377        M.get_or_init(|| BTreeMap::from([("hurl".to_owned(), "hurl".to_owned())]))
378    }
379
380    fn ctx_over<'a>(
381        provider: &'a dyn SourceProvider,
382        empty: &'a BTreeMap<String, String>,
383    ) -> AnalyzeCtx<'a> {
384        AnalyzeCtx {
385            provider,
386            kinds: KINDS,
387            kind_to_engine: hurl_kind_map(),
388            env: empty,
389            config_vars: empty,
390            run_id: "lsp",
391        }
392    }
393
394    #[test]
395    fn analyze_surfaces_bindings_and_no_errors_on_a_clean_suite() {
396        let mut files = BTreeMap::new();
397        files.insert(
398            "packs/p.yaml".to_owned(),
399            Arc::from(
400                "macros:\n  greet:\n    params: [who]\n    match: \"I greet {who}\"\n    steps:\n      - hurl: |\n          GET http://x\n",
401            ),
402        );
403        files.insert(
404            "f.feature".to_owned(),
405            Arc::from("Feature: F\n  Scenario: S\n    When I greet Sam\n"),
406        );
407        let provider = MemProvider {
408            features: vec!["f.feature".to_owned()],
409            packs: vec!["packs/p.yaml".to_owned()],
410            files,
411        };
412        let empty = BTreeMap::new();
413        let analysis = analyze_suite(&ctx_over(&provider, &empty));
414
415        let errors: usize = analysis
416            .diagnostics
417            .values()
418            .flatten()
419            .filter(|d| d.severity == crate::diag::Severity::Error)
420            .count();
421        assert_eq!(
422            errors, 0,
423            "clean suite must have zero errors: {:?}",
424            analysis.diagnostics
425        );
426
427        assert!(
428            analysis
429                .bindings
430                .iter()
431                .any(|b| b.macro_name == "greet" && b.feature == "f.feature"),
432            "the greet step must be recorded as a binding"
433        );
434        assert!(
435            analysis
436                .macros
437                .iter()
438                .any(|m| m.name == "greet" && m.pattern.is_some())
439        );
440    }
441
442    #[test]
443    fn analyze_collects_unbound_without_cascade() {
444        let mut files = BTreeMap::new();
445        files.insert("packs/p.yaml".to_owned(), Arc::from("macros: {}\n"));
446        files.insert(
447            "f.feature".to_owned(),
448            Arc::from("Feature: F\n  Scenario: S\n    When nothing matches this\n"),
449        );
450        let provider = MemProvider {
451            features: vec!["f.feature".to_owned()],
452            packs: vec!["packs/p.yaml".to_owned()],
453            files,
454        };
455        let empty = BTreeMap::new();
456        let analysis = analyze_suite(&ctx_over(&provider, &empty));
457        let feature_diags = analysis
458            .diagnostics
459            .get("f.feature")
460            .expect("feature bucket");
461        assert!(
462            feature_diags
463                .iter()
464                .any(|d| d.code == "proef::bind::unbound_step")
465        );
466        let errors: Vec<_> = feature_diags
467            .iter()
468            .filter(|d| d.severity == crate::diag::Severity::Error)
469            .collect();
470        assert_eq!(
471            errors.len(),
472            1,
473            "the unbound step must be the only error-severity diagnostic, no spurious extras: {feature_diags:?}"
474        );
475        assert_eq!(errors[0].code, "proef::bind::unbound_step");
476    }
477
478    // §9's headline robustness property: a parse failure suppresses only its own
479    // file's downstream diagnostics — it must not cascade into a sibling feature's
480    // binding. Two features share one valid pack; only one of them fails to parse.
481    #[test]
482    fn analyze_parse_failed_feature_does_not_cascade_to_sibling() {
483        let mut files = BTreeMap::new();
484        files.insert(
485            "packs/p.yaml".to_owned(),
486            Arc::from(
487                "macros:\n  greet:\n    params: [who]\n    match: \"I greet {who}\"\n    steps:\n      - hurl: |\n          GET http://x\n",
488            ),
489        );
490        // Whitespace-only text: feature::parse's own empty-file guard
491        // (`normalized.trim().is_empty()`) returns `Err` before the gherkin
492        // parser even runs — a guaranteed, deliberate parse failure.
493        files.insert("bad.feature".to_owned(), Arc::from("   \n"));
494        files.insert(
495            "good.feature".to_owned(),
496            Arc::from("Feature: F\n  Scenario: S\n    When I greet Sam\n"),
497        );
498        let provider = MemProvider {
499            features: vec!["bad.feature".to_owned(), "good.feature".to_owned()],
500            packs: vec!["packs/p.yaml".to_owned()],
501            files,
502        };
503        let empty = BTreeMap::new();
504        let analysis = analyze_suite(&ctx_over(&provider, &empty));
505
506        // The parse-failed feature carries its own parse-error diagnostic —
507        // proof the `feature::parse` `Err` branch was actually hit.
508        let bad_diags = analysis
509            .diagnostics
510            .get("bad.feature")
511            .expect("bad.feature bucket");
512        assert!(
513            bad_diags
514                .iter()
515                .any(|d| d.code == "proef::feature::empty_file"),
516            "the parse-failed feature must carry its parse-error diagnostic: {bad_diags:?}"
517        );
518
519        // The sibling feature is untouched: no error diagnostics, and its
520        // binding still made it through — proof there was no cascade.
521        let good_diags = analysis
522            .diagnostics
523            .get("good.feature")
524            .expect("good.feature bucket");
525        assert!(
526            good_diags
527                .iter()
528                .all(|d| d.severity != crate::diag::Severity::Error),
529            "the valid sibling feature must have no error diagnostics despite the parse failure next to it: {good_diags:?}"
530        );
531        assert!(
532            analysis
533                .bindings
534                .iter()
535                .any(|b| b.macro_name == "greet" && b.feature == "good.feature"),
536            "the valid sibling feature must still produce its binding — no cascade from the parse-failed feature"
537        );
538    }
539
540    // A broken pack degrades gracefully: it reports its own error, but the good
541    // pack still loads, so a feature binding against a good-pack macro survives.
542    // One broken pack must never zero the whole suite's analysis (v0.5.1 fix).
543    #[test]
544    fn analyze_degrades_when_one_pack_is_broken() {
545        let mut files = BTreeMap::new();
546        files.insert(
547            "packs/good.yaml".to_owned(),
548            Arc::from(
549                "macros:\n  greet:\n    params: [who]\n    match: \"I greet {who}\"\n    steps:\n      - hurl: |\n          GET http://x\n",
550            ),
551        );
552        // `bogus` is not a recognized root key → deny_unknown_fields → this pack
553        // fails to parse and contributes proef::pack::yaml, but must not sink the rest.
554        files.insert(
555            "packs/broken.yaml".to_owned(),
556            Arc::from("macros: {}\nbogus: true\n"),
557        );
558        files.insert(
559            "f.feature".to_owned(),
560            Arc::from("Feature: F\n  Scenario: S\n    When I greet Sam\n"),
561        );
562        let provider = MemProvider {
563            features: vec!["f.feature".to_owned()],
564            packs: vec!["packs/good.yaml".to_owned(), "packs/broken.yaml".to_owned()],
565            files,
566        };
567        let empty = BTreeMap::new();
568        let analysis = analyze_suite(&ctx_over(&provider, &empty));
569
570        // The broken pack still reports its own diagnostic.
571        let pack_diags = analysis
572            .diagnostics
573            .get("packs/broken.yaml")
574            .expect("broken pack bucket");
575        assert!(
576            pack_diags.iter().any(|d| d.code == "proef::pack::yaml"),
577            "the broken pack must carry its yaml diagnostic: {pack_diags:?}"
578        );
579
580        // The good pack still loaded: its macro is in the vocabulary...
581        assert!(
582            analysis.macros.iter().any(|m| m.name == "greet"),
583            "the good pack's macro must survive the broken sibling"
584        );
585        // ...and the feature bound against it — no cascade, no zeroing.
586        assert!(
587            analysis
588                .bindings
589                .iter()
590                .any(|b| b.macro_name == "greet" && b.feature == "f.feature"),
591            "the feature must still bind to the good-pack macro despite the broken pack"
592        );
593    }
594
595    #[test]
596    fn analyze_records_use_refs_and_match_spans() {
597        let mut files = BTreeMap::new();
598        files.insert(
599            "packs/p.yaml".to_owned(),
600            Arc::from(
601                "macros:\n  base:\n    match: the base\n    steps:\n      - hurl: |\n          GET http://x\n  wrapper:\n    steps:\n      - use: base\n",
602            ),
603        );
604        let provider = MemProvider {
605            features: vec![],
606            packs: vec!["packs/p.yaml".to_owned()],
607            files,
608        };
609        let empty = BTreeMap::new();
610        let analysis = analyze_suite(&ctx_over(&provider, &empty));
611
612        // The `use: base` line is indexed, resolved to `base`.
613        let u = analysis
614            .use_refs
615            .iter()
616            .find(|u| u.target_macro == "base")
617            .expect("use_ref for base");
618        assert_eq!(u.pack, "packs/p.yaml");
619        let src = provider_text(&provider, "packs/p.yaml");
620        assert_eq!(&src[u.span.start..u.span.end], "use: base");
621
622        // `base` carries a match_span; `wrapper` (use-only) does not.
623        let base = analysis
624            .macros
625            .iter()
626            .find(|m| m.name == "base")
627            .expect("macro base");
628        assert!(base.match_span.is_some());
629        let wrapper = analysis
630            .macros
631            .iter()
632            .find(|m| m.name == "wrapper")
633            .expect("macro wrapper");
634        assert!(wrapper.match_span.is_none());
635    }
636
637    // Guards the ordinal alignment between parsed `Use` steps and textual `use:`
638    // lines: a flow-style step (`- {use: base}`) is valid YAML and parses to a
639    // `Use` step, but the line scanner's `use:`-prefix match does not see it (the
640    // line reads `- {use: base}`, not a `use:`-prefixed line after the dash
641    // strip). Mixed with a block-style `use:` line in the same macro, the parsed
642    // count (2) and textual count (1) diverge — per-ordinal pairing would silently
643    // attribute the wrong textual span to a step. `index_use_refs` must skip
644    // `UseRef` generation for that macro entirely rather than emit a wrong `Some`.
645    #[test]
646    fn analyze_skips_use_refs_when_flow_and_block_style_counts_diverge() {
647        let mut files = BTreeMap::new();
648        files.insert(
649            "packs/p.yaml".to_owned(),
650            Arc::from(
651                "macros:\n  base:\n    match: the base\n    steps:\n      - hurl: |\n          GET http://x\n  wrapper:\n    steps:\n      - {use: base}\n      - use: base\n",
652            ),
653        );
654        let provider = MemProvider {
655            features: vec![],
656            packs: vec!["packs/p.yaml".to_owned()],
657            files,
658        };
659        let empty = BTreeMap::new();
660        let analysis = analyze_suite(&ctx_over(&provider, &empty));
661
662        // The pack is valid (flow-style `use:` is legal YAML) — no error
663        // diagnostics, so `analyze_suite` did not short-circuit before indexing.
664        let errors: usize = analysis
665            .diagnostics
666            .values()
667            .flatten()
668            .filter(|d| d.severity == crate::diag::Severity::Error)
669            .count();
670        assert_eq!(
671            errors, 0,
672            "the mixed-style pack must be valid, zero errors: {:?}",
673            analysis.diagnostics
674        );
675
676        // `base` has no `use:` steps of its own, so any `UseRef` in this suite
677        // would have to come from `wrapper` — the count mismatch must suppress
678        // all of them (no wrong-target/wrong-span `UseRef`, per the "never a
679        // wrong `Some`" contract).
680        assert!(
681            analysis.use_refs.is_empty(),
682            "count mismatch must skip UseRef generation for wrapper entirely, \
683             not emit a misaligned pairing: {:?}",
684            analysis.use_refs
685        );
686    }
687
688    // Small helper to read a source back for span assertions.
689    fn provider_text(p: &MemProvider, name: &str) -> String {
690        p.read(name).expect("provider source").to_string()
691    }
692}