diff --git a/crates/perry/src/commands/compile/init_order.rs b/crates/perry/src/commands/compile/init_order.rs index 5c1324ccc8..6844e8ef3d 100644 --- a/crates/perry/src/commands/compile/init_order.rs +++ b/crates/perry/src/commands/compile/init_order.rs @@ -209,12 +209,23 @@ pub(super) fn topo_sort_non_entry_modules( } visiting.insert(path.clone()); - // Visit dependencies first (so they get initialized before us) + // Visit dependencies first (so they get initialized before us). + // + // #6463 follow-up (effect web.ts "Not a valid effect: undefined"): + // visit deps in IMPORT-DECLARATION order, not alphabetically. For a + // DAG the two produce equally valid topological orders, but inside a + // cycle the visit order decides WHICH edge becomes the broken + // back-edge — i.e. which module's body runs first. Node's ESM + // evaluation visits requested modules in declaration order, so an + // alphabetical order here can break a cycle in the OPPOSITE + // direction from node: the module node evaluates first is ordered + // last by perry, its init-call edge is dropped as a back-edge + // (run_pipeline's #6463 filter), and every alias binding read from + // it (`export const x = internal.x`) captures undefined. `deps` is + // built in source order (imports first, then re-export sources), so + // simply not sorting preserves the ESM visit order. if let Some(module_deps) = deps.get(path) { - // Sort deps for deterministic order - let mut sorted_deps = module_deps.clone(); - sorted_deps.sort(); - for dep in &sorted_deps { + for dep in module_deps { dfs_visit(dep, deps, path_to_name, visited, visiting, sorted); } } @@ -226,7 +237,36 @@ pub(super) fn topo_sort_non_entry_modules( } } - // Sort starting nodes for deterministic iteration order + // #6463 follow-up: root the DFS at the ENTRY module's imports, in + // declaration order — the same place node's ESM evaluation starts. The + // previous alphabetical all-paths iteration produced a valid topological + // order for DAGs, but whichever alphabetically-early module first reached + // a cycle decided its break direction, which could invert node's + // evaluation order for that cycle (see the dep-order comment above). + // Any module not reachable from the entry through the collected edges + // (Deferred dynamic-import targets, etc.) is appended afterwards in + // alphabetical order for determinism. + if let Some(entry_module) = ctx.native_modules.get(entry_path) { + for import in &entry_module.imports { + if import.is_dynamic || import.type_only || import.is_deferred_require { + continue; + } + if let Some(ref resolved) = import.resolved_path { + let resolved_path = PathBuf::from(resolved); + if path_to_name.contains_key(&resolved_path) { + dfs_visit( + &resolved_path, + &deps, + &path_to_name, + &mut visited, + &mut visiting, + &mut sorted, + ); + } + } + } + } + let mut all_paths: Vec = path_to_name.keys().cloned().collect(); all_paths.sort(); diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 1c2b2beeca..bde9a85fe5 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -2148,6 +2148,36 @@ pub fn run_with_parse_cache( } } } + // Drop init-call back-edges (#6463). `topo_sort_non_entry_modules` + // breaks import cycles at the back-edge and the eager main + // sequence runs inits in that order — but the wrapper's nested + // dep-init calls re-derive the order dynamically at runtime. + // When the cycle member the sort placed FIRST runs, its broken + // edge to the member placed second pulled that module's BODY in + // early, before this module's own body had populated anything. + // Effect's web.ts died on this: find-my-way-ts + // internal/router.ts has `import * as Router from "../index.js"` + // used only in type positions (no `type` keyword, so it is a + // value edge), forming a cycle index ⇄ internal. The sort + // correctly placed internal first — matching node's ESM + // evaluation order from the entry — but internal's wrapper then + // called index's init, whose body copied + // `export const make = internal.make` while internal's global + // was still undefined. `FindMyWay.make` stayed undefined + // forever: "TypeError: value is not a function" at + // Layer.launch. Keeping only forward edges (dep positioned + // before this module) is exactly ESM's behavior of skipping a + // module already on the evaluation stack. A dep missing from + // the position map keeps its edge (conservative). + let init_pos: std::collections::HashMap = + non_entry_module_names + .iter() + .enumerate() + .map(|(i, name)| (sanitize_name(name), i)) + .collect(); + if let Some(&self_pos) = init_pos.get(&sanitize_name(&hir_module.name)) { + deps.retain(|dep| init_pos.get(dep).map_or(true, |&p| p < self_pos)); + } deps }; // Build import → source-prefix table for cross-module