Repository: casey/just (2025-07-24T23:10:28Z)

Issue:
1.42.0 breaks existing justfiles around modules with imports
Given the following justfiles, `just a::foo` and `just b::foo` both print `A`, when `just b::foo` should print `B`.

```just
# justfile
mod a
mod b
```

```just
# a.just
X := 'A'
import 'shared.just'
```

```just
# b.just
X := 'B'
import 'shared.just'
```

```just
# shared.just
foo:
  @echo {{ X }}
```


Discussion:
I create a gist that shows the problem : https://gist.github.com/quotidian-ennui/f8c382a309f2d5073426e0a4434eb535

- v1.41.0 : `just a::task` -> a.just && `just b::task` -> b.just
- v1.42.0 : `just b::task` -> a.just
Are you able to test latest `just` release (1.42.3 as of this writing) or latest `just` git `master`?
@laniakea64

- I tested with 1.42.3; and it is showing the problem behaviour.
- I compiled the master branch using rust 1.88

```
bsh ❯ rustc --version
rustc 1.88.0 (6b00bc388 2025-06-23)
bsh ❯ ./just --version
just 1.42.3
bsh ❯ ./just a::task
a.just
bsh ❯ ./just b::task
a.just
```
Thank you for the report! I hijacked the original issue and rewrote the reproduction to be more minimal.

This is definitely a bug.

Scopes are retrieved by recipe module path, which is set on the recipe when it is first parsed. Since imports are only parsed once and reused, the first time the recipe is imported, its module path is `a`, since it's imported in the `a` module. When it's imported again, the compiler re-uses the recipe with module path `a`, when the module path should in fact be `b`. This causes the compiler to retrieve the scope for `a.just`, when it should get the scope for `b.just`.

Looks like I'm headed towards a fourth point release T_T
Fixed in #2835 and released in [1.42.4](https://github.com/casey/just/releases/tag/1.42.4).



Patch:
diff --git a/src/alias.rs b/src/alias.rs
index 45d38acc1a..fbeaf769b7 100644
--- a/src/alias.rs
+++ b/src/alias.rs
@@ -14,7 +14,7 @@ pub(crate) struct Alias<'src, T = Arc<Recipe<'src>>> {
 
 impl<'src> Alias<'src, Namepath<'src>> {
   pub(crate) fn resolve(self, target: Arc<Recipe<'src>>) -> Alias<'src> {
-    assert!(self.target.last().lexeme() == target.namepath.last().lexeme());
+    assert!(self.target.last().lexeme() == target.name());
 
     Alias {
       attributes: self.attributes,
diff --git a/src/analyzer.rs b/src/analyzer.rs
index 7a63e45186..81d61e51c4 100644
--- a/src/analyzer.rs
+++ b/src/analyzer.rs
@@ -147,6 +147,7 @@ impl<'run, 'src> Analyzer<'run, 'src> {
 
     let recipes = RecipeResolver::resolve_recipes(
       &assignments,
+      &ast.module_path,
       &self.modules,
       &settings,
       deduplicated_recipes,
diff --git a/src/compiler.rs b/src/compiler.rs
index ea0683db8a..14083a001c 100644
--- a/src/compiler.rs
+++ b/src/compiler.rs
@@ -16,8 +16,13 @@ impl Compiler {
     stack.push(Source::root(root));
 
     while let Some(current) = stack.pop() {
+      if paths.contains_key(&current.path) {
+        continue;
+      }
+
       let (relative, src) = loader.load(root, &current.path)?;
       loaded.push(relative.into());
+
       let tokens = Lexer::lex(relative, src)?;
       let mut ast = Parser::parse(
         current.file_depth,
diff --git a/src/justfile.rs b/src/justfile.rs
index bb113d6262..bf4623de84 100644
--- a/src/justfile.rs
+++ b/src/justfile.rs
@@ -303,12 +303,12 @@ impl<'src> Justfile<'src> {
     config: &Config,
     dotenv: &BTreeMap<String, String>,
     is_dependency: bool,
-    ran: &Ran<'src>,
+    ran: &Ran,
     recipe: &Recipe<'src>,
     scopes: &BTreeMap<String, (&Justfile<'src>, &Scope<'src, '_>)>,
     search: &Search,
   ) -> RunResult<'src> {
-    let mutex = ran.mutex(&recipe.namepath, arguments);
+    let mutex = ran.mutex(recipe, arguments);
 
     let mut guard = mutex.lock().unwrap();
 
@@ -323,7 +323,7 @@ impl<'src> Justfile<'src> {
     }
 
     let (module, scope) = scopes
-      .get(&recipe.module_path())
+      .get(recipe.module_path())
       .expect("failed to retrieve scope for module");
 
     let context = ExecutionContext {
@@ -382,7 +382,7 @@ impl<'src> Justfile<'src> {
     dependencies: &[Dependency<'src>],
     dotenv: &BTreeMap<String, String>,
     evaluator: &mut Evaluator<'src, 'run>,
-    ran: &Ran<'src>,
+    ran: &Ran,
     recipe: &Recipe<'src>,
     scopes: &BTreeMap<String, (&Justfile<'src>, &Scope<'src, 'run>)>,
     search: &Search,
diff --git a/src/namepath.rs b/src/namepath.rs
index 66db701782..8860c8e113 100644
--- a/src/namepath.rs
+++ b/src/namepath.rs
@@ -8,13 +8,6 @@ impl<'src> Namepath<'src> {
     Self(self.0.iter().copied().chain(iter::once(name)).collect())
   }
 
-  pub(crate) fn spaced(&self) -> ModulePath {
-    ModulePath {
-      path: self.0.iter().map(|name| name.lexeme().into()).collect(),
-      spaced: true,
-    }
-  }
-
   pub(crate) fn push(&mut self, name: Name<'src>) {
     self.0.push(name);
   }
diff --git a/src/parser.rs b/src/parser.rs
index 07d3a947df..6ddd161c2e 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -1013,9 +1013,7 @@ impl<'run, 'src> Parser<'run, 'src> {
       file_depth: self.file_depth,
       import_offsets: self.import_offsets.clone(),
       name,
-      namepath: self
-        .module_namepath
-        .map_or_else(|| name.into(), |module_namepath| module_namepath.join(name)),
+      namepath: None,
       parameters: positional.into_iter().chain(variadic).collect(),
       priors,
       private,
diff --git a/src/ran.rs b/src/ran.rs
index 8854046bee..c884402f86 100644
--- a/src/ran.rs
+++ b/src/ran.rs
@@ -1,17 +1,15 @@
 use super::*;
 
 #[derive(Default)]
-pub(crate) struct Ran<'src>(
-  Mutex<BTreeMap<Namepath<'src>, BTreeMap<Vec<String>, Arc<Mutex<bool>>>>>,
-);
+pub(crate) struct Ran(Mutex<BTreeMap<String, BTreeMap<Vec<String>, Arc<Mutex<bool>>>>>);
 
-impl<'src> Ran<'src> {
-  pub(crate) fn mutex(&self, recipe: &Namepath<'src>, arguments: &[String]) -> Arc<Mutex<bool>> {
+impl Ran {
+  pub(crate) fn mutex(&self, recipe: &Recipe, arguments: &[String]) -> Arc<Mutex<bool>> {
     self
       .0
       .lock()
       .unwrap()
-      .entry(recipe.clone())
+      .entry(recipe.namepath().into())
       .or_default()
       .entry(arguments.into())
       .or_default()
diff --git a/src/recipe.rs b/src/recipe.rs
index 6b5727b63f..11b8f2edac 100644
--- a/src/recipe.rs
+++ b/src/recipe.rs
@@ -28,7 +28,7 @@ pub(crate) struct Recipe<'src, D = Dependency<'src>> {
   #[serde(skip)]
   pub(crate) import_offsets: Vec<usize>,
   pub(crate) name: Name<'src>,
-  pub(crate) namepath: Namepath<'src>,
+  pub(crate) namepath: Option<String>,
   pub(crate) parameters: Vec<Parameter<'src>>,
   pub(crate) priors: usize,
   pub(crate) private: bool,
@@ -36,6 +36,21 @@ pub(crate) struct Recipe<'src, D = Dependency<'src>> {
   pub(crate) shebang: bool,
 }
 
+impl Recipe<'_> {
+  pub(crate) fn module_path(&self) -> &str {
+    let namepath = self.namepath();
+    &namepath[0..namepath.rfind("::").unwrap_or_default()]
+  }
+
+  pub(crate) fn namepath(&self) -> &str {
+    self.namepath.as_ref().unwrap()
+  }
+
+  pub(crate) fn spaced_namepath(&self) -> String {
+    self.namepath().replace("::", " ")
+  }
+}
+
 impl<'src, D> Recipe<'src, D> {
   pub(crate) fn argument_range(&self) -> RangeInclusive<usize> {
     self.min_arguments()..=self.max_arguments()
@@ -57,12 +72,6 @@ impl<'src, D> Recipe<'src, D> {
     }
   }
 
-  pub(crate) fn module_path(&self) -> String {
-    let mut path = self.namepath.to_string();
-    path.truncate(path.rfind("::").unwrap_or_default());
-    path
-  }
-
   pub(crate) fn name(&self) -> &'src str {
     self.name.lexeme()
   }
diff --git a/src/recipe_resolver.rs b/src/recipe_resolver.rs
index 2e542d01a9..1bc1493a9c 100644
--- a/src/recipe_resolver.rs
+++ b/src/recipe_resolver.rs
@@ -2,6 +2,7 @@ use {super::*, CompileErrorKind::*};
 
 pub(crate) struct RecipeResolver<'src: 'run, 'run> {
   assignments: &'run Table<'src, Assignment<'src>>,
+  module_path: &'run str,
   modules: &'run Table<'src, Justfile<'src>>,
   resolved_recipes: Table<'src, Arc<Recipe<'src>>>,
   unresolved_recipes: Table<'src, UnresolvedRecipe<'src>>,
@@ -10,15 +11,17 @@ pub(crate) struct RecipeResolver<'src: 'run, 'run> {
 impl<'src: 'run, 'run> RecipeResolver<'src, 'run> {
   pub(crate) fn resolve_recipes(
     assignments: &'run Table<'src, Assignment<'src>>,
+    module_path: &'run str,
     modules: &'run Table<'src, Justfile<'src>>,
     settings: &Settings,
     unresolved_recipes: Table<'src, UnresolvedRecipe<'src>>,
   ) -> CompileResult<'src, Table<'src, Arc<Recipe<'src>>>> {
     let mut resolver = Self {
-      resolved_recipes: Table::new(),
-      unresolved_recipes,
       assignments,
+      module_path,
       modules,
+      resolved_recipes: Table::new(),
+      unresolved_recipes,
     };
 
     while let Some(unresolved) = resolver.unresolved_recipes.pop() {
@@ -106,7 +109,7 @@ impl<'src: 'run, 'run> RecipeResolver<'src, 'run> {
 
     stack.pop();
 
-    let resolved = Arc::new(recipe.resolve(dependencies)?);
+    let resolved = Arc::new(recipe.resolve(self.module_path, dependencies)?);
     self.resolved_recipes.insert(Arc::clone(&resolved));
     Ok(resolved)
   }
diff --git a/src/subcommand.rs b/src/subcommand.rs
index a61ab1c533..c2e19deaa2 100644
--- a/src/subcommand.rs
+++ b/src/subcommand.rs
@@ -246,7 +246,7 @@ impl Subcommand {
 
     let stdin = child.stdin.as_mut().unwrap();
     for recipe in recipes {
-      if let Err(io_error) = writeln!(stdin, "{}", recipe.namepath.spaced()) {
+      if let Err(io_error) = writeln!(stdin, "{}", recipe.spaced_namepath()) {
         if io_error.kind() != std::io::ErrorKind::BrokenPipe {
           return Err(Error::ChooserWrite { io_error, chooser });
         }
diff --git a/src/unresolved_recipe.rs b/src/unresolved_recipe.rs
index 55f987efa2..8c8e41eca4 100644
--- a/src/unresolved_recipe.rs
+++ b/src/unresolved_recipe.rs
@@ -5,6 +5,7 @@ pub(crate) type UnresolvedRecipe<'src> = Recipe<'src, UnresolvedDependency<'src>
 impl<'src> UnresolvedRecipe<'src> {
   pub(crate) fn resolve(
     self,
+    module_path: &str,
     resolved: Vec<Arc<Recipe<'src>>>,
   ) -> CompileResult<'src, Recipe<'src>> {
     assert_eq!(
@@ -42,6 +43,14 @@ impl<'src> UnresolvedRecipe<'src> {
       })
       .collect();
 
+    let mut namepath = String::from(module_path);
+
+    if !namepath.is_empty() {
+      namepath.push_str("::");
+    }
+
+    namepath.push_str(self.name.lexeme());
+
     Ok(Recipe {
       attributes: self.attributes,
       body: self.body,
@@ -50,7 +59,7 @@ impl<'src> UnresolvedRecipe<'src> {
       file_depth: self.file_depth,
       import_offsets: self.import_offsets,
       name: self.name,
-      namepath: self.namepath,
+      namepath: Some(namepath),
       parameters: self.parameters,
       priors: self.priors,
       private: self.private,


Test patch:
diff --git a/tests/scope.rs b/tests/scope.rs
index 97c36c07ed..a34bdb4fde 100644
--- a/tests/scope.rs
+++ b/tests/scope.rs
@@ -51,3 +51,20 @@ foo:
     .args(["a", "b", "foo"])
     .run();
 }
+
+#[test]
+fn imported_recipes_run_in_correct_scope() {
+  Test::new()
+    .justfile(
+      "
+        mod a
+        mod b
+      ",
+    )
+    .write("a.just", "X := 'A'\nimport 'shared.just'")
+    .write("b.just", "X := 'B'\nimport 'shared.just'")
+    .write("shared.just", "foo:\n @echo {{ X }}")
+    .args(["a::foo", "b::foo"])
+    .stdout("A\nB\n")
+    .run();
+}