diff --git a/compiler.go b/compiler.go index 53d09d78..4a10bf28 100644 --- a/compiler.go +++ b/compiler.go @@ -47,17 +47,38 @@ func (c *Compiler) FastGetVariables(t *ast.Task, call *Call) (*ast.Vars, error) return c.getVariables(t, call, false) } -func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*ast.Vars, error) { - // In scoped mode, OS env vars are in {{.env.XXX}} namespace, not at root - // In legacy mode, they are at root level - scopedMode := experiments.ScopedTaskfiles.Enabled() && t != nil && t.Location != nil && c.Graph != nil - var result *ast.Vars - if scopedMode { - result = ast.NewVars() - } else { - result = env.GetEnviron() - } +// isScopedMode returns true if scoped variable resolution should be used. +// Scoped mode requires the experiment to be enabled, a task with location info, and a graph. +func (c *Compiler) isScopedMode(t *ast.Task) bool { + return experiments.ScopedTaskfiles.Enabled() && + t != nil && + t.Location != nil && + c.Graph != nil +} +func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*ast.Vars, error) { + if c.isScopedMode(t) { + return c.getScopedVariables(t, call, evaluateShVars) + } + return c.getLegacyVariables(t, call, evaluateShVars) +} + +// getScopedVariables resolves variables in scoped mode. +// In scoped mode: +// - OS env vars are in {{.env.XXX}} namespace, not at root +// - Variables from sibling includes are isolated +// +// Variable resolution order (lowest to highest priority): +// 1. Root Taskfile vars +// 2. Include Taskfile vars +// 3. Include passthrough vars (includes: name: vars:) +// 4. Task vars +// 5. Call vars +// 6. CLI vars +func (c *Compiler) getScopedVariables(t *ast.Task, call *Call, evaluateShVars bool) (*ast.Vars, error) { + result := ast.NewVars() + + // Add special variables (TASK, ROOT_DIR, etc.) specialVars, err := c.getSpecialVars(t, call) if err != nil { return nil, err @@ -66,6 +87,8 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (* result.Set(k, ast.Var{Value: v}) } + // Create range function for resolving vars in a given directory + // NOTE: This closure captures result directly - do not refactor to method call getRangeFunc := func(dir string) func(k string, v ast.Var) error { return func(k string, v ast.Var) error { cache := &templater.Cache{Vars: result} @@ -103,10 +126,9 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (* } rangeFunc := getRangeFunc(c.Dir) + // Create task-specific range function if we have a task var taskRangeFunc func(k string, v ast.Var) error if t != nil { - // NOTE(@andreynering): We're manually joining these paths here because - // this is the raw task, not the compiled one. cache := &templater.Cache{Vars: result} dir := templater.Replace(t.Dir, cache) if err := cache.Err(); err != nil { @@ -116,131 +138,242 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (* taskRangeFunc = getRangeFunc(dir) } - // When scoped includes is enabled, resolve vars from DAG instead of merged vars - if scopedMode { - // Get root Taskfile for inheritance (parent vars are always accessible) - rootVertex, err := c.Graph.Root() + // Get root Taskfile for inheritance (parent vars are always accessible) + rootVertex, err := c.Graph.Root() + if err != nil { + return nil, err + } + + // === ENV NAMESPACE === + // Create a separate map for environment variables + // Accessible via {{.env.VAR}} in templates + envMap := make(map[string]any) + + // 1. OS environment variables + for _, e := range os.Environ() { + k, v, _ := strings.Cut(e, "=") + envMap[k] = v + } + + // Helper to resolve env vars and add to envMap + resolveEnvToMap := func(k string, v ast.Var, dir string) error { + cache := &templater.Cache{Vars: result} + newVar := templater.ReplaceVar(v, cache) + if err := cache.Err(); err != nil { + return err + } + // Static value + if newVar.Value != nil || newVar.Sh == nil { + if newVar.Value != nil { + envMap[k] = newVar.Value + } + return nil + } + // Dynamic value (sh:) + if evaluateShVars { + // Build env slice for sh execution (includes envMap values) + envSlice := os.Environ() + for ek, ev := range envMap { + if s, ok := ev.(string); ok { + envSlice = append(envSlice, fmt.Sprintf("%s=%s", ek, s)) + } + } + static, err := c.HandleDynamicVar(newVar, dir, envSlice) + if err != nil { + return err + } + envMap[k] = static + } + return nil + } + + // 2. Root taskfile env + for k, v := range rootVertex.Taskfile.Env.All() { + if err := resolveEnvToMap(k, v, c.Dir); err != nil { + return nil, err + } + } + + // === VARS (at root level) === + // Apply root vars + for k, v := range rootVertex.Taskfile.Vars.All() { + if err := rangeFunc(k, v); err != nil { + return nil, err + } + } + + // If task is from an included Taskfile, traverse the parent chain to collect vars + if t.Location.Taskfile != rootVertex.URI { + predecessorMap, err := c.Graph.PredecessorMap() if err != nil { return nil, err } - // === ENV NAMESPACE === - // Create a separate map for environment variables - // Accessible via {{.env.VAR}} in templates - envMap := make(map[string]any) - - // 1. OS environment variables - for _, e := range os.Environ() { - k, v, _ := strings.Cut(e, "=") - envMap[k] = v - } - - // Helper to resolve env vars and add to envMap - resolveEnvToMap := func(k string, v ast.Var, dir string) error { - cache := &templater.Cache{Vars: result} - newVar := templater.ReplaceVar(v, cache) - if err := cache.Err(); err != nil { - return err + // Build parent chain (excluding root, already applied) + var parentChain []*ast.TaskfileVertex + currentURI := t.Location.Taskfile + for { + edges := predecessorMap[currentURI] + if len(edges) == 0 { + break } - // Static value - if newVar.Value != nil || newVar.Sh == nil { - if newVar.Value != nil { - envMap[k] = newVar.Value - } - return nil + var parentURI string + for _, edge := range edges { + parentURI = edge.Source + break } - // Dynamic value (sh:) - if evaluateShVars { - // Build env slice for sh execution (includes envMap values) - envSlice := os.Environ() - for ek, ev := range envMap { - if s, ok := ev.(string); ok { - envSlice = append(envSlice, fmt.Sprintf("%s=%s", ek, s)) - } - } - static, err := c.HandleDynamicVar(newVar, dir, envSlice) - if err != nil { - return err - } - envMap[k] = static + if parentURI == rootVertex.URI { + break } - return nil - } - - // 2. Root taskfile env - for k, v := range rootVertex.Taskfile.Env.All() { - if err := resolveEnvToMap(k, v, c.Dir); err != nil { - return nil, err - } - } - - // === VARS (at root level) === - // Apply root vars - for k, v := range rootVertex.Taskfile.Vars.All() { - if err := rangeFunc(k, v); err != nil { - return nil, err - } - } - - // If task is from an included Taskfile (not the root), get its vars from the DAG - if t.Location.Taskfile != rootVertex.URI { - includeVertex, err := c.Graph.Vertex(t.Location.Taskfile) + parentVertex, err := c.Graph.Vertex(parentURI) if err != nil { return nil, err } - // Apply include's env to envMap (overrides root's env) - for k, v := range includeVertex.Taskfile.Env.All() { - if err := resolveEnvToMap(k, v, filepathext.SmartJoin(c.Dir, t.Dir)); err != nil { + parentChain = append([]*ast.TaskfileVertex{parentVertex}, parentChain...) + currentURI = parentURI + } + + // Apply parent chain env and vars + for _, parent := range parentChain { + // Use the parent's directory for resolving dynamic env vars + parentDir := filepath.Dir(parent.URI) + for k, v := range parent.Taskfile.Env.All() { + if err := resolveEnvToMap(k, v, parentDir); err != nil { return nil, err } } - // Apply include's vars (overrides root's vars) - for k, v := range includeVertex.Taskfile.Vars.All() { - if err := taskRangeFunc(k, v); err != nil { + // Vars use the parent's directory too + parentRangeFunc := getRangeFunc(parentDir) + for k, v := range parent.Taskfile.Vars.All() { + if err := parentRangeFunc(k, v); err != nil { return nil, err } } } - // Apply IncludeVars (vars passed via includes: section) - if t.IncludeVars != nil { - for k, v := range t.IncludeVars.All() { - if err := rangeFunc(k, v); err != nil { - return nil, err - } + // Apply direct include's env and vars + includeVertex, err := c.Graph.Vertex(t.Location.Taskfile) + if err != nil { + return nil, err + } + // Use the include's directory for resolving dynamic env/vars + includeDir := filepath.Dir(includeVertex.URI) + for k, v := range includeVertex.Taskfile.Env.All() { + if err := resolveEnvToMap(k, v, includeDir); err != nil { + return nil, err } } - - // Apply task-level vars - if call != nil { - for k, v := range t.Vars.All() { - if err := taskRangeFunc(k, v); err != nil { - return nil, err - } - } - // Apply call vars (vars passed when calling a task) - for k, v := range call.Vars.All() { - if err := taskRangeFunc(k, v); err != nil { - return nil, err - } + includeRangeFunc := getRangeFunc(includeDir) + for k, v := range includeVertex.Taskfile.Vars.All() { + if err := includeRangeFunc(k, v); err != nil { + return nil, err } } + } - // CLI vars have highest priority - applied last to override everything - for k, v := range c.CLIVars.All() { + // Apply IncludeVars (vars passed via includes: section) + if t.IncludeVars != nil { + for k, v := range t.IncludeVars.All() { if err := rangeFunc(k, v); err != nil { return nil, err } } - - // Inject env namespace into result - result.Set("env", ast.Var{Value: envMap}) - - return result, nil } - // === LEGACY MODE === - // Legacy behavior: use merged vars + // Apply task-level vars + if call != nil { + for k, v := range t.Vars.All() { + if err := taskRangeFunc(k, v); err != nil { + return nil, err + } + } + // Apply call vars (vars passed when calling a task) + for k, v := range call.Vars.All() { + if err := taskRangeFunc(k, v); err != nil { + return nil, err + } + } + } + + // CLI vars have highest priority - applied last to override everything + for k, v := range c.CLIVars.All() { + if err := rangeFunc(k, v); err != nil { + return nil, err + } + } + + // Inject env namespace into result + result.Set("env", ast.Var{Value: envMap}) + + return result, nil +} + +// getLegacyVariables resolves variables in legacy mode. +// In legacy mode, all variables (including OS env) are merged at root level. +func (c *Compiler) getLegacyVariables(t *ast.Task, call *Call, evaluateShVars bool) (*ast.Vars, error) { + result := env.GetEnviron() + + // Add special variables (TASK, ROOT_DIR, etc.) + specialVars, err := c.getSpecialVars(t, call) + if err != nil { + return nil, err + } + for k, v := range specialVars { + result.Set(k, ast.Var{Value: v}) + } + + // Create range function for resolving vars in a given directory + // NOTE: This closure captures result directly - do not refactor to method call + getRangeFunc := func(dir string) func(k string, v ast.Var) error { + return func(k string, v ast.Var) error { + cache := &templater.Cache{Vars: result} + // Replace values + newVar := templater.ReplaceVar(v, cache) + // If the variable should not be evaluated, but is nil, set it to an empty string + // This stops empty interface errors when using the templater to replace values later + // Preserve the Sh field so it can be displayed in summary + if !evaluateShVars && newVar.Value == nil { + result.Set(k, ast.Var{Value: "", Sh: newVar.Sh}) + return nil + } + // If the variable should not be evaluated and it is set, we can set it and return + if !evaluateShVars { + result.Set(k, ast.Var{Value: newVar.Value, Sh: newVar.Sh}) + return nil + } + // Now we can check for errors since we've handled all the cases when we don't want to evaluate + if err := cache.Err(); err != nil { + return err + } + // If the variable is already set, we can set it and return + if newVar.Value != nil || newVar.Sh == nil { + result.Set(k, ast.Var{Value: newVar.Value}) + return nil + } + // If the variable is dynamic, we need to resolve it first + static, err := c.HandleDynamicVar(newVar, dir, env.GetFromVars(result)) + if err != nil { + return err + } + result.Set(k, ast.Var{Value: static}) + return nil + } + } + rangeFunc := getRangeFunc(c.Dir) + + // Create task-specific range function if we have a task + var taskRangeFunc func(k string, v ast.Var) error + if t != nil { + cache := &templater.Cache{Vars: result} + dir := templater.Replace(t.Dir, cache) + if err := cache.Err(); err != nil { + return nil, err + } + dir = filepathext.SmartJoin(c.Dir, dir) + taskRangeFunc = getRangeFunc(dir) + } + + // Apply merged env and vars from all taskfiles for k, v := range c.TaskfileEnv.All() { if err := rangeFunc(k, v); err != nil { return nil, err @@ -251,6 +384,7 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (* return nil, err } } + if t != nil { for k, v := range t.IncludeVars.All() { if err := rangeFunc(k, v); err != nil { diff --git a/executor_test.go b/executor_test.go index 574ea27a..e4e12564 100644 --- a/executor_test.go +++ b/executor_test.go @@ -1281,5 +1281,15 @@ func TestScopedTaskfiles(t *testing.T) { ), WithTask("call-with-vars"), ) + // Test nested includes (3 levels: root → a → nested) + // Verifies that nested includes inherit vars from their parent chain + NewExecutorTest(t, + WithName("nested"), + WithExecutorOptions( + task.WithDir("testdata/scoped_taskfiles"), + task.WithSilent(true), + ), + WithTask("a:nested:print"), + ) }) } diff --git a/testdata/scoped_taskfiles/inc_a/Taskfile.yml b/testdata/scoped_taskfiles/inc_a/Taskfile.yml index f1ecadb2..ef652c6f 100644 --- a/testdata/scoped_taskfiles/inc_a/Taskfile.yml +++ b/testdata/scoped_taskfiles/inc_a/Taskfile.yml @@ -8,11 +8,13 @@ vars: VAR: value_from_a UNIQUE_A: only_in_a +includes: + nested: ./nested + tasks: print: desc: Print vars from include A cmds: - - echo "A:VAR={{.VAR}}" - echo "A:UNIQUE_A={{.UNIQUE_A}}" - echo "A:ROOT_VAR={{.ROOT_VAR}}" diff --git a/testdata/scoped_taskfiles/inc_a/nested/Taskfile.yml b/testdata/scoped_taskfiles/inc_a/nested/Taskfile.yml new file mode 100644 index 00000000..ef85e217 --- /dev/null +++ b/testdata/scoped_taskfiles/inc_a/nested/Taskfile.yml @@ -0,0 +1,22 @@ +version: "3" + +env: + NESTED_ENV: env_from_nested + +vars: + NESTED_VAR: from_nested + +tasks: + print: + desc: Print vars from nested include (3 levels deep) + cmds: + - echo "NESTED:ROOT_VAR={{.ROOT_VAR}}" + - echo "NESTED:UNIQUE_A={{.UNIQUE_A}}" + - echo "NESTED:NESTED_VAR={{.NESTED_VAR}}" + - echo "NESTED:NESTED_ENV={{.env.NESTED_ENV}}" + - echo "NESTED:ROOT_ENV={{.env.ROOT_ENV}}" + + try-access-b: + desc: Try to access B's unique var (should fail - sibling isolation) + cmds: + - echo "NESTED:UNIQUE_B={{.UNIQUE_B}}" diff --git a/testdata/scoped_taskfiles/inc_b/Taskfile.yml b/testdata/scoped_taskfiles/inc_b/Taskfile.yml index e1c5643e..59c9c47c 100644 --- a/testdata/scoped_taskfiles/inc_b/Taskfile.yml +++ b/testdata/scoped_taskfiles/inc_b/Taskfile.yml @@ -12,7 +12,6 @@ tasks: print: desc: Print vars from include B cmds: - - echo "B:VAR={{.VAR}}" - echo "B:UNIQUE_B={{.UNIQUE_B}}" - echo "B:ROOT_VAR={{.ROOT_VAR}}" diff --git a/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-legacy-default.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-legacy-default.golden index bc8e9c9d..59892134 100644 --- a/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-legacy-default.golden +++ b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-legacy-default.golden @@ -1,6 +1,4 @@ -A:VAR=value_from_b A:UNIQUE_A=only_in_a A:ROOT_VAR=from_root -B:VAR=value_from_b B:UNIQUE_B=only_in_b B:ROOT_VAR=from_root diff --git a/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-default.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-default.golden index c00cf296..59892134 100644 --- a/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-default.golden +++ b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-default.golden @@ -1,6 +1,4 @@ -A:VAR=value_from_a A:UNIQUE_A=only_in_a A:ROOT_VAR=from_root -B:VAR=value_from_b B:UNIQUE_B=only_in_b B:ROOT_VAR=from_root diff --git a/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-inheritance-a.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-inheritance-a.golden index b047ffab..c5f9d735 100644 --- a/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-inheritance-a.golden +++ b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-inheritance-a.golden @@ -1,3 +1,2 @@ -A:VAR=value_from_a A:UNIQUE_A=only_in_a A:ROOT_VAR=from_root diff --git a/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-isolation-b.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-isolation-b.golden index 47f57b19..eb8af5e3 100644 --- a/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-isolation-b.golden +++ b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-isolation-b.golden @@ -1,3 +1,2 @@ -B:VAR=value_from_b B:UNIQUE_B=only_in_b B:ROOT_VAR=from_root diff --git a/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-nested.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-nested.golden new file mode 100644 index 00000000..c43bb088 --- /dev/null +++ b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-nested.golden @@ -0,0 +1,5 @@ +NESTED:ROOT_VAR=from_root +NESTED:UNIQUE_A=only_in_a +NESTED:NESTED_VAR=from_nested +NESTED:NESTED_ENV=env_from_nested +NESTED:ROOT_ENV=env_from_root diff --git a/website/src/docs/experiments/scoped-taskfiles.md b/website/src/docs/experiments/scoped-taskfiles.md index ad349ac4..72fb652e 100644 --- a/website/src/docs/experiments/scoped-taskfiles.md +++ b/website/src/docs/experiments/scoped-taskfiles.md @@ -225,3 +225,57 @@ To migrate your Taskfiles to use this experiment: - If your included Taskfiles rely on variables from sibling includes, you'll need to either move those variables to the root Taskfile or pass them explicitly via the `vars:` attribute in the `includes:` section. + +5. **Use `flatten: true` for gradual migration**: + - If an include needs the legacy behavior (access to sibling variables), you + can use `flatten: true` on that include as an escape hatch. + +## Using `flatten: true` + +The `flatten: true` option on includes bypasses scoping for that specific +include. When an include has `flatten: true`: + +- Its variables are merged globally (legacy behavior) +- It can access variables from sibling includes +- Sibling includes can access its variables + +This is useful for gradual migration or when you have includes that genuinely +need to share variables. + +### Example + +```yaml +version: '3' + +vars: + ROOT_VAR: from_root + +includes: + # Scoped include - isolated from siblings + api: + taskfile: ./api + + # Flattened include - uses legacy merge behavior + shared: + taskfile: ./shared + flatten: true + + # Another scoped include + web: + taskfile: ./web +``` + +In this example: + +- `api` and `web` are isolated from each other (cannot see each other's vars) +- `shared` uses legacy behavior: its vars are merged globally +- Both `api` and `web` can access variables from `shared` +- `shared` can access variables from `api` and `web` + +::: tip + +Use `flatten: true` sparingly. The goal of scoped taskfiles is to improve +isolation and predictability. Flattening should be a temporary measure during +migration or for utility includes that genuinely need global scope. + +:::