Skip to main content

lex_vcs/
diff_to_ops.rs

1//! Convert a `DiffReport` (+ import set deltas + old head info)
2//! into a sequence of typed operations.
3//!
4//! NOTE: `lex-cli`'s `compute_diff` (the only producer of `DiffReport`
5//! today) only diffs `Stage::FnDecl` — types are not yet surfaced.
6//! The `RemoveType`, `AddType`, and `ModifyType` branches below are
7//! forward-looking placeholders that will activate when type-decl
8//! diffing lands. The fn-vs-type heuristic uses
9//! `signature.starts_with("type ")` which depends on the renderer
10//! in `lex-cli/src/diff.rs::render_signature` for `TypeDecl` to
11//! produce strings beginning with "type ". When types come online,
12//! consider extending `AddRemove` with a `kind: SymbolKind` field
13//! to make this typed rather than string-prefix-based.
14
15use crate::diff_report::DiffReport;
16use crate::operation::{default_import_alias, EffectSet, OperationKind, SigId, StageId};
17use lex_ast::{sig_id, stage_id, Effect, Stage};
18use std::collections::{BTreeMap, BTreeSet};
19
20/// One import as the diff sees it: the module reference plus the alias
21/// it is bound under. The alias is part of the set key, so re-aliasing
22/// an already-imported module (`as sql` → `as db`) reads as a
23/// remove + add, which is exactly the pair of ops that reproduces it.
24#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
25pub struct ImportRef {
26    pub reference: String,
27    pub alias: String,
28}
29
30pub type ImportMap = BTreeMap<String, BTreeSet<ImportRef>>;
31
32#[derive(Debug, thiserror::Error)]
33pub enum DiffMappingError {
34    #[error("diff mentions removed/modified name `{0}` but no old_sig_id was resolved for it")]
35    MissingOldSigForName(String),
36    #[error("diff mentions added/renamed name `{0}` but new_stages has no matching stage")]
37    MissingNewStageForName(String),
38    #[error("sig `{0}` is in the diff's old_sig_id but not in old_head")]
39    MissingOldHeadForSig(SigId),
40    #[error("stage for `{0}` produces no sig_id (likely an Import that slipped through)")]
41    NoSigIdForStage(String),
42    #[error("stage for `{0}` produces no stage_id (likely an Import that slipped through)")]
43    NoStageIdForStage(String),
44}
45
46#[derive(Debug)]
47pub struct DiffInputs<'a> {
48    /// Current head SigId → StageId map.
49    pub old_head: &'a BTreeMap<SigId, StageId>,
50    /// Effect set per sig at the current head.
51    pub old_effects: &'a BTreeMap<SigId, EffectSet>,
52    /// Per-file imports at the current head.
53    pub old_imports: &'a ImportMap,
54    /// Stages of the new program (post-canonicalize).
55    pub new_stages: &'a [Stage],
56    /// Per-file imports of the new program.
57    pub new_imports: &'a ImportMap,
58    /// AST-diff between old and new sources, by name. Each removed/
59    /// renamed/modified entry carries its own resolved `old_sig_id`
60    /// (see `diff_report`'s doc comments) rather than this module
61    /// re-deriving one from a name-keyed lookup: a bare function name
62    /// is not unique across a package's files (#818 — e.g. two files
63    /// legitimately both declaring a local `validate` helper with
64    /// different signatures), so a `name -> SigId` map here would
65    /// silently collapse distinct functions onto one SigId.
66    pub diff: &'a DiffReport,
67    /// Mangling prefix → package source file (`schema_a1b2` →
68    /// `src/schema.lex`), for a multi-module package publish. A
69    /// declaration's mangled name is `<prefix>.<local>`, so this maps
70    /// each `AddFunction`/`AddType` to its origin file, recorded as the
71    /// op's `in_file` so `export-git` can de-flatten the package (#894).
72    /// Empty for a single-file publish (then `in_file` stays `None` and
73    /// OpIds are unchanged).
74    pub module_prefixes: &'a BTreeMap<String, String>,
75}
76
77pub fn diff_to_ops(inputs: DiffInputs<'_>) -> Result<Vec<OperationKind>, DiffMappingError> {
78    let mut out = Vec::new();
79    let new_by_name: BTreeMap<&str, &Stage> = inputs.new_stages.iter()
80        .filter_map(|s| {
81            let n = match s {
82                Stage::FnDecl(fd) => fd.name.as_str(),
83                Stage::TypeDecl(td) => td.name.as_str(),
84                Stage::Import(_) => return None,
85            };
86            Some((n, s))
87        })
88        .collect();
89
90    // 1. Imports — separate from stage ops; emit first so importer
91    //    state is consistent before any sig ops apply.
92    for (file, modules) in inputs.new_imports {
93        let old = inputs.old_imports.get(file).cloned().unwrap_or_default();
94        for m in modules.difference(&old) {
95            out.push(OperationKind::AddImport {
96                in_file: file.clone(),
97                module: m.reference.clone(),
98                alias: explicit_alias(m),
99            });
100        }
101        for m in old.difference(modules) {
102            out.push(OperationKind::RemoveImport {
103                in_file: file.clone(),
104                module: m.reference.clone(),
105            });
106        }
107    }
108    for (file, old) in inputs.old_imports {
109        if !inputs.new_imports.contains_key(file) {
110            for m in old {
111                out.push(OperationKind::RemoveImport {
112                    in_file: file.clone(),
113                    module: m.reference.clone(),
114                });
115            }
116        }
117    }
118
119    // 2. Removed → RemoveFunction / RemoveType.
120    for r in &inputs.diff.removed {
121        let Some(sig) = r.old_sig_id.as_ref() else {
122            return Err(DiffMappingError::MissingOldSigForName(r.name.clone()));
123        };
124        let Some(last) = inputs.old_head.get(sig) else {
125            return Err(DiffMappingError::MissingOldHeadForSig(sig.clone()));
126        };
127        // Decide fn vs type by looking at the diff signature string:
128        // type signatures start with "type ".
129        if r.signature.starts_with("type ") {
130            out.push(OperationKind::RemoveType {
131                sig_id: sig.clone(),
132                last_stage_id: last.clone(),
133            });
134        } else {
135            out.push(OperationKind::RemoveFunction {
136                sig_id: sig.clone(),
137                last_stage_id: last.clone(),
138            });
139        }
140    }
141
142    // 3. Added → AddFunction / AddType.
143    for a in &inputs.diff.added {
144        let Some(stage) = new_by_name.get(a.name.as_str()) else {
145            return Err(DiffMappingError::MissingNewStageForName(a.name.clone()));
146        };
147        let Some(sig) = sig_id(stage) else {
148            return Err(DiffMappingError::NoSigIdForStage(a.name.clone()));
149        };
150        let Some(stg) = stage_id(stage) else {
151            return Err(DiffMappingError::NoStageIdForStage(a.name.clone()));
152        };
153        let in_file = origin_file(&a.name, inputs.module_prefixes);
154        match stage {
155            Stage::FnDecl(fd) => {
156                let effects = effect_set(&fd.effects);
157                // #247: extract the function's declared `[budget(N)]`
158                // from its effect set so the op log carries the
159                // initial cost without rehydrating the stage at
160                // query time.
161                let budget_cost = crate::operation::budget_from_effects(&effects);
162                out.push(OperationKind::AddFunction {
163                    sig_id: sig, stage_id: stg, effects, budget_cost, in_file,
164                });
165            }
166            Stage::TypeDecl(_) => {
167                out.push(OperationKind::AddType { sig_id: sig, stage_id: stg, in_file });
168            }
169            Stage::Import(_) => unreachable!(),
170        }
171    }
172
173    // 4. Renamed → RenameSymbol.
174    for r in &inputs.diff.renamed {
175        let from_sig = &r.old_sig_id;
176        let Some(stage) = new_by_name.get(r.to.as_str()) else {
177            return Err(DiffMappingError::MissingNewStageForName(r.to.clone()));
178        };
179        let Some(to_sig) = sig_id(stage) else {
180            return Err(DiffMappingError::NoSigIdForStage(r.to.clone()));
181        };
182        let Some(body_id) = stage_id(stage) else {
183            return Err(DiffMappingError::NoStageIdForStage(r.to.clone()));
184        };
185        out.push(OperationKind::RenameSymbol {
186            from: from_sig.clone(),
187            to: to_sig,
188            body_stage_id: body_id,
189        });
190    }
191
192    // 5. Modified → ChangeEffectSig | ModifyBody | ModifyType.
193    for m in &inputs.diff.modified {
194        let sig = &m.old_sig_id;
195        let Some(from_id) = inputs.old_head.get(sig) else {
196            return Err(DiffMappingError::MissingOldHeadForSig(sig.clone()));
197        };
198        let Some(stage) = new_by_name.get(m.name.as_str()) else {
199            return Err(DiffMappingError::MissingNewStageForName(m.name.clone()));
200        };
201        let Some(to_id) = stage_id(stage) else {
202            return Err(DiffMappingError::NoStageIdForStage(m.name.clone()));
203        };
204        let effects_changed =
205            !m.effect_changes.added.is_empty() || !m.effect_changes.removed.is_empty();
206        // #992: record the sig the declaration moves *to* whenever the new
207        // stage hashes to a different one. A SigId covers the effect row, the
208        // input/output types, the signature-level examples and (for a type)
209        // its params — so any of those changing under the same name is a sig
210        // move, and leaving the head bound to the old sig produces an entry no
211        // store can ever satisfy. `None` for a body-only change.
212        let to_sig_id = sig_id(stage).filter(|s| s != sig);
213        match stage {
214            Stage::FnDecl(fd) if effects_changed => {
215                let from_effects = inputs.old_effects.get(sig).cloned().unwrap_or_default();
216                let to_effects = effect_set(&fd.effects);
217                // #247: the budget delta. ChangeEffectSig fires
218                // because the effect set changed, which often
219                // includes the `[budget(N)]` declaration itself.
220                let from_budget = crate::operation::budget_from_effects(&from_effects);
221                let to_budget = crate::operation::budget_from_effects(&to_effects);
222                out.push(OperationKind::ChangeEffectSig {
223                    sig_id: sig.clone(),
224                    from_stage_id: from_id.clone(),
225                    to_stage_id: to_id,
226                    from_effects,
227                    to_effects,
228                    from_budget,
229                    to_budget,
230                    to_sig_id,
231                });
232            }
233            Stage::FnDecl(fd) => {
234                // #247: ModifyBody fires when only the body changed
235                // — effects (including budget) are unchanged. Pull
236                // the budget from the new stage's effect set; the
237                // old effect set in `old_effects[sig]` would yield
238                // the same value.
239                let to_effects = effect_set(&fd.effects);
240                let budget = crate::operation::budget_from_effects(&to_effects);
241                out.push(OperationKind::ModifyBody {
242                    sig_id: sig.clone(),
243                    from_stage_id: from_id.clone(),
244                    to_stage_id: to_id,
245                    from_budget: budget,
246                    to_budget: budget,
247                    to_sig_id,
248                });
249            }
250            Stage::TypeDecl(_) => {
251                out.push(OperationKind::ModifyType {
252                    sig_id: sig.clone(),
253                    from_stage_id: from_id.clone(),
254                    to_stage_id: to_id,
255                    to_sig_id,
256                });
257            }
258            Stage::Import(_) => unreachable!(),
259        }
260    }
261
262    Ok(out)
263}
264
265/// Project a slice of effects into the canonical `EffectSet` (sorted
266/// label strings).
267///
268/// Effect args are preserved via the canonical pretty-print form
269/// (e.g. `fs_read("/tmp")`, `net("wttr.in")`) — see
270/// `compute_diff::effect_label`. This makes `[net]` → `[net("wttr.in")]`
271/// a real `ChangeEffectSig` op (the strings differ), satisfying #207's
272/// third acceptance criterion via #223.
273///
274/// **OpId stability**: bare effects still produce just `"net"` (not
275/// `"net()"` or any other suffix), so every pre-#223 op log retains
276/// its existing OpIds. Only ops *introducing* parameterized effects
277/// see new hashes — and those are by definition new ops.
278fn effect_set(effs: &[Effect]) -> EffectSet {
279    effs.iter().map(crate::compute_diff::effect_label).collect()
280}
281
282/// The package source file a declaration came from, or `None`. A
283/// package-mangled name is `<prefix>.<local>` (e.g.
284/// `schema_a1b2.validate`); `module_prefixes` maps the prefix to its
285/// file. A single-file publish leaves names unmangled and passes an
286/// empty map, so this is `None` and the op's `in_file` is omitted.
287fn origin_file(name: &str, module_prefixes: &BTreeMap<String, String>) -> Option<String> {
288    let prefix = name.split_once('.')?.0;
289    module_prefixes.get(prefix).cloned()
290}
291
292/// The alias to record on an `AddImport`, or `None` when it is just the
293/// module's default alias. Omitting the default keeps the common-case
294/// `AddImport` byte-identical to its pre-alias form, so its `OpId` does
295/// not rotate; only a deliberately-renamed import (`as e`) carries the
296/// alias explicitly.
297fn explicit_alias(m: &ImportRef) -> Option<String> {
298    if m.alias == default_import_alias(&m.reference) {
299        None
300    } else {
301        Some(m.alias.clone())
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::diff_report::{DiffReport, EffectChanges, Modified, Renamed};
309
310    fn dr() -> DiffReport { DiffReport::default() }
311
312    #[test]
313    fn empty_diff_yields_no_ops() {
314        let head: BTreeMap<SigId, StageId> = BTreeMap::new();
315        let eff: BTreeMap<SigId, EffectSet> = BTreeMap::new();
316        let oi: ImportMap = ImportMap::new();
317        let ni: ImportMap = ImportMap::new();
318        let stages: Vec<Stage> = Vec::new();
319        let d = dr();
320        let ops = diff_to_ops(DiffInputs {
321            old_head: &head,
322            old_effects: &eff,
323            old_imports: &oi,
324            new_stages: &stages,
325            new_imports: &ni,
326            diff: &d,
327            module_prefixes: &BTreeMap::new(),
328        }).expect("ok");
329        assert!(ops.is_empty());
330    }
331
332    #[test]
333    fn rename_emits_a_single_rename_op() {
334        // Build a tiny new program with one fn under the new name.
335        let src = "fn parse_int(s :: Str) -> Int { 0 }";
336        let prog = lex_syntax::load_program_from_str(src).unwrap();
337        let stages = lex_ast::canonicalize_program(&prog);
338        let parse_int = stages.iter()
339            .find(|s| matches!(s, Stage::FnDecl(fd) if fd.name == "parse_int"))
340            .cloned().unwrap();
341        let to_sig = sig_id(&parse_int).unwrap();
342        let to_stage = stage_id(&parse_int).unwrap();
343
344        let mut head = BTreeMap::new();
345        head.insert("parse-old-sig".to_string(), to_stage.clone());
346
347        let mut diff = dr();
348        diff.renamed.push(Renamed {
349            from: "parse".into(),
350            to: "parse_int".into(),
351            signature: "fn parse_int(s :: Str) -> Int".into(),
352            old_sig_id: "parse-old-sig".into(),
353        });
354
355        let eff = BTreeMap::new();
356        let oi = ImportMap::new();
357        let ni = ImportMap::new();
358        let ops = diff_to_ops(DiffInputs {
359            old_head: &head,
360            old_effects: &eff,
361            old_imports: &oi,
362            new_stages: &[parse_int],
363            new_imports: &ni,
364            diff: &diff,
365            module_prefixes: &BTreeMap::new(),
366        }).expect("ok");
367        assert_eq!(ops.len(), 1);
368        match &ops[0] {
369            OperationKind::RenameSymbol { from, to, body_stage_id } => {
370                assert_eq!(from, "parse-old-sig");
371                assert_eq!(to, &to_sig);
372                assert_eq!(body_stage_id, &to_stage);
373            }
374            other => panic!("expected RenameSymbol, got {other:?}"),
375        }
376    }
377
378    #[test]
379    fn body_only_modify_emits_modify_body() {
380        let src = "fn fac(n :: Int) -> Int { 1 }";
381        let prog = lex_syntax::load_program_from_str(src).unwrap();
382        let stages = lex_ast::canonicalize_program(&prog);
383        let fac = stages.iter().find(|s| matches!(s, Stage::FnDecl(fd) if fd.name == "fac"))
384            .cloned().unwrap();
385        let sig = sig_id(&fac).unwrap();
386        let new_stg = stage_id(&fac).unwrap();
387
388        let mut head = BTreeMap::new();
389        head.insert(sig.clone(), "old-stage-id".to_string());
390
391        let mut diff = dr();
392        diff.modified.push(Modified {
393            name: "fac".into(),
394            signature_before: "fn fac(n :: Int) -> Int".into(),
395            signature_after:  "fn fac(n :: Int) -> Int".into(),
396            signature_changed: false,
397            effect_changes: EffectChanges::default(),
398            body_patches: Vec::new(),
399            old_sig_id: sig.clone(),
400        });
401
402        let eff = BTreeMap::new();
403        let oi = ImportMap::new();
404        let ni = ImportMap::new();
405        let ops = diff_to_ops(DiffInputs {
406            old_head: &head, old_effects: &eff,
407            old_imports: &oi, new_stages: &[fac], new_imports: &ni, diff: &diff,
408            module_prefixes: &BTreeMap::new(),
409        }).expect("ok");
410        assert_eq!(ops.len(), 1);
411        match &ops[0] {
412            OperationKind::ModifyBody { sig_id: s, from_stage_id, to_stage_id, .. } => {
413                assert_eq!(s, &sig);
414                assert_eq!(from_stage_id, "old-stage-id");
415                assert_eq!(to_stage_id, &new_stg);
416            }
417            other => panic!("expected ModifyBody, got {other:?}"),
418        }
419    }
420
421    #[test]
422    fn import_added_emits_add_import() {
423        let mut new_imports = ImportMap::new();
424        new_imports.insert("main.lex".into(),
425            std::iter::once(ImportRef { reference: "std.io".into(), alias: "io".into() }).collect());
426        let head = BTreeMap::new();
427        let eff = BTreeMap::new();
428        let oi = ImportMap::new();
429        let stages: Vec<Stage> = Vec::new();
430        let diff = dr();
431        let ops = diff_to_ops(DiffInputs {
432            old_head: &head, old_effects: &eff,
433            old_imports: &oi, new_stages: &stages, new_imports: &new_imports, diff: &diff,
434            module_prefixes: &BTreeMap::new(),
435        }).expect("ok");
436        assert_eq!(ops.len(), 1);
437        match &ops[0] {
438            OperationKind::AddImport { in_file, module, alias } => {
439                assert_eq!(in_file, "main.lex");
440                assert_eq!(module, "std.io");
441                // "io" is the default alias of "std.io", so it's omitted.
442                assert_eq!(alias, &None);
443            }
444            other => panic!("expected AddImport, got {other:?}"),
445        }
446    }
447
448    #[test]
449    fn import_with_nondefault_alias_carries_it() {
450        let mut new_imports = ImportMap::new();
451        new_imports.insert("main.lex".into(),
452            std::iter::once(ImportRef { reference: "./error".into(), alias: "e".into() }).collect());
453        let head = BTreeMap::new();
454        let eff = BTreeMap::new();
455        let oi = ImportMap::new();
456        let stages: Vec<Stage> = Vec::new();
457        let diff = dr();
458        let ops = diff_to_ops(DiffInputs {
459            old_head: &head, old_effects: &eff,
460            old_imports: &oi, new_stages: &stages, new_imports: &new_imports, diff: &diff,
461            module_prefixes: &BTreeMap::new(),
462        }).expect("ok");
463        match &ops[0] {
464            OperationKind::AddImport { module, alias, .. } => {
465                assert_eq!(module, "./error");
466                // "e" != default alias "error", so it's carried explicitly.
467                assert_eq!(alias, &Some("e".to_string()));
468            }
469            other => panic!("expected AddImport, got {other:?}"),
470        }
471    }
472
473    #[test]
474    fn missing_old_sig_for_removed_name_errors() {
475        let head: BTreeMap<SigId, StageId> = BTreeMap::new();
476        let eff: BTreeMap<SigId, EffectSet> = BTreeMap::new();
477        let oi = ImportMap::new();
478        let ni = ImportMap::new();
479        let stages: Vec<Stage> = Vec::new();
480        let mut diff = dr();
481        // No `old_sig_id` was resolved for this removal — the caller
482        // (e.g. a hand-assembled report, or a bug in the resolver)
483        // failed to find the old side.
484        diff.removed.push(crate::diff_report::AddRemove {
485            name: "ghost".into(),
486            signature: "fn ghost() -> Int".into(),
487            old_sig_id: None,
488        });
489        let err = diff_to_ops(DiffInputs {
490            old_head: &head, old_effects: &eff,
491            old_imports: &oi, new_stages: &stages, new_imports: &ni, diff: &diff,
492            module_prefixes: &BTreeMap::new(),
493        }).unwrap_err();
494        match err {
495            DiffMappingError::MissingOldSigForName(n) => assert_eq!(n, "ghost"),
496            other => panic!("expected MissingOldSigForName, got {other:?}"),
497        }
498    }
499
500    // ----------------------------- #223 acceptance ---------------------
501
502    /// Bare effects must produce identical strings to pre-#223
503    /// behavior — preserves OpId stability for every existing op log.
504    /// Pre-#223 `effect_set` was `effs.iter().map(|e| e.name.clone())`,
505    /// so the canonical form for `[net]` was `"net"`. Confirm that.
506    #[test]
507    fn bare_effect_set_string_is_unchanged_from_pre_223() {
508        let src = "fn f() -> [net] Int { 0 }";
509        let prog = lex_syntax::load_program_from_str(src).unwrap();
510        let stages = lex_ast::canonicalize_program(&prog);
511        let fd = match &stages[0] {
512            Stage::FnDecl(fd) => fd,
513            other => panic!("{other:?}"),
514        };
515        let set = effect_set(&fd.effects);
516        assert_eq!(set, ["net".to_string()].into_iter().collect::<EffectSet>(),
517            "bare [net] must canonicalize to {{\"net\"}} so existing \
518             op logs keep their OpIds across the #223 change");
519    }
520
521    /// Parameterized effects produce a distinct, parens-quoted string
522    /// — `[net("wttr.in")]` becomes `"net(\"wttr.in\")"`. This is the
523    /// fulcrum that makes `[net]` → `[net("wttr.in")]` a real
524    /// `ChangeEffectSig` op rather than a no-op.
525    #[test]
526    fn parameterized_effect_label_is_distinct_from_bare() {
527        let bare_src = "fn f() -> [net] Int { 0 }";
528        let scoped_src = r#"fn f() -> [net("wttr.in")] Int { 0 }"#;
529        for (src, expected) in [
530            (bare_src, vec!["net"]),
531            (scoped_src, vec!["net(\"wttr.in\")"]),
532        ] {
533            let prog = lex_syntax::load_program_from_str(src).unwrap();
534            let stages = lex_ast::canonicalize_program(&prog);
535            let fd = match &stages[0] {
536                Stage::FnDecl(fd) => fd,
537                other => panic!("{other:?}"),
538            };
539            let want: EffectSet = expected.into_iter().map(String::from).collect();
540            assert_eq!(effect_set(&fd.effects), want);
541        }
542    }
543
544    /// End-to-end: when a function's effect declaration changes from
545    /// `[net]` to `[net("wttr.in")]`, `diff_to_ops` must emit a
546    /// `ChangeEffectSig` op carrying the parameterized form in
547    /// `to_effects`. Pre-#223 this was a no-op (both flattened to
548    /// `{"net"}`), defeating #207's reason to exist.
549    #[test]
550    fn changing_bare_to_parameterized_emits_change_effect_sig() {
551        let bare_src   = "fn weather() -> [net] Str { \"\" }";
552        let scoped_src = r#"fn weather() -> [net("wttr.in")] Str { "" }"#;
553
554        let bare_stage = match &lex_ast::canonicalize_program(
555            &lex_syntax::load_program_from_str(bare_src).unwrap())[0] {
556            Stage::FnDecl(fd) => fd.clone(),
557            _ => unreachable!(),
558        };
559        let scoped_stage = match &lex_ast::canonicalize_program(
560            &lex_syntax::load_program_from_str(scoped_src).unwrap())[0] {
561            Stage::FnDecl(fd) => fd.clone(),
562            _ => unreachable!(),
563        };
564
565        let sig = sig_id(&Stage::FnDecl(bare_stage.clone())).unwrap();
566        let from_stage_id = stage_id(&Stage::FnDecl(bare_stage.clone())).unwrap();
567
568        let mut head = BTreeMap::new();
569        head.insert(sig.clone(), from_stage_id.clone());
570        let mut eff = BTreeMap::new();
571        eff.insert(sig.clone(), effect_set(&bare_stage.effects));
572
573        let mut diff = dr();
574        diff.modified.push(Modified {
575            name: "weather".into(),
576            signature_before: "fn weather() -> [net] Str".into(),
577            signature_after:  "fn weather() -> [net(\"wttr.in\")] Str".into(),
578            signature_changed: true,
579            body_patches: Vec::new(),
580            effect_changes: EffectChanges {
581                before: vec!["net".into()],
582                after: vec!["net(\"wttr.in\")".into()],
583                added: vec!["net(\"wttr.in\")".into()],
584                removed: vec!["net".into()],
585            },
586            old_sig_id: sig.clone(),
587        });
588
589        let oi = ImportMap::new();
590        let ni = ImportMap::new();
591        let new_stage = Stage::FnDecl(scoped_stage);
592        let ops = diff_to_ops(DiffInputs {
593            old_head: &head,
594            old_effects: &eff,
595            old_imports: &oi,
596            new_stages: &[new_stage],
597            new_imports: &ni,
598            diff: &diff,
599            module_prefixes: &BTreeMap::new(),
600        }).expect("diff_to_ops should succeed");
601
602        let change = ops.iter().find(|op| matches!(op, OperationKind::ChangeEffectSig { .. }));
603        let change = change.expect(
604            "expected a ChangeEffectSig op when going [net] → [net(\"wttr.in\")] — \
605             pre-#223 both sides flattened to {\"net\"} and the op was incorrectly \
606             skipped");
607        match change {
608            OperationKind::ChangeEffectSig { from_effects, to_effects, .. } => {
609                let from: Vec<_> = from_effects.iter().cloned().collect();
610                let to:   Vec<_> = to_effects.iter().cloned().collect();
611                assert_eq!(from, vec!["net".to_string()]);
612                assert_eq!(to,   vec!["net(\"wttr.in\")".to_string()]);
613            }
614            _ => unreachable!(),
615        }
616    }
617}