feat(scoped): refactor compiler, add nested includes, document flatten

Refactor compiler.go for better maintainability:
- Extract isScopedMode() helper function
- Split getVariables() into getScopedVariables() and getLegacyVariables()
- Fix directory resolution: parent chain env/vars now resolve from their
  own directory instead of the current task's directory

Add nested includes support and tests:
- Add testdata/scoped_taskfiles/inc_a/nested/Taskfile.yml (3 levels deep)
- Add test case for nested include inheritance (root → a → nested)
- Verify nested includes inherit vars from full parent chain

Fix flaky tests:
- Remove VAR from print tasks (defined in both inc_a and inc_b)
- Test only unique variables (UNIQUE_A, UNIQUE_B, ROOT_VAR)

Document flatten: true escape hatch:
- Add migration guide step for using flatten: true
- Add new section explaining flatten bypasses scoping
- Include example and usage recommendations
This commit is contained in:
Valentin Maerten
2026-01-14 19:34:53 +01:00
parent a57a16efca
commit 2810c267dd
11 changed files with 336 additions and 116 deletions

View File

@@ -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 {

View File

@@ -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"),
)
})
}

View File

@@ -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}}"

View File

@@ -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}}"

View File

@@ -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}}"

View File

@@ -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

View File

@@ -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

View File

@@ -1,3 +1,2 @@
A:VAR=value_from_a
A:UNIQUE_A=only_in_a
A:ROOT_VAR=from_root

View File

@@ -1,3 +1,2 @@
B:VAR=value_from_b
B:UNIQUE_B=only_in_b
B:ROOT_VAR=from_root

View File

@@ -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

View File

@@ -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.
:::