From 1b418409d168f071082eed6125e511baac91cfe7 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Fri, 26 Dec 2025 20:53:10 +0100 Subject: [PATCH 01/15] feat(experiments): add SCOPED_INCLUDES experiment Add new experiment flag TASK_X_SCOPED_INCLUDES for scoped variable resolution in included Taskfiles. When enabled, variables from included Taskfiles will be isolated rather than merged globally. This is the first step towards implementing lazy DAG-based variable resolution with strict isolation between includes. --- experiments/experiments.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/experiments/experiments.go b/experiments/experiments.go index 3e14d39a03..b3f07baa33 100644 --- a/experiments/experiments.go +++ b/experiments/experiments.go @@ -19,6 +19,7 @@ var ( GentleForce Experiment RemoteTaskfiles Experiment EnvPrecedence Experiment + ScopedIncludes Experiment ) // Inactive experiments. These are experiments that cannot be enabled, but are @@ -43,6 +44,7 @@ func ParseWithConfig(dir string, config *ast.TaskRC) { GentleForce = New("GENTLE_FORCE", config, 1) RemoteTaskfiles = New("REMOTE_TASKFILES", config, 1) EnvPrecedence = New("ENV_PRECEDENCE", config, 1) + ScopedIncludes = New("SCOPED_INCLUDES", config, 1) AnyVariables = New("ANY_VARIABLES", config) MapVariables = New("MAP_VARIABLES", config) } From 9732f7e08bfb0f38019c5b5b511794f74b1b62c3 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Fri, 26 Dec 2025 20:54:33 +0100 Subject: [PATCH 02/15] feat(executor): store TaskfileGraph for lazy resolution Store the TaskfileGraph in the Executor so it can be used for lazy variable resolution when SCOPED_INCLUDES experiment is enabled. The graph is now preserved after reading, before merging into the final Taskfile. This allows traversing the DAG at runtime to resolve variables from the correct scope. --- executor.go | 1 + setup.go | 1 + 2 files changed, 2 insertions(+) diff --git a/executor.go b/executor.go index 43b8bacffe..a6632a30ef 100644 --- a/executor.go +++ b/executor.go @@ -63,6 +63,7 @@ type ( // Internal Taskfile *ast.Taskfile + Graph *ast.TaskfileGraph Logger *logger.Logger Compiler *Compiler Output output.Output diff --git a/setup.go b/setup.go index fbbe64e746..8b3eca1006 100644 --- a/setup.go +++ b/setup.go @@ -104,6 +104,7 @@ func (e *Executor) readTaskfile(node taskfile.Node) error { } return err } + e.Graph = graph if e.Taskfile, err = graph.Merge(); err != nil { return err } From da927ad5fe9b1d910d59b1df79b62cc4e7c5996d Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Fri, 26 Dec 2025 21:00:02 +0100 Subject: [PATCH 03/15] feat(graph): add Root() helper method Add Root() method to TaskfileGraph to get the root vertex (entrypoint Taskfile). This will be used for lazy variable resolution. Note: Tasks already have Location.Taskfile which can be used to find their source Taskfile in the graph, so GetVertexByNamespace is not needed. --- taskfile/ast/graph.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/taskfile/ast/graph.go b/taskfile/ast/graph.go index cb30093d88..b6c2743832 100644 --- a/taskfile/ast/graph.go +++ b/taskfile/ast/graph.go @@ -45,6 +45,18 @@ func (tfg *TaskfileGraph) Visualize(filename string) error { return draw.DOT(tfg.Graph, f) } +// Root returns the root vertex of the graph (the entrypoint Taskfile). +func (tfg *TaskfileGraph) Root() (*TaskfileVertex, error) { + hashes, err := graph.TopologicalSort(tfg.Graph) + if err != nil { + return nil, err + } + if len(hashes) == 0 { + return nil, fmt.Errorf("task: graph has no vertices") + } + return tfg.Vertex(hashes[0]) +} + func (tfg *TaskfileGraph) Merge() (*Taskfile, error) { hashes, err := graph.TopologicalSort(tfg.Graph) if err != nil { From 0dbeaaf18708a882a2e7e513e57a910b4855cc75 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Fri, 26 Dec 2025 21:02:33 +0100 Subject: [PATCH 04/15] feat(taskfile): skip var merge when SCOPED_INCLUDES enabled When the SCOPED_INCLUDES experiment is enabled, variables from included Taskfiles are no longer merged globally. They remain in their original Taskfile within the DAG. Exception: flatten includes still merge variables globally to allow sharing common variables across multiple Taskfiles. --- setup.go | 3 ++- taskfile/ast/graph.go | 5 ++++- taskfile/ast/taskfile.go | 12 ++++++++---- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/setup.go b/setup.go index 8b3eca1006..b19408868e 100644 --- a/setup.go +++ b/setup.go @@ -20,6 +20,7 @@ import ( "github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/output" "github.com/go-task/task/v3/internal/version" + "github.com/go-task/task/v3/experiments" "github.com/go-task/task/v3/taskfile" "github.com/go-task/task/v3/taskfile/ast" ) @@ -105,7 +106,7 @@ func (e *Executor) readTaskfile(node taskfile.Node) error { return err } e.Graph = graph - if e.Taskfile, err = graph.Merge(); err != nil { + if e.Taskfile, err = graph.Merge(experiments.ScopedIncludes.Enabled()); err != nil { return err } return nil diff --git a/taskfile/ast/graph.go b/taskfile/ast/graph.go index b6c2743832..ab961b7d84 100644 --- a/taskfile/ast/graph.go +++ b/taskfile/ast/graph.go @@ -57,7 +57,9 @@ func (tfg *TaskfileGraph) Root() (*TaskfileVertex, error) { return tfg.Vertex(hashes[0]) } -func (tfg *TaskfileGraph) Merge() (*Taskfile, error) { +// Merge merges all included Taskfiles into the root Taskfile. +// If skipVarsMerge is true, variables are not merged (used for scoped includes). +func (tfg *TaskfileGraph) Merge(skipVarsMerge bool) (*Taskfile, error) { hashes, err := graph.TopologicalSort(tfg.Graph) if err != nil { return nil, err @@ -104,6 +106,7 @@ func (tfg *TaskfileGraph) Merge() (*Taskfile, error) { if err := vertex.Taskfile.Merge( includedVertex.Taskfile, include, + skipVarsMerge, ); err != nil { return err } diff --git a/taskfile/ast/taskfile.go b/taskfile/ast/taskfile.go index 4ae1dbac84..a56d2262dc 100644 --- a/taskfile/ast/taskfile.go +++ b/taskfile/ast/taskfile.go @@ -36,8 +36,9 @@ type Taskfile struct { Interval time.Duration } -// Merge merges the second Taskfile into the first -func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include) error { +// Merge merges the second Taskfile into the first. +// If skipVarsMerge is true, variables are not merged (used for scoped includes). +func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include, skipVarsMerge bool) error { if !t1.Version.Equal(t2.Version) { return fmt.Errorf(`task: Taskfiles versions should match. First is "%s" but second is "%s"`, t1.Version, t2.Version) } @@ -67,8 +68,11 @@ func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include) error { } } } - t1.Vars.Merge(t2.Vars, include) - t1.Env.Merge(t2.Env, include) + // Only merge vars if not using scoped includes, or if flattening + if !skipVarsMerge || include.Flatten { + t1.Vars.Merge(t2.Vars, include) + t1.Env.Merge(t2.Env, include) + } return t1.Tasks.Merge(t2.Tasks, include, t1.Vars) } From 04b8b755252a11b3d9d809908d7fc45d5d17b44c Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Fri, 26 Dec 2025 21:08:37 +0100 Subject: [PATCH 05/15] feat(compiler): implement lazy variable resolution for scoped includes When SCOPED_INCLUDES experiment is enabled: - Resolve vars from DAG instead of merged vars - Apply root Taskfile vars first (inheritance) - Then apply task's source Taskfile vars from DAG - Apply IncludeVars passed via includes: section - Skip IncludedTaskfileVars (contains parent's vars, not source's) This ensures tasks in included Taskfiles see: 1. Root vars (inheritance from parent) 2. Their own Taskfile's vars 3. Vars passed through includes: section 4. Call vars and task-level vars --- compiler.go | 77 +++++++++++++++++++++++++++++++++++++++++++++-------- setup.go | 1 + 2 files changed, 67 insertions(+), 11 deletions(-) diff --git a/compiler.go b/compiler.go index 311fd58423..f42425d6c2 100644 --- a/compiler.go +++ b/compiler.go @@ -9,6 +9,7 @@ import ( "strings" "sync" + "github.com/go-task/task/v3/experiments" "github.com/go-task/task/v3/internal/env" "github.com/go-task/task/v3/internal/execext" "github.com/go-task/task/v3/internal/filepathext" @@ -25,6 +26,7 @@ type Compiler struct { TaskfileEnv *ast.Vars TaskfileVars *ast.Vars + Graph *ast.TaskfileGraph Logger *logger.Logger @@ -104,27 +106,80 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (* taskRangeFunc = getRangeFunc(dir) } - for k, v := range c.TaskfileEnv.All() { - if err := rangeFunc(k, v); err != nil { + // When scoped includes is enabled, resolve vars from DAG instead of merged vars + if experiments.ScopedIncludes.Enabled() && t != nil && t.Location != nil && c.Graph != nil { + // Get root Taskfile for inheritance (parent vars are always accessible) + rootVertex, err := c.Graph.Root() + if err != nil { return nil, err } - } - for k, v := range c.TaskfileVars.All() { - if err := rangeFunc(k, v); err != nil { - return nil, err + + // Apply root env + for k, v := range rootVertex.Taskfile.Env.All() { + if err := rangeFunc(k, v); err != nil { + return nil, err + } } - } - if t != nil { - for k, v := range t.IncludeVars.All() { + + // 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) + if err != nil { + return nil, err + } + // Apply include's env (overrides root's env) + for k, v := range includeVertex.Taskfile.Env.All() { + if err := taskRangeFunc(k, v); 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 { + 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 + } + } + } + } else { + // Legacy behavior: use merged vars + for k, v := range c.TaskfileEnv.All() { if err := rangeFunc(k, v); err != nil { return nil, err } } - for k, v := range t.IncludedTaskfileVars.All() { - if err := taskRangeFunc(k, v); err != nil { + for k, v := range c.TaskfileVars.All() { + if err := rangeFunc(k, v); err != nil { return nil, err } } + if t != nil { + for k, v := range t.IncludeVars.All() { + if err := rangeFunc(k, v); err != nil { + return nil, err + } + } + for k, v := range t.IncludedTaskfileVars.All() { + if err := taskRangeFunc(k, v); err != nil { + return nil, err + } + } + } } if t == nil || call == nil { diff --git a/setup.go b/setup.go index b19408868e..e0aa596589 100644 --- a/setup.go +++ b/setup.go @@ -228,6 +228,7 @@ func (e *Executor) setupCompiler() error { UserWorkingDir: e.UserWorkingDir, TaskfileEnv: e.Taskfile.Env, TaskfileVars: e.Taskfile.Vars, + Graph: e.Graph, Logger: e.Logger, } return nil From efaea39503ebd16bf159a4905316d6cff62aaf4e Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Fri, 26 Dec 2025 21:19:13 +0100 Subject: [PATCH 06/15] test(scoped-includes): add tests for variable isolation Tests verify: - Legacy mode: vars merged globally (A sees B's VAR, can access UNIQUE_B) - Scoped mode: vars isolated (A sees own VAR, cannot access UNIQUE_B) - Inheritance: includes can still access root vars (ROOT_VAR) Test structure: - testdata/scoped_includes/ with main Taskfile and two includes - inc_a and inc_b both define VAR with different values - Cross-include test shows A trying to access B's UNIQUE_B --- executor_test.go | 66 +++++++++++++++++++ testdata/scoped_includes/Taskfile.yml | 20 ++++++ testdata/scoped_includes/inc_a/Taskfile.yml | 18 +++++ testdata/scoped_includes/inc_b/Taskfile.yml | 13 ++++ ...ScopedIncludes-legacy-cross-include.golden | 1 + .../TestScopedIncludes-legacy-default.golden | 6 ++ ...ScopedIncludes-scoped-cross-include.golden | 1 + .../TestScopedIncludes-scoped-default.golden | 6 ++ ...ScopedIncludes-scoped-inheritance-a.golden | 3 + ...stScopedIncludes-scoped-isolation-b.golden | 3 + 10 files changed, 137 insertions(+) create mode 100644 testdata/scoped_includes/Taskfile.yml create mode 100644 testdata/scoped_includes/inc_a/Taskfile.yml create mode 100644 testdata/scoped_includes/inc_b/Taskfile.yml create mode 100644 testdata/scoped_includes/testdata/TestScopedIncludes-legacy-cross-include.golden create mode 100644 testdata/scoped_includes/testdata/TestScopedIncludes-legacy-default.golden create mode 100644 testdata/scoped_includes/testdata/TestScopedIncludes-scoped-cross-include.golden create mode 100644 testdata/scoped_includes/testdata/TestScopedIncludes-scoped-default.golden create mode 100644 testdata/scoped_includes/testdata/TestScopedIncludes-scoped-inheritance-a.golden create mode 100644 testdata/scoped_includes/testdata/TestScopedIncludes-scoped-isolation-b.golden diff --git a/executor_test.go b/executor_test.go index d810e652f0..89c28777f4 100644 --- a/executor_test.go +++ b/executor_test.go @@ -1180,3 +1180,69 @@ func TestIf(t *testing.T) { NewExecutorTest(t, opts...) } } + +func TestScopedIncludes(t *testing.T) { + t.Parallel() + + // Legacy tests (without experiment) - vars should be merged globally + t.Run("legacy", func(t *testing.T) { + // Test with scoped includes disabled (legacy) - vars should be merged globally + NewExecutorTest(t, + WithName("default"), + WithExecutorOptions( + task.WithDir("testdata/scoped_includes"), + task.WithSilent(true), + ), + ) + // In legacy mode, UNIQUE_B should be accessible (merged globally) + NewExecutorTest(t, + WithName("cross-include"), + WithExecutorOptions( + task.WithDir("testdata/scoped_includes"), + task.WithSilent(true), + ), + WithTask("a:try-access-b"), + ) + }) + + // Scoped tests (with experiment enabled) - vars should be isolated + t.Run("scoped", func(t *testing.T) { + enableExperimentForTest(t, &experiments.ScopedIncludes, 1) + + // Test with scoped includes enabled - vars should be isolated + NewExecutorTest(t, + WithName("default"), + WithExecutorOptions( + task.WithDir("testdata/scoped_includes"), + task.WithSilent(true), + ), + ) + // Test inheritance: include can access root vars + NewExecutorTest(t, + WithName("inheritance-a"), + WithExecutorOptions( + task.WithDir("testdata/scoped_includes"), + task.WithSilent(true), + ), + WithTask("a:print"), + ) + // Test isolation: each include sees its own vars + NewExecutorTest(t, + WithName("isolation-b"), + WithExecutorOptions( + task.WithDir("testdata/scoped_includes"), + task.WithSilent(true), + ), + WithTask("b:print"), + ) + // In scoped mode, UNIQUE_B should be empty (isolated) + NewExecutorTest(t, + WithName("cross-include"), + WithExecutorOptions( + task.WithDir("testdata/scoped_includes"), + task.WithSilent(true), + ), + WithTask("a:try-access-b"), + ) + }) +} diff --git a/testdata/scoped_includes/Taskfile.yml b/testdata/scoped_includes/Taskfile.yml new file mode 100644 index 0000000000..d8b76ccc3f --- /dev/null +++ b/testdata/scoped_includes/Taskfile.yml @@ -0,0 +1,20 @@ +version: "3" + +vars: + ROOT_VAR: from_root + +includes: + a: ./inc_a + b: ./inc_b + +tasks: + default: + desc: Test scoped includes - vars should be isolated + cmds: + - task: a:print + - task: b:print + + print-root-var: + desc: Print ROOT_VAR from root + cmds: + - echo "ROOT_VAR={{.ROOT_VAR}}" diff --git a/testdata/scoped_includes/inc_a/Taskfile.yml b/testdata/scoped_includes/inc_a/Taskfile.yml new file mode 100644 index 0000000000..384c81afb7 --- /dev/null +++ b/testdata/scoped_includes/inc_a/Taskfile.yml @@ -0,0 +1,18 @@ +version: "3" + +vars: + VAR: value_from_a + UNIQUE_A: only_in_a + +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}}" + + try-access-b: + desc: Try to access B's unique var (should fail in scoped mode) + cmds: + - echo "A:UNIQUE_B={{.UNIQUE_B}}" diff --git a/testdata/scoped_includes/inc_b/Taskfile.yml b/testdata/scoped_includes/inc_b/Taskfile.yml new file mode 100644 index 0000000000..662d325fe8 --- /dev/null +++ b/testdata/scoped_includes/inc_b/Taskfile.yml @@ -0,0 +1,13 @@ +version: "3" + +vars: + VAR: value_from_b + UNIQUE_B: only_in_b + +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_includes/testdata/TestScopedIncludes-legacy-cross-include.golden b/testdata/scoped_includes/testdata/TestScopedIncludes-legacy-cross-include.golden new file mode 100644 index 0000000000..5b2b94fc33 --- /dev/null +++ b/testdata/scoped_includes/testdata/TestScopedIncludes-legacy-cross-include.golden @@ -0,0 +1 @@ +A:UNIQUE_B=only_in_b diff --git a/testdata/scoped_includes/testdata/TestScopedIncludes-legacy-default.golden b/testdata/scoped_includes/testdata/TestScopedIncludes-legacy-default.golden new file mode 100644 index 0000000000..bc8e9c9d76 --- /dev/null +++ b/testdata/scoped_includes/testdata/TestScopedIncludes-legacy-default.golden @@ -0,0 +1,6 @@ +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_includes/testdata/TestScopedIncludes-scoped-cross-include.golden b/testdata/scoped_includes/testdata/TestScopedIncludes-scoped-cross-include.golden new file mode 100644 index 0000000000..0dcb063b80 --- /dev/null +++ b/testdata/scoped_includes/testdata/TestScopedIncludes-scoped-cross-include.golden @@ -0,0 +1 @@ +A:UNIQUE_B= diff --git a/testdata/scoped_includes/testdata/TestScopedIncludes-scoped-default.golden b/testdata/scoped_includes/testdata/TestScopedIncludes-scoped-default.golden new file mode 100644 index 0000000000..c00cf2967a --- /dev/null +++ b/testdata/scoped_includes/testdata/TestScopedIncludes-scoped-default.golden @@ -0,0 +1,6 @@ +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_includes/testdata/TestScopedIncludes-scoped-inheritance-a.golden b/testdata/scoped_includes/testdata/TestScopedIncludes-scoped-inheritance-a.golden new file mode 100644 index 0000000000..b047ffabae --- /dev/null +++ b/testdata/scoped_includes/testdata/TestScopedIncludes-scoped-inheritance-a.golden @@ -0,0 +1,3 @@ +A:VAR=value_from_a +A:UNIQUE_A=only_in_a +A:ROOT_VAR=from_root diff --git a/testdata/scoped_includes/testdata/TestScopedIncludes-scoped-isolation-b.golden b/testdata/scoped_includes/testdata/TestScopedIncludes-scoped-isolation-b.golden new file mode 100644 index 0000000000..47f57b19fd --- /dev/null +++ b/testdata/scoped_includes/testdata/TestScopedIncludes-scoped-isolation-b.golden @@ -0,0 +1,3 @@ +B:VAR=value_from_b +B:UNIQUE_B=only_in_b +B:ROOT_VAR=from_root From edee501b6b17d4a6b08073d4435fd97243274e0e Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Mon, 29 Dec 2025 16:31:51 +0100 Subject: [PATCH 07/15] feat(experiments): rename SCOPED_INCLUDES to SCOPED_TASKFILES and add env namespace Rename the experiment from SCOPED_INCLUDES to SCOPED_TASKFILES to better reflect its expanded scope. This experiment now provides: 1. Variable scoping (existing): includes see only their own vars + parent vars 2. Environment namespace (new): env vars accessible via {{.env.XXX}} With TASK_X_SCOPED_TASKFILES=1: - {{.VAR}} accesses vars only (scoped per include) - {{.env.VAR}} accesses env (OS + Taskfile env:, inherited) - {{.TASK}} and other special vars remain at root level This is a breaking change for the experimental feature: - {{.PATH}} no longer works, use {{.env.PATH}} instead - Env vars are no longer at root level in templates --- compiler.go | 68 +++++++++++++++++-- executor_test.go | 50 +++++++++++--- experiments/experiments.go | 10 +-- setup.go | 2 +- testdata/scoped_includes/Taskfile.yml | 20 ------ testdata/scoped_includes/inc_a/Taskfile.yml | 18 ----- testdata/scoped_includes/inc_b/Taskfile.yml | 13 ---- testdata/scoped_taskfiles/Taskfile.yml | 38 +++++++++++ testdata/scoped_taskfiles/inc_a/Taskfile.yml | 36 ++++++++++ testdata/scoped_taskfiles/inc_b/Taskfile.yml | 24 +++++++ ...opedTaskfiles-legacy-cross-include.golden} | 0 ...TestScopedTaskfiles-legacy-default.golden} | 0 ...opedTaskfiles-scoped-cross-include.golden} | 0 ...TestScopedTaskfiles-scoped-default.golden} | 0 ...copedTaskfiles-scoped-env-namespace.golden | 3 + ...opedTaskfiles-scoped-env-separation.golden | 1 + ...tScopedTaskfiles-scoped-include-env.golden | 3 + ...opedTaskfiles-scoped-inheritance-a.golden} | 0 ...ScopedTaskfiles-scoped-isolation-b.golden} | 0 19 files changed, 212 insertions(+), 74 deletions(-) delete mode 100644 testdata/scoped_includes/Taskfile.yml delete mode 100644 testdata/scoped_includes/inc_a/Taskfile.yml delete mode 100644 testdata/scoped_includes/inc_b/Taskfile.yml create mode 100644 testdata/scoped_taskfiles/Taskfile.yml create mode 100644 testdata/scoped_taskfiles/inc_a/Taskfile.yml create mode 100644 testdata/scoped_taskfiles/inc_b/Taskfile.yml rename testdata/{scoped_includes/testdata/TestScopedIncludes-legacy-cross-include.golden => scoped_taskfiles/testdata/TestScopedTaskfiles-legacy-cross-include.golden} (100%) rename testdata/{scoped_includes/testdata/TestScopedIncludes-legacy-default.golden => scoped_taskfiles/testdata/TestScopedTaskfiles-legacy-default.golden} (100%) rename testdata/{scoped_includes/testdata/TestScopedIncludes-scoped-cross-include.golden => scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-cross-include.golden} (100%) rename testdata/{scoped_includes/testdata/TestScopedIncludes-scoped-default.golden => scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-default.golden} (100%) create mode 100644 testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-env-namespace.golden create mode 100644 testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-env-separation.golden create mode 100644 testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-include-env.golden rename testdata/{scoped_includes/testdata/TestScopedIncludes-scoped-inheritance-a.golden => scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-inheritance-a.golden} (100%) rename testdata/{scoped_includes/testdata/TestScopedIncludes-scoped-isolation-b.golden => scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-isolation-b.golden} (100%) diff --git a/compiler.go b/compiler.go index f42425d6c2..f6a97814ac 100644 --- a/compiler.go +++ b/compiler.go @@ -47,7 +47,16 @@ func (c *Compiler) FastGetVariables(t *ast.Task, call *Call) (*ast.Vars, error) } func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*ast.Vars, error) { - result := env.GetEnviron() + // 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() + } + specialVars, err := c.getSpecialVars(t, call) if err != nil { return nil, err @@ -107,20 +116,64 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (* } // When scoped includes is enabled, resolve vars from DAG instead of merged vars - if experiments.ScopedIncludes.Enabled() && t != nil && t.Location != nil && c.Graph != nil { + if scopedMode { // Get root Taskfile for inheritance (parent vars are always accessible) rootVertex, err := c.Graph.Root() if err != nil { return nil, err } - // Apply root env + // === 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 := rangeFunc(k, v); err != nil { + 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 { @@ -134,9 +187,9 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (* if err != nil { return nil, err } - // Apply include's env (overrides root's env) + // Apply include's env to envMap (overrides root's env) for k, v := range includeVertex.Taskfile.Env.All() { - if err := taskRangeFunc(k, v); err != nil { + if err := resolveEnvToMap(k, v, filepathext.SmartJoin(c.Dir, t.Dir)); err != nil { return nil, err } } @@ -156,6 +209,9 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (* } } } + + // Inject env namespace into result + result.Set("env", ast.Var{Value: envMap}) } else { // Legacy behavior: use merged vars for k, v := range c.TaskfileEnv.All() { diff --git a/executor_test.go b/executor_test.go index 89c28777f4..dd268330ab 100644 --- a/executor_test.go +++ b/executor_test.go @@ -1181,16 +1181,17 @@ func TestIf(t *testing.T) { } } -func TestScopedIncludes(t *testing.T) { - t.Parallel() +func TestScopedTaskfiles(t *testing.T) { + // NOTE: Don't use t.Parallel() here because enableExperimentForTest modifies + // global state that can affect other tests running in parallel. // Legacy tests (without experiment) - vars should be merged globally t.Run("legacy", func(t *testing.T) { - // Test with scoped includes disabled (legacy) - vars should be merged globally + // Test with scoped taskfiles disabled (legacy) - vars should be merged globally NewExecutorTest(t, WithName("default"), WithExecutorOptions( - task.WithDir("testdata/scoped_includes"), + task.WithDir("testdata/scoped_taskfiles"), task.WithSilent(true), ), ) @@ -1198,7 +1199,7 @@ func TestScopedIncludes(t *testing.T) { NewExecutorTest(t, WithName("cross-include"), WithExecutorOptions( - task.WithDir("testdata/scoped_includes"), + task.WithDir("testdata/scoped_taskfiles"), task.WithSilent(true), ), WithTask("a:try-access-b"), @@ -1207,13 +1208,13 @@ func TestScopedIncludes(t *testing.T) { // Scoped tests (with experiment enabled) - vars should be isolated t.Run("scoped", func(t *testing.T) { - enableExperimentForTest(t, &experiments.ScopedIncludes, 1) + enableExperimentForTest(t, &experiments.ScopedTaskfiles, 1) - // Test with scoped includes enabled - vars should be isolated + // Test with scoped taskfiles enabled - vars should be isolated NewExecutorTest(t, WithName("default"), WithExecutorOptions( - task.WithDir("testdata/scoped_includes"), + task.WithDir("testdata/scoped_taskfiles"), task.WithSilent(true), ), ) @@ -1221,7 +1222,7 @@ func TestScopedIncludes(t *testing.T) { NewExecutorTest(t, WithName("inheritance-a"), WithExecutorOptions( - task.WithDir("testdata/scoped_includes"), + task.WithDir("testdata/scoped_taskfiles"), task.WithSilent(true), ), WithTask("a:print"), @@ -1230,7 +1231,7 @@ func TestScopedIncludes(t *testing.T) { NewExecutorTest(t, WithName("isolation-b"), WithExecutorOptions( - task.WithDir("testdata/scoped_includes"), + task.WithDir("testdata/scoped_taskfiles"), task.WithSilent(true), ), WithTask("b:print"), @@ -1239,10 +1240,37 @@ func TestScopedIncludes(t *testing.T) { NewExecutorTest(t, WithName("cross-include"), WithExecutorOptions( - task.WithDir("testdata/scoped_includes"), + task.WithDir("testdata/scoped_taskfiles"), task.WithSilent(true), ), WithTask("a:try-access-b"), ) + // Test env namespace: {{.env.XXX}} should access env vars + NewExecutorTest(t, + WithName("env-namespace"), + WithExecutorOptions( + task.WithDir("testdata/scoped_taskfiles"), + task.WithSilent(true), + ), + WithTask("print-env"), + ) + // Test env separation: {{.ROOT_ENV}} at root should be empty (env not at root level) + NewExecutorTest(t, + WithName("env-separation"), + WithExecutorOptions( + task.WithDir("testdata/scoped_taskfiles"), + task.WithSilent(true), + ), + WithTask("test-env-separation"), + ) + // Test include env: include's env is accessible via {{.env.XXX}} + NewExecutorTest(t, + WithName("include-env"), + WithExecutorOptions( + task.WithDir("testdata/scoped_taskfiles"), + task.WithSilent(true), + ), + WithTask("a:print-env"), + ) }) } diff --git a/experiments/experiments.go b/experiments/experiments.go index b3f07baa33..0e0139d182 100644 --- a/experiments/experiments.go +++ b/experiments/experiments.go @@ -16,10 +16,10 @@ const envPrefix = "TASK_X_" // Active experiments. var ( - GentleForce Experiment - RemoteTaskfiles Experiment - EnvPrecedence Experiment - ScopedIncludes Experiment + GentleForce Experiment + RemoteTaskfiles Experiment + EnvPrecedence Experiment + ScopedTaskfiles Experiment ) // Inactive experiments. These are experiments that cannot be enabled, but are @@ -44,7 +44,7 @@ func ParseWithConfig(dir string, config *ast.TaskRC) { GentleForce = New("GENTLE_FORCE", config, 1) RemoteTaskfiles = New("REMOTE_TASKFILES", config, 1) EnvPrecedence = New("ENV_PRECEDENCE", config, 1) - ScopedIncludes = New("SCOPED_INCLUDES", config, 1) + ScopedTaskfiles = New("SCOPED_TASKFILES", config, 1) AnyVariables = New("ANY_VARIABLES", config) MapVariables = New("MAP_VARIABLES", config) } diff --git a/setup.go b/setup.go index e0aa596589..bf252fb740 100644 --- a/setup.go +++ b/setup.go @@ -106,7 +106,7 @@ func (e *Executor) readTaskfile(node taskfile.Node) error { return err } e.Graph = graph - if e.Taskfile, err = graph.Merge(experiments.ScopedIncludes.Enabled()); err != nil { + if e.Taskfile, err = graph.Merge(experiments.ScopedTaskfiles.Enabled()); err != nil { return err } return nil diff --git a/testdata/scoped_includes/Taskfile.yml b/testdata/scoped_includes/Taskfile.yml deleted file mode 100644 index d8b76ccc3f..0000000000 --- a/testdata/scoped_includes/Taskfile.yml +++ /dev/null @@ -1,20 +0,0 @@ -version: "3" - -vars: - ROOT_VAR: from_root - -includes: - a: ./inc_a - b: ./inc_b - -tasks: - default: - desc: Test scoped includes - vars should be isolated - cmds: - - task: a:print - - task: b:print - - print-root-var: - desc: Print ROOT_VAR from root - cmds: - - echo "ROOT_VAR={{.ROOT_VAR}}" diff --git a/testdata/scoped_includes/inc_a/Taskfile.yml b/testdata/scoped_includes/inc_a/Taskfile.yml deleted file mode 100644 index 384c81afb7..0000000000 --- a/testdata/scoped_includes/inc_a/Taskfile.yml +++ /dev/null @@ -1,18 +0,0 @@ -version: "3" - -vars: - VAR: value_from_a - UNIQUE_A: only_in_a - -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}}" - - try-access-b: - desc: Try to access B's unique var (should fail in scoped mode) - cmds: - - echo "A:UNIQUE_B={{.UNIQUE_B}}" diff --git a/testdata/scoped_includes/inc_b/Taskfile.yml b/testdata/scoped_includes/inc_b/Taskfile.yml deleted file mode 100644 index 662d325fe8..0000000000 --- a/testdata/scoped_includes/inc_b/Taskfile.yml +++ /dev/null @@ -1,13 +0,0 @@ -version: "3" - -vars: - VAR: value_from_b - UNIQUE_B: only_in_b - -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/Taskfile.yml b/testdata/scoped_taskfiles/Taskfile.yml new file mode 100644 index 0000000000..739cd481c2 --- /dev/null +++ b/testdata/scoped_taskfiles/Taskfile.yml @@ -0,0 +1,38 @@ +version: "3" + +env: + ROOT_ENV: env_from_root + SHARED_ENV: shared_from_root + +vars: + ROOT_VAR: from_root + +includes: + a: ./inc_a + b: ./inc_b + +tasks: + default: + desc: Test scoped includes - vars should be isolated + cmds: + - task: a:print + - task: b:print + + print-root-var: + desc: Print ROOT_VAR from root + cmds: + - echo "ROOT_VAR={{.ROOT_VAR}}" + + print-env: + desc: Print env vars using {{.env.XXX}} syntax + cmds: + - echo "ROOT_ENV={{.env.ROOT_ENV}}" + - echo "SHARED_ENV={{.env.SHARED_ENV}}" + - echo "PATH_EXISTS={{if .env.PATH}}yes{{else}}no{{end}}" + + test-env-separation: + desc: Test that env is NOT at root level in scoped mode + cmds: + # In scoped mode, {{.ROOT_ENV}} should be empty (env not at root) + # In legacy mode, {{.ROOT_ENV}} would have the value + - echo "ROOT_ENV_AT_ROOT={{.ROOT_ENV}}" diff --git a/testdata/scoped_taskfiles/inc_a/Taskfile.yml b/testdata/scoped_taskfiles/inc_a/Taskfile.yml new file mode 100644 index 0000000000..f1ecadb2e0 --- /dev/null +++ b/testdata/scoped_taskfiles/inc_a/Taskfile.yml @@ -0,0 +1,36 @@ +version: "3" + +env: + INC_A_ENV: env_from_a + SHARED_ENV: shared_from_a + +vars: + VAR: value_from_a + UNIQUE_A: only_in_a + +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}}" + + try-access-b: + desc: Try to access B's unique var (should fail in scoped mode) + cmds: + - echo "A:UNIQUE_B={{.UNIQUE_B}}" + + print-env: + desc: Print env vars from include A + cmds: + - echo "A:INC_A_ENV={{.env.INC_A_ENV}}" + - echo "A:ROOT_ENV={{.env.ROOT_ENV}}" + - echo "A:SHARED_ENV={{.env.SHARED_ENV}}" + + test-env-in-var: + desc: Test using env in a var template + vars: + COMPOSED: "env={{.env.ROOT_ENV}}" + cmds: + - echo "{{.COMPOSED}}" diff --git a/testdata/scoped_taskfiles/inc_b/Taskfile.yml b/testdata/scoped_taskfiles/inc_b/Taskfile.yml new file mode 100644 index 0000000000..e1c5643e17 --- /dev/null +++ b/testdata/scoped_taskfiles/inc_b/Taskfile.yml @@ -0,0 +1,24 @@ +version: "3" + +env: + INC_B_ENV: env_from_b + SHARED_ENV: shared_from_b + +vars: + VAR: value_from_b + UNIQUE_B: only_in_b + +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}}" + + print-env: + desc: Print env vars from include B + cmds: + - echo "B:INC_B_ENV={{.env.INC_B_ENV}}" + - echo "B:ROOT_ENV={{.env.ROOT_ENV}}" + - echo "B:SHARED_ENV={{.env.SHARED_ENV}}" diff --git a/testdata/scoped_includes/testdata/TestScopedIncludes-legacy-cross-include.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-legacy-cross-include.golden similarity index 100% rename from testdata/scoped_includes/testdata/TestScopedIncludes-legacy-cross-include.golden rename to testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-legacy-cross-include.golden diff --git a/testdata/scoped_includes/testdata/TestScopedIncludes-legacy-default.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-legacy-default.golden similarity index 100% rename from testdata/scoped_includes/testdata/TestScopedIncludes-legacy-default.golden rename to testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-legacy-default.golden diff --git a/testdata/scoped_includes/testdata/TestScopedIncludes-scoped-cross-include.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-cross-include.golden similarity index 100% rename from testdata/scoped_includes/testdata/TestScopedIncludes-scoped-cross-include.golden rename to testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-cross-include.golden diff --git a/testdata/scoped_includes/testdata/TestScopedIncludes-scoped-default.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-default.golden similarity index 100% rename from testdata/scoped_includes/testdata/TestScopedIncludes-scoped-default.golden rename to testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-default.golden diff --git a/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-env-namespace.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-env-namespace.golden new file mode 100644 index 0000000000..6f3d53916f --- /dev/null +++ b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-env-namespace.golden @@ -0,0 +1,3 @@ +ROOT_ENV=env_from_root +SHARED_ENV=shared_from_root +PATH_EXISTS=yes diff --git a/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-env-separation.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-env-separation.golden new file mode 100644 index 0000000000..9a4d6ae270 --- /dev/null +++ b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-env-separation.golden @@ -0,0 +1 @@ +ROOT_ENV_AT_ROOT= diff --git a/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-include-env.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-include-env.golden new file mode 100644 index 0000000000..28650ffb6c --- /dev/null +++ b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-include-env.golden @@ -0,0 +1,3 @@ +A:INC_A_ENV=env_from_a +A:ROOT_ENV=env_from_root +A:SHARED_ENV=shared_from_a diff --git a/testdata/scoped_includes/testdata/TestScopedIncludes-scoped-inheritance-a.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-inheritance-a.golden similarity index 100% rename from testdata/scoped_includes/testdata/TestScopedIncludes-scoped-inheritance-a.golden rename to testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-inheritance-a.golden diff --git a/testdata/scoped_includes/testdata/TestScopedIncludes-scoped-isolation-b.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-isolation-b.golden similarity index 100% rename from testdata/scoped_includes/testdata/TestScopedIncludes-scoped-isolation-b.golden rename to testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-isolation-b.golden From e05c9f7793ed47f8864f8732cd65d809a40e1717 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Mon, 29 Dec 2025 16:45:41 +0100 Subject: [PATCH 08/15] fix(compiler): CLI vars have highest priority in scoped mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In scoped mode, CLI vars (e.g., `task foo VAR=value`) now correctly override task-level vars. This is achieved by: 1. Adding a `CLIVars` field to the Compiler struct 2. Storing CLI globals in this field after parsing 3. Applying CLI vars last in scoped mode to ensure they override everything The order of variable resolution in scoped mode is now: 1. OS env → {{.env.XXX}} 2. Root taskfile env → {{.env.XXX}} 3. Root taskfile vars → {{.VAR}} 4. Include taskfile env/vars (if applicable) 5. IncludeVars (vars passed via includes: section) 6. Task-level vars 7. CLI vars (highest priority) Legacy mode behavior is unchanged. --- cmd/task/task.go | 2 + compiler.go | 53 ++++++++++++++++++-------- testdata/scoped_taskfiles/Taskfile.yml | 6 +++ 3 files changed, 45 insertions(+), 16 deletions(-) diff --git a/cmd/task/task.go b/cmd/task/task.go index b81e23dd5f..9274b24726 100644 --- a/cmd/task/task.go +++ b/cmd/task/task.go @@ -174,6 +174,8 @@ func run() error { // Merge CLI variables first (e.g. FOO=bar) so they take priority over Taskfile defaults e.Taskfile.Vars.Merge(globals, nil) + // Store CLI vars for scoped mode where they need highest priority + e.Compiler.CLIVars = globals // Then ReverseMerge special variables so they're available for templating cliArgsPostDashQuoted, err := args.ToQuotedString(cliArgsPostDash) diff --git a/compiler.go b/compiler.go index f6a97814ac..1e24b3b39b 100644 --- a/compiler.go +++ b/compiler.go @@ -26,6 +26,7 @@ type Compiler struct { TaskfileEnv *ast.Vars TaskfileVars *ast.Vars + CLIVars *ast.Vars // CLI vars passed via command line (e.g., task foo VAR=value) Graph *ast.TaskfileGraph Logger *logger.Logger @@ -210,30 +211,49 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (* } } - // Inject env namespace into result - result.Set("env", ast.Var{Value: envMap}) - } else { - // Legacy behavior: use merged vars - for k, v := range c.TaskfileEnv.All() { + // Apply task-level vars + if call != nil { + for k, v := range t.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 } } - for k, v := range c.TaskfileVars.All() { + + // Inject env namespace into result + result.Set("env", ast.Var{Value: envMap}) + + return result, nil + } + + // === LEGACY MODE === + // Legacy behavior: use merged vars + for k, v := range c.TaskfileEnv.All() { + if err := rangeFunc(k, v); err != nil { + return nil, err + } + } + for k, v := range c.TaskfileVars.All() { + if err := rangeFunc(k, v); err != nil { + return nil, err + } + } + if t != nil { + for k, v := range t.IncludeVars.All() { if err := rangeFunc(k, v); err != nil { return nil, err } } - if t != nil { - for k, v := range t.IncludeVars.All() { - if err := rangeFunc(k, v); err != nil { - return nil, err - } - } - for k, v := range t.IncludedTaskfileVars.All() { - if err := taskRangeFunc(k, v); err != nil { - return nil, err - } + for k, v := range t.IncludedTaskfileVars.All() { + if err := taskRangeFunc(k, v); err != nil { + return nil, err } } } @@ -242,6 +262,7 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (* return result, nil } + // Legacy order: CLI vars, then task vars (task vars override CLI) for k, v := range call.Vars.All() { if err := rangeFunc(k, v); err != nil { return nil, err diff --git a/testdata/scoped_taskfiles/Taskfile.yml b/testdata/scoped_taskfiles/Taskfile.yml index 739cd481c2..771859770e 100644 --- a/testdata/scoped_taskfiles/Taskfile.yml +++ b/testdata/scoped_taskfiles/Taskfile.yml @@ -36,3 +36,9 @@ tasks: # In scoped mode, {{.ROOT_ENV}} should be empty (env not at root) # In legacy mode, {{.ROOT_ENV}} would have the value - echo "ROOT_ENV_AT_ROOT={{.ROOT_ENV}}" + + prout: + vars: + LOL: prout_from_root + cmds: + - echo "{{.LOL}}" From 5ef7313e9521dfa8c4ec6e1ce49830f66ac5f36b Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Mon, 29 Dec 2025 16:56:14 +0100 Subject: [PATCH 09/15] docs(experiments): add SCOPED_TASKFILES documentation Document the new experiment with: - Environment namespace ({{.env.XXX}}) explanation - Variable scoping between includes - CLI variables priority - Migration guide from legacy mode - Comparison table between legacy and scoped modes --- website/.vitepress/config.ts | 4 + .../src/docs/experiments/scoped-taskfiles.md | 201 ++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 website/src/docs/experiments/scoped-taskfiles.md diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index 24c675f16b..db44f510b1 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -302,6 +302,10 @@ export default defineConfig({ { text: 'Remote Taskfiles (#1317)', link: '/docs/experiments/remote-taskfiles' + }, + { + text: 'Scoped Taskfiles', + link: '/docs/experiments/scoped-taskfiles' } ] }, diff --git a/website/src/docs/experiments/scoped-taskfiles.md b/website/src/docs/experiments/scoped-taskfiles.md new file mode 100644 index 0000000000..7cee72e81f --- /dev/null +++ b/website/src/docs/experiments/scoped-taskfiles.md @@ -0,0 +1,201 @@ +--- +title: 'Scoped Taskfiles' +description: + Experiment for variable isolation and env namespace in included Taskfiles +outline: deep +--- + +# Scoped Taskfiles + +::: warning + +All experimental features are subject to breaking changes and/or removal _at any +time_. We strongly recommend that you do not use these features in a production +environment. They are intended for testing and feedback only. + +::: + +::: danger + +This experiment breaks the following functionality: + +- **Environment variables are no longer available at root level in templates** + - Before: `{{.PATH}}`, `{{.MY_ENV}}` + - After: `{{.env.PATH}}`, + `{{.env.MY_ENV}}` +- **Variables from sibling includes are no longer visible** + - Include A cannot access variables defined in Include B + - Each include only sees: root vars + its own vars + parent vars + +::: + +::: info + +To enable this experiment, set the environment variable: +`TASK_X_SCOPED_TASKFILES=1`. Check out +[our guide to enabling experiments](./index.md#enabling-experiments) for more +information. + +::: + +This experiment introduces two major changes to how variables work in Task: + +1. **Environment namespace**: Environment variables (both OS and Taskfile `env:` + sections) are moved to a dedicated `{{.env.XXX}}` + namespace, separating them from regular variables +2. **Variable scoping**: Variables defined in included Taskfiles are isolated - + sibling includes cannot see each other's variables + +## Environment Namespace + +With this experiment enabled, environment variables are no longer mixed with +regular variables at the template root level. Instead, they are accessible +through the `{{.env.XXX}}` namespace. + +### Comparison Table + +| Template | Legacy | SCOPED_TASKFILES | +| ----------------------------------------------- | ------ | ------------------------- | +| `{{.MY_VAR}}` (from `vars:`) | Works | Works | +| `{{.MY_ENV}}` (from `env:`) | Works | `` | +| `{{.env.MY_ENV}}` | - | Works | +| `{{.PATH}}` (OS) | Works | `` | +| `{{.env.PATH}}` (OS) | - | Works | +| `{{.TASK}}` (special) | Works | Works (stays at root) | + +### Example + +```yaml +version: '3' + +env: + DB_HOST: localhost + +vars: + DB_NAME: mydb + +tasks: + show: + cmds: + # Access Taskfile env: section + - echo "Host: {{.env.DB_HOST}}" + + # Access regular vars (unchanged) + - echo "Name: {{.DB_NAME}}" + + # Access OS environment variables + - echo "Path: {{.env.PATH}}" + + # Special variables stay at root level + - echo "Task: {{.TASK}}" +``` + +## Variable Scoping + +Variables defined in included Taskfiles are now isolated from each other. +Sibling includes cannot access each other's variables, but child includes can +still inherit variables from their parent. + +### Example + +```yaml +# Taskfile.yml +version: '3' + +vars: + ROOT_VAR: from_root + +includes: + api: ./api + web: ./web +``` + +```yaml +# api/Taskfile.yml +version: '3' + +vars: + API_VAR: from_api + +tasks: + show: + cmds: + # Inherited from root - works + - echo "ROOT_VAR={{.ROOT_VAR}}" + + # Own variable - works + - echo "API_VAR={{.API_VAR}}" + + # From sibling include - NOT visible + - echo "WEB_VAR={{.WEB_VAR}}" +``` + +```yaml +# web/Taskfile.yml +version: '3' + +vars: + WEB_VAR: from_web + +tasks: + show: + cmds: + # Inherited from root - works + - echo "ROOT_VAR={{.ROOT_VAR}}" + + # Own variable - works + - echo "WEB_VAR={{.WEB_VAR}}" + + # From sibling include - NOT visible + - echo "API_VAR={{.API_VAR}}" +``` + +## CLI Variables Priority + +With this experiment, CLI variables (passed as `task foo VAR=value`) have the +highest priority and will override task-level variables. + +```yaml +version: '3' + +tasks: + greet: + vars: + NAME: from_task + cmds: + - echo "Hello {{.NAME}}" +``` + +```bash +# CLI vars now override task vars +TASK_X_SCOPED_TASKFILES=1 task greet NAME=cli +# Output: Hello cli +``` + +## Migration Guide + +To migrate your Taskfiles to use this experiment: + +1. **Update environment variable references** in your templates: + + - `{{.PATH}}` becomes + `{{.env.PATH}}` + - `{{.HOME}}` becomes + `{{.env.HOME}}` + - `{{.MY_TASKFILE_ENV}}` becomes + `{{.env.MY_TASKFILE_ENV}}` + +2. **Variables in `vars:` sections remain unchanged**: + + - `{{.MY_VAR}}` still works the same way + +3. **Special variables stay at root level**: + + - `{{.TASK}}`, `{{.ROOT_DIR}}`, + `{{.TASKFILE}}`, `{{.TASKFILE_DIR}}`, + etc. + +4. **Review cross-include variable dependencies**: + - 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. From a57a16efca2e1f6189a778daee1cb8bfd1b2054f Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Mon, 29 Dec 2025 17:07:48 +0100 Subject: [PATCH 10/15] fix(compiler): add call.Vars support in scoped mode When calling a task with vars (e.g., `task: name` with `vars:`), those vars were not being applied in scoped mode. This fix adds call.Vars to the variable resolution chain. Variable priority (lowest to highest): 1. Root Taskfile vars 2. Include Taskfile vars 3. Include passthrough vars 4. Task vars 5. Call vars (NEW) 6. CLI vars --- compiler.go | 6 +++ executor_test.go | 9 ++++ testdata/scoped_taskfiles/Taskfile.yml | 13 +++++ ...estScopedTaskfiles-scoped-call-vars.golden | 1 + .../src/docs/experiments/scoped-taskfiles.md | 50 ++++++++++++++----- 5 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-call-vars.golden diff --git a/compiler.go b/compiler.go index 1e24b3b39b..53d09d7863 100644 --- a/compiler.go +++ b/compiler.go @@ -218,6 +218,12 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (* 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 diff --git a/executor_test.go b/executor_test.go index dd268330ab..574ea27a70 100644 --- a/executor_test.go +++ b/executor_test.go @@ -1272,5 +1272,14 @@ func TestScopedTaskfiles(t *testing.T) { ), WithTask("a:print-env"), ) + // Test call vars: vars passed when calling a task override task vars + NewExecutorTest(t, + WithName("call-vars"), + WithExecutorOptions( + task.WithDir("testdata/scoped_taskfiles"), + task.WithSilent(true), + ), + WithTask("call-with-vars"), + ) }) } diff --git a/testdata/scoped_taskfiles/Taskfile.yml b/testdata/scoped_taskfiles/Taskfile.yml index 771859770e..8cff71139e 100644 --- a/testdata/scoped_taskfiles/Taskfile.yml +++ b/testdata/scoped_taskfiles/Taskfile.yml @@ -42,3 +42,16 @@ tasks: LOL: prout_from_root cmds: - echo "{{.LOL}}" + + call-with-vars: + desc: Test calling a task with vars override + cmds: + - task: print-name + vars: + NAME: from_caller + + print-name: + vars: + NAME: default_name + cmds: + - echo "NAME={{.NAME}}" diff --git a/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-call-vars.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-call-vars.golden new file mode 100644 index 0000000000..8a11e1d5cd --- /dev/null +++ b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-call-vars.golden @@ -0,0 +1 @@ +NAME=from_caller diff --git a/website/src/docs/experiments/scoped-taskfiles.md b/website/src/docs/experiments/scoped-taskfiles.md index 7cee72e81f..ad349ac46b 100644 --- a/website/src/docs/experiments/scoped-taskfiles.md +++ b/website/src/docs/experiments/scoped-taskfiles.md @@ -98,8 +98,9 @@ still inherit variables from their parent. ### Example -```yaml -# Taskfile.yml +::: code-group + +```yaml [Taskfile.yml] version: '3' vars: @@ -110,8 +111,7 @@ includes: web: ./web ``` -```yaml -# api/Taskfile.yml +```yaml [api/Taskfile.yml] version: '3' vars: @@ -130,8 +130,7 @@ tasks: - echo "WEB_VAR={{.WEB_VAR}}" ``` -```yaml -# web/Taskfile.yml +```yaml [web/Taskfile.yml] version: '3' vars: @@ -150,10 +149,23 @@ tasks: - echo "API_VAR={{.API_VAR}}" ``` -## CLI Variables Priority +::: + +## Variable Priority -With this experiment, CLI variables (passed as `task foo VAR=value`) have the -highest priority and will override task-level variables. +With this experiment, variables follow a clear priority order (lowest to +highest): + +| Priority | Source | Description | +| -------- | ------------------------ | ---------------------------------------- | +| 1 | Root Taskfile vars | `vars:` in the root Taskfile | +| 2 | Include Taskfile vars | `vars:` in the included Taskfile | +| 3 | Include passthrough vars | `includes: name: vars:` from parent | +| 4 | Task vars | `tasks: name: vars:` in the task | +| 5 | Call vars | `task: name` with `vars:` when calling | +| 6 | CLI vars | `task foo VAR=value` on command line | + +### Example: Call vars override task vars ```yaml version: '3' @@ -161,14 +173,28 @@ version: '3' tasks: greet: vars: - NAME: from_task + NAME: default cmds: - echo "Hello {{.NAME}}" + + caller: + cmds: + - task: greet + vars: + NAME: from_caller ``` ```bash -# CLI vars now override task vars -TASK_X_SCOPED_TASKFILES=1 task greet NAME=cli +# Direct call uses task default +task greet +# Output: Hello default + +# Call vars override task vars +task caller +# Output: Hello from_caller + +# CLI vars override everything +task greet NAME=cli # Output: Hello cli ``` From 2810c267dd05521199ab681b4e21e88a380e1f63 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Wed, 14 Jan 2026 19:34:53 +0100 Subject: [PATCH 11/15] feat(scoped): refactor compiler, add nested includes, document flatten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- compiler.go | 330 ++++++++++++------ executor_test.go | 10 + testdata/scoped_taskfiles/inc_a/Taskfile.yml | 4 +- .../inc_a/nested/Taskfile.yml | 22 ++ testdata/scoped_taskfiles/inc_b/Taskfile.yml | 1 - .../TestScopedTaskfiles-legacy-default.golden | 2 - .../TestScopedTaskfiles-scoped-default.golden | 2 - ...copedTaskfiles-scoped-inheritance-a.golden | 1 - ...tScopedTaskfiles-scoped-isolation-b.golden | 1 - .../TestScopedTaskfiles-scoped-nested.golden | 5 + .../src/docs/experiments/scoped-taskfiles.md | 54 +++ 11 files changed, 326 insertions(+), 106 deletions(-) create mode 100644 testdata/scoped_taskfiles/inc_a/nested/Taskfile.yml create mode 100644 testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-nested.golden diff --git a/compiler.go b/compiler.go index 53d09d7863..4a10bf28a8 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) } +// 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) { - // 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() + 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() - if err != nil { - return nil, err - } + // 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) + // === 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 - } + // 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 + // 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 } - // 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)) } - 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 + static, err := c.HandleDynamicVar(newVar, dir, envSlice) + if err != nil { + return err } - return nil + 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 - } + // 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 - } + // === 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) + // 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 + } + + // Build parent chain (excluding root, already applied) + var parentChain []*ast.TaskfileVertex + currentURI := t.Location.Taskfile + for { + edges := predecessorMap[currentURI] + if len(edges) == 0 { + break + } + var parentURI string + for _, edge := range edges { + parentURI = edge.Source + break + } + if parentURI == rootVertex.URI { + break + } + 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 } - - // Apply task-level vars - if call != nil { - for k, v := range t.Vars.All() { - if err := taskRangeFunc(k, v); 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 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 } } + } + + // 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}) + // Inject env namespace into result + result.Set("env", ast.Var{Value: envMap}) - return result, nil + 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) - // === LEGACY MODE === - // Legacy behavior: use merged vars + // 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 574ea27a70..e4e125643d 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 f1ecadb2e0..ef652c6f88 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 0000000000..ef85e21703 --- /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 e1c5643e17..59c9c47cba 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 bc8e9c9d76..5989213412 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 c00cf2967a..5989213412 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 b047ffabae..c5f9d73547 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 47f57b19fd..eb8af5e3af 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 0000000000..c43bb088b6 --- /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 ad349ac46b..72fb652eae 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. + +::: From 17257a1c3150dd0731f5134a23ce4c4eeafa82fe Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Wed, 14 Jan 2026 19:36:03 +0100 Subject: [PATCH 12/15] chore: add scoped variables planning documents (to be reverted) --- PLAN_SCOPED_VARIABLES.md | 214 ++++++++++++++++ PLAN_SCOPED_VARIABLES_FINAL.md | 248 +++++++++++++++++++ PLAN_SCOPED_VARIABLES_V2_LAZY_DAG.md | 315 ++++++++++++++++++++++++ PLAN_SCOPED_VARIABLES_V3_LAZY_SIMPLE.md | 208 ++++++++++++++++ 4 files changed, 985 insertions(+) create mode 100644 PLAN_SCOPED_VARIABLES.md create mode 100644 PLAN_SCOPED_VARIABLES_FINAL.md create mode 100644 PLAN_SCOPED_VARIABLES_V2_LAZY_DAG.md create mode 100644 PLAN_SCOPED_VARIABLES_V3_LAZY_SIMPLE.md diff --git a/PLAN_SCOPED_VARIABLES.md b/PLAN_SCOPED_VARIABLES.md new file mode 100644 index 0000000000..e718b2143a --- /dev/null +++ b/PLAN_SCOPED_VARIABLES.md @@ -0,0 +1,214 @@ +# Plan : Scoped Variables pour les Includes de Taskfiles + +## Objectif + +Implémenter le scoping des variables pour les Taskfiles inclus. Les variables d'un include seront accessibles via `{{.namespace.VAR}}` au lieu d'être mergées globalement (problème actuel de conflits). + +## Décisions prises + +- **Accès** : Préfixe namespace (`{{.db.HOST}}`) +- **Conflits** : Préfixage obligatoire, pas de merge global +- **Requires/Preconditions** : Reporté à une phase ultérieure + +--- + +## Visibilité des variables + +| Relation | Accès | Mécanisme | +|----------|-------|-----------| +| Parent → Include | `{{.ns.VAR}}` | Le but principal | +| Include → Parent | Non (via `includes.vars:` si besoin) | Isolation pour réutilisabilité | +| Include → Sibling | Non (via parent comme passeur) | Évite les dépendances implicites | +| Include → Soi-même | `{{.VAR}}` | Naturel | + +**Exemple de passage explicite du parent vers l'include :** +```yaml +includes: + db: + taskfile: ./db/Taskfile.yml + vars: + HOST: "{{.MY_HOST}}" # Passage explicite des vars nécessaires +``` + +**Exemple de passage entre siblings via le parent :** +```yaml +includes: + a: ./a/Taskfile.yml + b: + taskfile: ./b/Taskfile.yml + vars: + A_VAR: "{{.a.SOME_VAR}}" # Parent passe les vars de a à b +``` + +--- + +## Étape 1 : Ajouter l'experiment flag + +**Fichier** : `experiments/experiments.go` + +```go +var ( + GentleForce Experiment + RemoteTaskfiles Experiment + EnvPrecedence Experiment + ScopedVariables Experiment // NEW +) + +func ParseWithConfig(dir string, config *ast.TaskRC) { + // ... + ScopedVariables = New("SCOPED_VARIABLES", config, 1) +} +``` + +Activation : `TASK_X_SCOPED_VARIABLES=1` + +--- + +## Étape 2 : Modifier la structure `Vars` + +**Fichier** : `taskfile/ast/vars.go` + +Ajouter un champ pour stocker les variables scopées par namespace : + +```go +type Vars struct { + om *orderedmap.OrderedMap[string, Var] + mutex sync.RWMutex + scoped map[string]*Vars // NEW: namespace -> vars + scopedMutex sync.RWMutex +} +``` + +Nouvelles méthodes à ajouter : + +```go +// SetScoped stocke les variables d'un namespace +func (vars *Vars) SetScoped(namespace string, scopedVars *Vars) + +// GetScoped récupère les variables d'un namespace +func (vars *Vars) GetScoped(namespace string) (*Vars, bool) + +// MergeScoped merge les variables dans un namespace au lieu de globalement +func (vars *Vars) MergeScoped(namespace string, other *Vars, include *Include) +``` + +Modifier `ToCacheMap()` pour inclure les namespaces comme maps imbriquées : + +```go +func (vars *Vars) ToCacheMap() map[string]any { + m := make(map[string]any, vars.Len()) + // Variables plates existantes + for k, v := range vars.All() { ... } + + // NEW: Ajouter les namespaces comme maps imbriquées + for namespace, scopedVars := range vars.scoped { + m[namespace] = scopedVars.ToCacheMap() + } + return m +} +``` + +Cela permet `{{.db.HOST}}` naturellement via Go templates. + +--- + +## Étape 3 : Modifier `Taskfile.Merge()` + +**Fichier** : `taskfile/ast/taskfile.go` + +```go +func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include) error { + // ... validations existantes ... + + if experiments.ScopedVariables.Enabled() && include != nil && !include.Flatten { + // Scoped merge : variables dans le namespace + t1.Vars.MergeScoped(include.Namespace, t2.Vars, include) + t1.Env.MergeScoped(include.Namespace, t2.Env, include) + } else { + // Comportement legacy : merge plat + t1.Vars.Merge(t2.Vars, include) + t1.Env.Merge(t2.Env, include) + } + + return t1.Tasks.Merge(t2.Tasks, include, t1.Vars) +} +``` + +--- + +## Étape 4 : Accès local sans préfixe pour les tasks de l'include + +**Fichier** : `compiler.go` + +Dans `getVariables()`, ajouter les variables du namespace courant pour qu'une task de l'include puisse y accéder sans préfixe : + +```go +// Si la task a un namespace, ajouter ses variables localement +if experiments.ScopedVariables.Enabled() && t != nil && t.Namespace != "" { + if scopedVars, ok := c.TaskfileVars.GetScoped(t.Namespace); ok { + for k, v := range scopedVars.All() { + result.Set(k, v) // Accessible via {{.VAR}} dans le namespace + } + } +} +``` + +--- + +## Étape 5 : Gérer les includes imbriqués + +**Fichier** : `taskfile/ast/graph.go` + +Pour les includes imbriqués (a→b→c), propager le path complet du namespace : +- `{{.a.VAR}}` - variable de a +- `{{.a.b.VAR}}` - variable de b (inclus par a) + +Option : ajouter `FullNamespacePath []string` dans `Include` ou calculer lors du merge. + +--- + +## Étape 6 : Gérer le cas `flatten: true` + +Déjà couvert par la condition `!include.Flatten` dans l'étape 3. Les includes aplatis conservent le comportement actuel (merge global). + +--- + +## Étape 7 : Tests + +**Nouveau fichier** : `testdata/includes/scoped_vars/` + +Scénarios de test : +1. **Accès cross-namespace** : `{{.db.HOST}}` depuis le Taskfile parent +2. **Accès local** : `{{.HOST}}` depuis une task de l'include `db` +3. **Override via include.vars** : Les vars passées dans `includes:` continuent de fonctionner +4. **Pas de conflits** : Deux includes avec même nom de variable ne se marchent pas dessus +5. **Flatten** : `flatten: true` conserve le merge global +6. **Includes imbriqués** : `{{.a.b.VAR}}` + +--- + +## Fichiers à modifier + +| Fichier | Changement | +|---------|------------| +| `experiments/experiments.go` | Ajouter `ScopedVariables` experiment | +| `taskfile/ast/vars.go` | Ajouter `scoped` map, `MergeScoped()`, modifier `ToCacheMap()` | +| `taskfile/ast/taskfile.go` | Conditionner le merge scoped/plat | +| `compiler.go` | Injecter les vars du namespace dans le scope local de la task | +| `taskfile/ast/graph.go` | (optionnel) Propager le path namespace pour les includes imbriqués | + +--- + +## Stratégie de migration + +1. **v3.x** : Feature derrière `TASK_X_SCOPED_VARIABLES=1` +2. **v4.0** : Activer par défaut (breaking change documenté) +3. **Migration** : Mettre à jour les templates `{{.VAR}}` → `{{.namespace.VAR}}` + +--- + +## Risques et considérations + +- **Breaking change** : Les Taskfiles existants utilisant des variables d'includes sans préfixe casseront +- **Performance** : Négligeable (ajout d'une map) +- **Complexité** : Modérée, mais bien isolée dans quelques fichiers diff --git a/PLAN_SCOPED_VARIABLES_FINAL.md b/PLAN_SCOPED_VARIABLES_FINAL.md new file mode 100644 index 0000000000..abdb6e099d --- /dev/null +++ b/PLAN_SCOPED_VARIABLES_FINAL.md @@ -0,0 +1,248 @@ +# Plan : Scoped Includes (Lazy DAG) + +## Objectif + +Scoper les variables des Taskfiles inclus via **lazy resolution** sur le DAG, avec **isolation stricte**. + +## Experiment Flag + +```bash +TASK_X_SCOPED_INCLUDES=1 +``` + +--- + +## Modèle de scopes + +### Priorité (croissante) + +``` +1. Environment ← Shell + CLI (task FOO=bar) +2. Root vars ← Taskfile racine +3. Include vars ← Chaîne d'héritage (parent → enfant) +4. Task vars ← Plus haut +``` + +### Visibilité (isolation stricte) + +| Depuis | Voit | Ne voit PAS | +|--------|------|-------------| +| Root | Ses vars | Vars des includes | +| Include | Ses vars + héritage parent | Vars des siblings | +| Task | Toute la chaîne d'héritage | - | + +### Partage de variables + +Pour partager des vars entre plusieurs Taskfiles, utiliser `flatten: true` : + +```yaml +includes: + common: + taskfile: ../common/Taskfile.yml + flatten: true # Merge global, pas de scoping +``` + +--- + +## Considérations spéciales + +### Variables dynamiques (`sh:`) + +```yaml +vars: + VERSION: + sh: git describe --tags +``` + +- Exécutées dans le `Dir` de leur Taskfile d'origine +- Le champ `Var.Dir` stocke le répertoire d'exécution +- Cache des résultats pour éviter les re-exécutions + +### Defer + +- Utilise le même cache de variables résolu +- Pas d'impact sur l'implémentation + +### Ordre de résolution + +```yaml +vars: + A: "hello" + B: "{{.A}} world" + C: + sh: echo "{{.B}}" +``` + +- Variables résolues dans l'ordre de déclaration +- Chaîne d'héritage résolue AVANT les vars locales + +--- + +## Architecture + +### Avant (merge global) + +``` +Reader.Read() → TaskfileGraph + ↓ +TaskfileGraph.Merge() → Taskfile unique + - Vars mergées globalement (last-one-wins) + ↓ +Compiler.TaskfileVars = Toutes les vars mergées +``` + +### Après (lazy DAG) + +``` +Reader.Read() → TaskfileGraph + ↓ +TaskfileGraph.Merge() → Taskfile racine + - Vars NON mergées (restent dans le DAG) + - Tasks mergées (comme avant) + ↓ +Executor.Graph = DAG préservé + ↓ +Compiler.getVariablesLazy(task) → Traverse le DAG +``` + +--- + +## Implémentation + +### Étape 1 : Experiment flag + +**Fichier** : `experiments/experiments.go` + +```go +var ScopedIncludes Experiment + +func ParseWithConfig(dir string, config *ast.TaskRC) { + // ... + ScopedIncludes = New("SCOPED_INCLUDES", config, 1) +} +``` + +### Étape 2 : Stocker le DAG dans l'Executor + +**Fichier** : `executor.go` + +```go +type Executor struct { + // ... + Graph *ast.TaskfileGraph +} +``` + +**Fichier** : `setup.go` + +```go +func (e *Executor) readTaskfile(node taskfile.Node) error { + graph, err := reader.Read(ctx, node) + e.Graph = graph + e.Taskfile, err = graph.Merge() + return nil +} +``` + +### Étape 3 : Ne pas merger les vars (si experiment ON) + +**Fichier** : `taskfile/ast/taskfile.go` + +```go +func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include, experimentEnabled bool) error { + if !experimentEnabled || include.Flatten { + // Legacy ou flatten : merge global + t1.Vars.Merge(t2.Vars, include) + t1.Env.Merge(t2.Env, include) + } + // Sinon : vars restent dans le DAG + + return t1.Tasks.Merge(t2.Tasks, include, t1.Vars) +} +``` + +### Étape 4 : Helpers pour le DAG + +**Fichier** : `taskfile/ast/graph.go` + +```go +func (tfg *TaskfileGraph) Root() (*TaskfileVertex, error) + +func (tfg *TaskfileGraph) GetVertexByNamespace(namespace string) (*TaskfileVertex, error) +``` + +### Étape 5 : Résolution lazy dans le Compiler + +**Fichier** : `compiler.go` + +```go +type Compiler struct { + // ... + Graph *ast.TaskfileGraph + varsCache map[string]*ast.Vars // Cache par namespace +} + +func (c *Compiler) getVariables(t *ast.Task, call *Call, eval bool) (*ast.Vars, error) { + if experiments.ScopedIncludes.Enabled() { + return c.getVariablesLazy(t, call, eval) + } + // Legacy... +} + +func (c *Compiler) getVariablesLazy(t *ast.Task, call *Call, eval bool) (*ast.Vars, error) { + result := env.GetEnviron() + + // 1. Special vars + // 2. Root vars (depuis DAG.Root()) + // 3. Include chain vars (traverse DAG selon t.Namespace) + // 4. CLI vars (call.Vars) + // 5. Task vars (t.Vars) + + return result, nil +} +``` + +--- + +## Fichiers à modifier + +| Fichier | Changement | +|---------|------------| +| `experiments/experiments.go` | Ajouter `ScopedIncludes` | +| `executor.go` | Ajouter `Graph` | +| `setup.go` | Stocker le DAG | +| `taskfile/ast/taskfile.go` | Ne pas merger vars si experiment ON | +| `taskfile/ast/graph.go` | Helpers `Root()`, `GetVertexByNamespace()` | +| `compiler.go` | `getVariablesLazy()` + cache | + +--- + +## Tests + +### Variables +1. Héritage : include voit vars du parent +2. Override : include peut override une var du parent +3. Isolation : parent ne voit PAS vars de l'include +4. Siblings : includes ne se voient pas entre eux +5. Chaîne : root → a → b fonctionne +6. Flatten : `flatten: true` = merge global +7. Legacy : flag OFF = comportement inchangé + +### Variables dynamiques +8. `sh:` exécuté dans le Dir de l'include +9. Var dynamique référençant une var héritée +10. Cache des résultats + +### Defer +11. Defer a accès aux vars scopées + +--- + +## Ordre des commits + +1. `feat(experiments): add SCOPED_INCLUDES experiment` +2. `feat(executor): store TaskfileGraph for lazy resolution` +3. `feat(graph): add Root() and GetVertexByNamespace() helpers` +4. `feat(taskfile): skip var merge when SCOPED_INCLUDES enabled` +5. `feat(compiler): implement lazy variable resolution` +6. `test: add scoped includes variable tests` diff --git a/PLAN_SCOPED_VARIABLES_V2_LAZY_DAG.md b/PLAN_SCOPED_VARIABLES_V2_LAZY_DAG.md new file mode 100644 index 0000000000..e0e68167ef --- /dev/null +++ b/PLAN_SCOPED_VARIABLES_V2_LAZY_DAG.md @@ -0,0 +1,315 @@ +# Plan V2 : Scoped Variables avec Lazy DAG + +## Objectif + +Implémenter le scoping des variables pour les Taskfiles inclus avec **lazy resolution via DAG** et **accès cross-namespace**. + +## Décisions prises + +- **Architecture** : Lazy resolution via DAG (pas de merge global des variables) +- **Accès cross-namespace** : `{{.namespace.VAR}}` pour accéder aux variables d'un include +- **Isolation** : Un include ne voit pas les vars du parent (sauf passage explicite via `includes.vars:`) +- **Requires/Preconditions** : Reporté à une phase ultérieure + +--- + +## Comparaison des approches + +| Aspect | Plan V1 (scoped merge) | Plan V2 (lazy DAG) | +|--------|------------------------|---------------------| +| Merge des vars | Oui, dans map scopée | Non, préservées dans le DAG | +| Résolution | Build time | Runtime (lazy) | +| Performance | Résout tout | Résout seulement le nécessaire | +| Complexité | Modérée | Plus élevée | +| Accès cross-namespace | `{{.ns.VAR}}` via map | `{{.ns.VAR}}` via traversée DAG | + +--- + +## Modèle de scopes (priorité croissante) + +``` +1. Environment Scope (plus bas) + - Variables d'environnement shell + - Variables CLI (task VAR=value) + +2. Entrypoint Scope + - Dotenv globaux du Taskfile racine + - Vars globales du Taskfile racine + +3. Include Scope(s) (dans l'ordre d'inclusion) + - Variables passées via `includes.vars:` + - Dotenv de l'include (futur) + - Vars globales de l'include + +4. Task Scope (plus haut) + - Variables passées via une autre task + - Dotenv au niveau task + - Vars au niveau task +``` + +**Règle** : Chaque scope hérite du précédent et peut override. + +--- + +## Visibilité des variables + +| Depuis... | Accès à... | Mécanisme | +|-----------|------------|-----------| +| Parent | Ses propres vars | `{{.VAR}}` | +| Parent | Vars d'un include | `{{.namespace.VAR}}` (traversée DAG) | +| Include | Ses propres vars | `{{.VAR}}` | +| Include | Vars du parent | Non (via `includes.vars:` si besoin) | +| Include | Vars d'un sibling | Non (via parent comme passeur) | + +--- + +## Architecture technique + +### Avant (merge global) + +``` +Reader.Read() → TaskfileGraph + ↓ +TaskfileGraph.Merge() → Taskfile unique + - Vars mergées globalement (last-one-wins) + - Tasks mergées avec namespace + ↓ +Executor.Taskfile = Taskfile unique + ↓ +Compiler.TaskfileVars = Taskfile.Vars (toutes mergées) +``` + +### Après (lazy DAG) + +``` +Reader.Read() → TaskfileGraph + ↓ +TaskfileGraph.Merge() → Taskfile racine + - Vars NON mergées (restent dans chaque vertex du DAG) + - Tasks mergées avec namespace (comme avant) + ↓ +Executor.Taskfile = Taskfile racine +Executor.Graph = TaskfileGraph (NOUVEAU) + ↓ +Compiler.Graph = TaskfileGraph +Compiler.resolveVariables(task) → traverse le DAG lazy +``` + +--- + +## Étapes d'implémentation + +### Étape 1 : Ajouter l'experiment flag + +**Fichier** : `experiments/experiments.go` + +```go +var ScopedVariables Experiment + +func ParseWithConfig(dir string, config *ast.TaskRC) { + ScopedVariables = New("SCOPED_VARIABLES", config, 1) +} +``` + +--- + +### Étape 2 : Préserver le DAG dans l'Executor + +**Fichier** : `executor.go` + `setup.go` + +```go +type Executor struct { + // ... existant ... + Taskfile *ast.Taskfile + Graph *ast.TaskfileGraph // NOUVEAU +} + +func (e *Executor) readTaskfile(node taskfile.Node) error { + graph, err := reader.Read(ctx, node) + e.Graph = graph // Stocker le DAG + e.Taskfile, err = graph.Merge() // Merge pour les tasks + return nil +} +``` + +--- + +### Étape 3 : Modifier le merge pour ne pas merger les variables + +**Fichier** : `taskfile/ast/taskfile.go` + +```go +func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include) error { + // ... validations existantes ... + + if experiments.ScopedVariables.Enabled() { + // NE PAS merger les variables - elles restent dans le DAG + // Optionnel : merger seulement les vars passées via include.Vars + } else { + // Comportement legacy + t1.Vars.Merge(t2.Vars, include) + t1.Env.Merge(t2.Env, include) + } + + return t1.Tasks.Merge(t2.Tasks, include, t1.Vars) +} +``` + +--- + +### Étape 4 : Passer le DAG au Compiler + +**Fichier** : `compiler.go` + +```go +type Compiler struct { + // ... existant ... + Graph *ast.TaskfileGraph // NOUVEAU +} +``` + +--- + +### Étape 5 : Résolution lazy des variables + +**Fichier** : `compiler.go` + +Nouvelle méthode pour résoudre les variables en traversant le DAG : + +```go +func (c *Compiler) resolveVariablesFromGraph(t *ast.Task) (*ast.Vars, error) { + result := env.GetEnviron() + + // 1. Variables spéciales + specialVars, _ := c.getSpecialVars(t, call) + + // 2. Variables du Taskfile racine (entrypoint scope) + rootVars := c.getRootTaskfileVars() + + // 3. Variables de la chaîne d'includes jusqu'à cette task (include scope) + // Traverser le DAG en suivant le namespace de la task + includeChainVars := c.getIncludeChainVars(t.Namespace) + + // 4. Variables de la task elle-même (task scope) + taskVars := t.Vars + + // Merger dans l'ordre de priorité + // ... + + return result, nil +} + +func (c *Compiler) getIncludeChainVars(namespace string) *ast.Vars { + // Trouver le vertex de l'include dans le DAG + // Récupérer ses variables + // Récursivement remonter si includes imbriqués +} +``` + +--- + +### Étape 6 : Supporter `{{.namespace.VAR}}` dans le templater + +**Fichier** : `internal/templater/templater.go` + +Deux approches possibles : + +**A) Pré-populer une map avec les namespaces accessibles :** +```go +func (c *Compiler) buildTemplateVars(t *ast.Task) map[string]any { + m := make(map[string]any) + + // Variables locales + for k, v := range localVars { + m[k] = v + } + + // Namespaces accessibles (depuis la perspective de cette task) + for _, include := range c.getAccessibleIncludes(t) { + m[include.Namespace] = c.getIncludeVarsAsMap(include) + } + + return m +} +``` + +**B) Custom template function :** +```go +// {{ns "db" "HOST"}} au lieu de {{.db.HOST}} +funcMap := template.FuncMap{ + "ns": func(namespace, varName string) any { + return c.resolveNamespacedVar(namespace, varName) + }, +} +``` + +**Recommandation** : Approche A pour garder la syntaxe `{{.db.HOST}}` + +--- + +### Étape 7 : Gérer les includes imbriqués + +Pour `root → a → b`, les variables doivent être accessibles comme : +- `{{.a.VAR}}` depuis root +- `{{.a.b.VAR}}` depuis root (via a) +- `{{.b.VAR}}` depuis a +- `{{.VAR}}` depuis b (ses propres vars) + +Le namespace complet est déjà tracké dans `Task.Namespace` (e.g., `"a:b:taskname"`). + +--- + +### Étape 8 : Tests + +**Nouveau répertoire** : `testdata/includes/scoped_vars/` + +Scénarios : +1. Accès cross-namespace : `{{.db.HOST}}` depuis root +2. Accès local : `{{.HOST}}` depuis l'include +3. Isolation : include ne voit pas les vars du parent +4. Override via `includes.vars:` +5. Pas de conflits entre includes +6. `flatten: true` conserve le comportement legacy +7. Includes imbriqués : `{{.a.b.VAR}}` +8. Variables dynamiques (`sh:`) dans un include + +--- + +## Fichiers à modifier + +| Fichier | Changement | +|---------|------------| +| `experiments/experiments.go` | Ajouter `ScopedVariables` | +| `executor.go` | Ajouter champ `Graph` | +| `setup.go` | Stocker le DAG dans l'Executor | +| `taskfile/ast/taskfile.go` | Conditionner le merge des vars | +| `compiler.go` | Traversée lazy du DAG, accès au Graph | +| `internal/templater/templater.go` | (optionnel) Support `{{.ns.VAR}}` | + +--- + +## Stratégie de migration + +1. **v3.x** : Feature derrière `TASK_X_SCOPED_VARIABLES=1` +2. **v4.0** : Activer par défaut (breaking change documenté) +3. **Migration** : + - Les vars d'includes ne sont plus accessibles directement → utiliser `{{.namespace.VAR}}` + - Les vars du parent ne sont plus visibles dans l'include → passer via `includes.vars:` + +--- + +## Avantages de cette approche + +1. **Performance** : Résolution lazy, on ne calcule que ce qui est nécessaire +2. **Clarté** : Chaque scope est explicite +3. **Pas de conflits** : Les variables ne se marchent plus dessus +4. **Réutilisabilité** : Un include peut être utilisé dans différents contextes +5. **Utilise l'existant** : Le DAG existe déjà, on l'exploite mieux + +--- + +## Risques et considérations + +- **Breaking change** : Important - comportement fondamentalement différent +- **Complexité** : La traversée du DAG est plus complexe que le merge +- **Debug** : Plus difficile de comprendre d'où vient une variable (ajouter du logging) diff --git a/PLAN_SCOPED_VARIABLES_V3_LAZY_SIMPLE.md b/PLAN_SCOPED_VARIABLES_V3_LAZY_SIMPLE.md new file mode 100644 index 0000000000..dd1a38b90e --- /dev/null +++ b/PLAN_SCOPED_VARIABLES_V3_LAZY_SIMPLE.md @@ -0,0 +1,208 @@ +# Plan V3 : Lazy DAG avec Isolation Stricte + +## Objectif + +Implémenter le scoping des variables via **lazy resolution** et **isolation stricte** (pas de `{{.namespace.VAR}}`). + +## Décisions + +- **Architecture** : Lazy resolution via DAG +- **Isolation stricte** : Pas d'accès cross-namespace +- **Héritage** : Include hérite du parent, pas l'inverse +- **Experiment** : `TASK_X_SCOPED_VARIABLES=1` + +--- + +## Modèle de scopes + +``` +Environment → Shell + CLI vars + ↓ hérite +Entrypoint → Root Taskfile vars + ↓ hérite +Include(s) → Vars de chaque include (dans l'ordre) + ↓ hérite +Task → Vars de la task +``` + +Chaque scope hérite du précédent et peut override. + +--- + +## Visibilité + +| Depuis | Voit | Ne voit PAS | +|--------|------|-------------| +| Root | Ses vars | Vars des includes | +| Include | Ses vars + parent | Vars des siblings | +| Task | Toute la chaîne | - | + +**Communication unidirectionnelle** : parent → enfant via `includes.vars:` + +--- + +## Changements requis + +### 1. Experiment flag + +**Fichier** : `experiments/experiments.go` + +```go +var ScopedVariables Experiment + +func ParseWithConfig(dir string, config *ast.TaskRC) { + ScopedVariables = New("SCOPED_VARIABLES", config, 1) +} +``` + +--- + +### 2. Préserver le DAG + +**Fichier** : `executor.go` + +```go +type Executor struct { + Taskfile *ast.Taskfile + Graph *ast.TaskfileGraph // NOUVEAU +} +``` + +**Fichier** : `setup.go` + +```go +func (e *Executor) readTaskfile(node taskfile.Node) error { + graph, err := reader.Read(ctx, node) + e.Graph = graph + e.Taskfile, err = graph.Merge() + return nil +} +``` + +--- + +### 3. Ne pas merger les vars + +**Fichier** : `taskfile/ast/taskfile.go` + +```go +func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include) error { + if !experiments.ScopedVariables.Enabled() { + // Legacy : merge global + t1.Vars.Merge(t2.Vars, include) + t1.Env.Merge(t2.Env, include) + } + // Les vars restent dans le DAG, pas de merge + + return t1.Tasks.Merge(t2.Tasks, include, t1.Vars) +} +``` + +--- + +### 4. Résolution lazy dans le Compiler + +**Fichier** : `compiler.go` + +```go +type Compiler struct { + Graph *ast.TaskfileGraph // NOUVEAU +} + +func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*ast.Vars, error) { + if experiments.ScopedVariables.Enabled() { + return c.getVariablesLazy(t, call, evaluateShVars) + } + // Legacy behavior... +} + +func (c *Compiler) getVariablesLazy(t *ast.Task, call *Call, evaluateShVars bool) (*ast.Vars, error) { + result := env.GetEnviron() + + // 1. Special vars + specialVars, _ := c.getSpecialVars(t, call) + + // 2. Root taskfile vars (depuis le DAG) + rootVertex := c.Graph.Root() + for k, v := range rootVertex.Taskfile.Vars.All() { + result.Set(k, v) + } + + // 3. Include chain vars (traverser le DAG jusqu'à cette task) + if t != nil && t.Namespace != "" { + includeVars := c.resolveIncludeChain(t.Namespace) + for k, v := range includeVars.All() { + result.Set(k, v) + } + } + + // 4. Task vars + if t != nil { + for k, v := range t.Vars.All() { + result.Set(k, v) + } + } + + return result, nil +} + +func (c *Compiler) resolveIncludeChain(namespace string) *ast.Vars { + // namespace = "a:b:taskname" → trouver les vars de a, puis b + // Traverser le DAG en suivant les edges +} +``` + +--- + +### 5. Helper pour trouver un vertex par namespace + +**Fichier** : `taskfile/ast/graph.go` + +```go +func (tfg *TaskfileGraph) GetVertexByNamespace(namespace string) (*TaskfileVertex, error) { + // Trouver le vertex correspondant au namespace +} + +func (tfg *TaskfileGraph) Root() *TaskfileVertex { + // Retourner le vertex racine +} +``` + +--- + +## Fichiers à modifier + +| Fichier | Changement | +|---------|------------| +| `experiments/experiments.go` | Ajouter `ScopedVariables` | +| `executor.go` | Ajouter `Graph` | +| `setup.go` | Stocker le DAG | +| `taskfile/ast/taskfile.go` | Conditionner le merge | +| `taskfile/ast/graph.go` | Helpers pour accès au DAG | +| `compiler.go` | `getVariablesLazy()` | + +--- + +## Tests + +1. **Héritage** : Include voit les vars du parent +2. **Override** : Include peut override une var du parent +3. **Isolation** : Parent ne voit pas les vars de l'include +4. **Siblings** : Un include ne voit pas les vars d'un autre +5. **Chaîne** : `root → a → b` - b voit les vars de a et root +6. **Legacy** : Sans le flag, comportement inchangé + +--- + +## Avantages + +1. **Simple** : Pas de mapping cross-namespace +2. **Performance** : Lazy, résout que le nécessaire +3. **Clair** : Héritage linéaire, facile à comprendre +4. **Safe** : Experiment flag, rollback facile + +--- + +## Inconvénient + +Le parent ne peut pas "lire" les vars d'un include. Si nécessaire, il faudrait ajouter `{{.namespace.VAR}}` plus tard (V2). From c8efbc2f4aa2f24f1d2e1fa732d086d9e362ce5e Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 25 Jan 2026 19:08:45 +0100 Subject: [PATCH 13/15] docs(experiments): reference issue #2035 in scoped taskfiles doc --- PLAN_SCOPED_VARIABLES.md | 214 ------------ PLAN_SCOPED_VARIABLES_FINAL.md | 248 -------------- PLAN_SCOPED_VARIABLES_V2_LAZY_DAG.md | 315 ------------------ PLAN_SCOPED_VARIABLES_V3_LAZY_SIMPLE.md | 208 ------------ compiler.go | 49 --- .../src/docs/experiments/scoped-taskfiles.md | 4 +- 6 files changed, 2 insertions(+), 1036 deletions(-) delete mode 100644 PLAN_SCOPED_VARIABLES.md delete mode 100644 PLAN_SCOPED_VARIABLES_FINAL.md delete mode 100644 PLAN_SCOPED_VARIABLES_V2_LAZY_DAG.md delete mode 100644 PLAN_SCOPED_VARIABLES_V3_LAZY_SIMPLE.md diff --git a/PLAN_SCOPED_VARIABLES.md b/PLAN_SCOPED_VARIABLES.md deleted file mode 100644 index e718b2143a..0000000000 --- a/PLAN_SCOPED_VARIABLES.md +++ /dev/null @@ -1,214 +0,0 @@ -# Plan : Scoped Variables pour les Includes de Taskfiles - -## Objectif - -Implémenter le scoping des variables pour les Taskfiles inclus. Les variables d'un include seront accessibles via `{{.namespace.VAR}}` au lieu d'être mergées globalement (problème actuel de conflits). - -## Décisions prises - -- **Accès** : Préfixe namespace (`{{.db.HOST}}`) -- **Conflits** : Préfixage obligatoire, pas de merge global -- **Requires/Preconditions** : Reporté à une phase ultérieure - ---- - -## Visibilité des variables - -| Relation | Accès | Mécanisme | -|----------|-------|-----------| -| Parent → Include | `{{.ns.VAR}}` | Le but principal | -| Include → Parent | Non (via `includes.vars:` si besoin) | Isolation pour réutilisabilité | -| Include → Sibling | Non (via parent comme passeur) | Évite les dépendances implicites | -| Include → Soi-même | `{{.VAR}}` | Naturel | - -**Exemple de passage explicite du parent vers l'include :** -```yaml -includes: - db: - taskfile: ./db/Taskfile.yml - vars: - HOST: "{{.MY_HOST}}" # Passage explicite des vars nécessaires -``` - -**Exemple de passage entre siblings via le parent :** -```yaml -includes: - a: ./a/Taskfile.yml - b: - taskfile: ./b/Taskfile.yml - vars: - A_VAR: "{{.a.SOME_VAR}}" # Parent passe les vars de a à b -``` - ---- - -## Étape 1 : Ajouter l'experiment flag - -**Fichier** : `experiments/experiments.go` - -```go -var ( - GentleForce Experiment - RemoteTaskfiles Experiment - EnvPrecedence Experiment - ScopedVariables Experiment // NEW -) - -func ParseWithConfig(dir string, config *ast.TaskRC) { - // ... - ScopedVariables = New("SCOPED_VARIABLES", config, 1) -} -``` - -Activation : `TASK_X_SCOPED_VARIABLES=1` - ---- - -## Étape 2 : Modifier la structure `Vars` - -**Fichier** : `taskfile/ast/vars.go` - -Ajouter un champ pour stocker les variables scopées par namespace : - -```go -type Vars struct { - om *orderedmap.OrderedMap[string, Var] - mutex sync.RWMutex - scoped map[string]*Vars // NEW: namespace -> vars - scopedMutex sync.RWMutex -} -``` - -Nouvelles méthodes à ajouter : - -```go -// SetScoped stocke les variables d'un namespace -func (vars *Vars) SetScoped(namespace string, scopedVars *Vars) - -// GetScoped récupère les variables d'un namespace -func (vars *Vars) GetScoped(namespace string) (*Vars, bool) - -// MergeScoped merge les variables dans un namespace au lieu de globalement -func (vars *Vars) MergeScoped(namespace string, other *Vars, include *Include) -``` - -Modifier `ToCacheMap()` pour inclure les namespaces comme maps imbriquées : - -```go -func (vars *Vars) ToCacheMap() map[string]any { - m := make(map[string]any, vars.Len()) - // Variables plates existantes - for k, v := range vars.All() { ... } - - // NEW: Ajouter les namespaces comme maps imbriquées - for namespace, scopedVars := range vars.scoped { - m[namespace] = scopedVars.ToCacheMap() - } - return m -} -``` - -Cela permet `{{.db.HOST}}` naturellement via Go templates. - ---- - -## Étape 3 : Modifier `Taskfile.Merge()` - -**Fichier** : `taskfile/ast/taskfile.go` - -```go -func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include) error { - // ... validations existantes ... - - if experiments.ScopedVariables.Enabled() && include != nil && !include.Flatten { - // Scoped merge : variables dans le namespace - t1.Vars.MergeScoped(include.Namespace, t2.Vars, include) - t1.Env.MergeScoped(include.Namespace, t2.Env, include) - } else { - // Comportement legacy : merge plat - t1.Vars.Merge(t2.Vars, include) - t1.Env.Merge(t2.Env, include) - } - - return t1.Tasks.Merge(t2.Tasks, include, t1.Vars) -} -``` - ---- - -## Étape 4 : Accès local sans préfixe pour les tasks de l'include - -**Fichier** : `compiler.go` - -Dans `getVariables()`, ajouter les variables du namespace courant pour qu'une task de l'include puisse y accéder sans préfixe : - -```go -// Si la task a un namespace, ajouter ses variables localement -if experiments.ScopedVariables.Enabled() && t != nil && t.Namespace != "" { - if scopedVars, ok := c.TaskfileVars.GetScoped(t.Namespace); ok { - for k, v := range scopedVars.All() { - result.Set(k, v) // Accessible via {{.VAR}} dans le namespace - } - } -} -``` - ---- - -## Étape 5 : Gérer les includes imbriqués - -**Fichier** : `taskfile/ast/graph.go` - -Pour les includes imbriqués (a→b→c), propager le path complet du namespace : -- `{{.a.VAR}}` - variable de a -- `{{.a.b.VAR}}` - variable de b (inclus par a) - -Option : ajouter `FullNamespacePath []string` dans `Include` ou calculer lors du merge. - ---- - -## Étape 6 : Gérer le cas `flatten: true` - -Déjà couvert par la condition `!include.Flatten` dans l'étape 3. Les includes aplatis conservent le comportement actuel (merge global). - ---- - -## Étape 7 : Tests - -**Nouveau fichier** : `testdata/includes/scoped_vars/` - -Scénarios de test : -1. **Accès cross-namespace** : `{{.db.HOST}}` depuis le Taskfile parent -2. **Accès local** : `{{.HOST}}` depuis une task de l'include `db` -3. **Override via include.vars** : Les vars passées dans `includes:` continuent de fonctionner -4. **Pas de conflits** : Deux includes avec même nom de variable ne se marchent pas dessus -5. **Flatten** : `flatten: true` conserve le merge global -6. **Includes imbriqués** : `{{.a.b.VAR}}` - ---- - -## Fichiers à modifier - -| Fichier | Changement | -|---------|------------| -| `experiments/experiments.go` | Ajouter `ScopedVariables` experiment | -| `taskfile/ast/vars.go` | Ajouter `scoped` map, `MergeScoped()`, modifier `ToCacheMap()` | -| `taskfile/ast/taskfile.go` | Conditionner le merge scoped/plat | -| `compiler.go` | Injecter les vars du namespace dans le scope local de la task | -| `taskfile/ast/graph.go` | (optionnel) Propager le path namespace pour les includes imbriqués | - ---- - -## Stratégie de migration - -1. **v3.x** : Feature derrière `TASK_X_SCOPED_VARIABLES=1` -2. **v4.0** : Activer par défaut (breaking change documenté) -3. **Migration** : Mettre à jour les templates `{{.VAR}}` → `{{.namespace.VAR}}` - ---- - -## Risques et considérations - -- **Breaking change** : Les Taskfiles existants utilisant des variables d'includes sans préfixe casseront -- **Performance** : Négligeable (ajout d'une map) -- **Complexité** : Modérée, mais bien isolée dans quelques fichiers diff --git a/PLAN_SCOPED_VARIABLES_FINAL.md b/PLAN_SCOPED_VARIABLES_FINAL.md deleted file mode 100644 index abdb6e099d..0000000000 --- a/PLAN_SCOPED_VARIABLES_FINAL.md +++ /dev/null @@ -1,248 +0,0 @@ -# Plan : Scoped Includes (Lazy DAG) - -## Objectif - -Scoper les variables des Taskfiles inclus via **lazy resolution** sur le DAG, avec **isolation stricte**. - -## Experiment Flag - -```bash -TASK_X_SCOPED_INCLUDES=1 -``` - ---- - -## Modèle de scopes - -### Priorité (croissante) - -``` -1. Environment ← Shell + CLI (task FOO=bar) -2. Root vars ← Taskfile racine -3. Include vars ← Chaîne d'héritage (parent → enfant) -4. Task vars ← Plus haut -``` - -### Visibilité (isolation stricte) - -| Depuis | Voit | Ne voit PAS | -|--------|------|-------------| -| Root | Ses vars | Vars des includes | -| Include | Ses vars + héritage parent | Vars des siblings | -| Task | Toute la chaîne d'héritage | - | - -### Partage de variables - -Pour partager des vars entre plusieurs Taskfiles, utiliser `flatten: true` : - -```yaml -includes: - common: - taskfile: ../common/Taskfile.yml - flatten: true # Merge global, pas de scoping -``` - ---- - -## Considérations spéciales - -### Variables dynamiques (`sh:`) - -```yaml -vars: - VERSION: - sh: git describe --tags -``` - -- Exécutées dans le `Dir` de leur Taskfile d'origine -- Le champ `Var.Dir` stocke le répertoire d'exécution -- Cache des résultats pour éviter les re-exécutions - -### Defer - -- Utilise le même cache de variables résolu -- Pas d'impact sur l'implémentation - -### Ordre de résolution - -```yaml -vars: - A: "hello" - B: "{{.A}} world" - C: - sh: echo "{{.B}}" -``` - -- Variables résolues dans l'ordre de déclaration -- Chaîne d'héritage résolue AVANT les vars locales - ---- - -## Architecture - -### Avant (merge global) - -``` -Reader.Read() → TaskfileGraph - ↓ -TaskfileGraph.Merge() → Taskfile unique - - Vars mergées globalement (last-one-wins) - ↓ -Compiler.TaskfileVars = Toutes les vars mergées -``` - -### Après (lazy DAG) - -``` -Reader.Read() → TaskfileGraph - ↓ -TaskfileGraph.Merge() → Taskfile racine - - Vars NON mergées (restent dans le DAG) - - Tasks mergées (comme avant) - ↓ -Executor.Graph = DAG préservé - ↓ -Compiler.getVariablesLazy(task) → Traverse le DAG -``` - ---- - -## Implémentation - -### Étape 1 : Experiment flag - -**Fichier** : `experiments/experiments.go` - -```go -var ScopedIncludes Experiment - -func ParseWithConfig(dir string, config *ast.TaskRC) { - // ... - ScopedIncludes = New("SCOPED_INCLUDES", config, 1) -} -``` - -### Étape 2 : Stocker le DAG dans l'Executor - -**Fichier** : `executor.go` - -```go -type Executor struct { - // ... - Graph *ast.TaskfileGraph -} -``` - -**Fichier** : `setup.go` - -```go -func (e *Executor) readTaskfile(node taskfile.Node) error { - graph, err := reader.Read(ctx, node) - e.Graph = graph - e.Taskfile, err = graph.Merge() - return nil -} -``` - -### Étape 3 : Ne pas merger les vars (si experiment ON) - -**Fichier** : `taskfile/ast/taskfile.go` - -```go -func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include, experimentEnabled bool) error { - if !experimentEnabled || include.Flatten { - // Legacy ou flatten : merge global - t1.Vars.Merge(t2.Vars, include) - t1.Env.Merge(t2.Env, include) - } - // Sinon : vars restent dans le DAG - - return t1.Tasks.Merge(t2.Tasks, include, t1.Vars) -} -``` - -### Étape 4 : Helpers pour le DAG - -**Fichier** : `taskfile/ast/graph.go` - -```go -func (tfg *TaskfileGraph) Root() (*TaskfileVertex, error) - -func (tfg *TaskfileGraph) GetVertexByNamespace(namespace string) (*TaskfileVertex, error) -``` - -### Étape 5 : Résolution lazy dans le Compiler - -**Fichier** : `compiler.go` - -```go -type Compiler struct { - // ... - Graph *ast.TaskfileGraph - varsCache map[string]*ast.Vars // Cache par namespace -} - -func (c *Compiler) getVariables(t *ast.Task, call *Call, eval bool) (*ast.Vars, error) { - if experiments.ScopedIncludes.Enabled() { - return c.getVariablesLazy(t, call, eval) - } - // Legacy... -} - -func (c *Compiler) getVariablesLazy(t *ast.Task, call *Call, eval bool) (*ast.Vars, error) { - result := env.GetEnviron() - - // 1. Special vars - // 2. Root vars (depuis DAG.Root()) - // 3. Include chain vars (traverse DAG selon t.Namespace) - // 4. CLI vars (call.Vars) - // 5. Task vars (t.Vars) - - return result, nil -} -``` - ---- - -## Fichiers à modifier - -| Fichier | Changement | -|---------|------------| -| `experiments/experiments.go` | Ajouter `ScopedIncludes` | -| `executor.go` | Ajouter `Graph` | -| `setup.go` | Stocker le DAG | -| `taskfile/ast/taskfile.go` | Ne pas merger vars si experiment ON | -| `taskfile/ast/graph.go` | Helpers `Root()`, `GetVertexByNamespace()` | -| `compiler.go` | `getVariablesLazy()` + cache | - ---- - -## Tests - -### Variables -1. Héritage : include voit vars du parent -2. Override : include peut override une var du parent -3. Isolation : parent ne voit PAS vars de l'include -4. Siblings : includes ne se voient pas entre eux -5. Chaîne : root → a → b fonctionne -6. Flatten : `flatten: true` = merge global -7. Legacy : flag OFF = comportement inchangé - -### Variables dynamiques -8. `sh:` exécuté dans le Dir de l'include -9. Var dynamique référençant une var héritée -10. Cache des résultats - -### Defer -11. Defer a accès aux vars scopées - ---- - -## Ordre des commits - -1. `feat(experiments): add SCOPED_INCLUDES experiment` -2. `feat(executor): store TaskfileGraph for lazy resolution` -3. `feat(graph): add Root() and GetVertexByNamespace() helpers` -4. `feat(taskfile): skip var merge when SCOPED_INCLUDES enabled` -5. `feat(compiler): implement lazy variable resolution` -6. `test: add scoped includes variable tests` diff --git a/PLAN_SCOPED_VARIABLES_V2_LAZY_DAG.md b/PLAN_SCOPED_VARIABLES_V2_LAZY_DAG.md deleted file mode 100644 index e0e68167ef..0000000000 --- a/PLAN_SCOPED_VARIABLES_V2_LAZY_DAG.md +++ /dev/null @@ -1,315 +0,0 @@ -# Plan V2 : Scoped Variables avec Lazy DAG - -## Objectif - -Implémenter le scoping des variables pour les Taskfiles inclus avec **lazy resolution via DAG** et **accès cross-namespace**. - -## Décisions prises - -- **Architecture** : Lazy resolution via DAG (pas de merge global des variables) -- **Accès cross-namespace** : `{{.namespace.VAR}}` pour accéder aux variables d'un include -- **Isolation** : Un include ne voit pas les vars du parent (sauf passage explicite via `includes.vars:`) -- **Requires/Preconditions** : Reporté à une phase ultérieure - ---- - -## Comparaison des approches - -| Aspect | Plan V1 (scoped merge) | Plan V2 (lazy DAG) | -|--------|------------------------|---------------------| -| Merge des vars | Oui, dans map scopée | Non, préservées dans le DAG | -| Résolution | Build time | Runtime (lazy) | -| Performance | Résout tout | Résout seulement le nécessaire | -| Complexité | Modérée | Plus élevée | -| Accès cross-namespace | `{{.ns.VAR}}` via map | `{{.ns.VAR}}` via traversée DAG | - ---- - -## Modèle de scopes (priorité croissante) - -``` -1. Environment Scope (plus bas) - - Variables d'environnement shell - - Variables CLI (task VAR=value) - -2. Entrypoint Scope - - Dotenv globaux du Taskfile racine - - Vars globales du Taskfile racine - -3. Include Scope(s) (dans l'ordre d'inclusion) - - Variables passées via `includes.vars:` - - Dotenv de l'include (futur) - - Vars globales de l'include - -4. Task Scope (plus haut) - - Variables passées via une autre task - - Dotenv au niveau task - - Vars au niveau task -``` - -**Règle** : Chaque scope hérite du précédent et peut override. - ---- - -## Visibilité des variables - -| Depuis... | Accès à... | Mécanisme | -|-----------|------------|-----------| -| Parent | Ses propres vars | `{{.VAR}}` | -| Parent | Vars d'un include | `{{.namespace.VAR}}` (traversée DAG) | -| Include | Ses propres vars | `{{.VAR}}` | -| Include | Vars du parent | Non (via `includes.vars:` si besoin) | -| Include | Vars d'un sibling | Non (via parent comme passeur) | - ---- - -## Architecture technique - -### Avant (merge global) - -``` -Reader.Read() → TaskfileGraph - ↓ -TaskfileGraph.Merge() → Taskfile unique - - Vars mergées globalement (last-one-wins) - - Tasks mergées avec namespace - ↓ -Executor.Taskfile = Taskfile unique - ↓ -Compiler.TaskfileVars = Taskfile.Vars (toutes mergées) -``` - -### Après (lazy DAG) - -``` -Reader.Read() → TaskfileGraph - ↓ -TaskfileGraph.Merge() → Taskfile racine - - Vars NON mergées (restent dans chaque vertex du DAG) - - Tasks mergées avec namespace (comme avant) - ↓ -Executor.Taskfile = Taskfile racine -Executor.Graph = TaskfileGraph (NOUVEAU) - ↓ -Compiler.Graph = TaskfileGraph -Compiler.resolveVariables(task) → traverse le DAG lazy -``` - ---- - -## Étapes d'implémentation - -### Étape 1 : Ajouter l'experiment flag - -**Fichier** : `experiments/experiments.go` - -```go -var ScopedVariables Experiment - -func ParseWithConfig(dir string, config *ast.TaskRC) { - ScopedVariables = New("SCOPED_VARIABLES", config, 1) -} -``` - ---- - -### Étape 2 : Préserver le DAG dans l'Executor - -**Fichier** : `executor.go` + `setup.go` - -```go -type Executor struct { - // ... existant ... - Taskfile *ast.Taskfile - Graph *ast.TaskfileGraph // NOUVEAU -} - -func (e *Executor) readTaskfile(node taskfile.Node) error { - graph, err := reader.Read(ctx, node) - e.Graph = graph // Stocker le DAG - e.Taskfile, err = graph.Merge() // Merge pour les tasks - return nil -} -``` - ---- - -### Étape 3 : Modifier le merge pour ne pas merger les variables - -**Fichier** : `taskfile/ast/taskfile.go` - -```go -func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include) error { - // ... validations existantes ... - - if experiments.ScopedVariables.Enabled() { - // NE PAS merger les variables - elles restent dans le DAG - // Optionnel : merger seulement les vars passées via include.Vars - } else { - // Comportement legacy - t1.Vars.Merge(t2.Vars, include) - t1.Env.Merge(t2.Env, include) - } - - return t1.Tasks.Merge(t2.Tasks, include, t1.Vars) -} -``` - ---- - -### Étape 4 : Passer le DAG au Compiler - -**Fichier** : `compiler.go` - -```go -type Compiler struct { - // ... existant ... - Graph *ast.TaskfileGraph // NOUVEAU -} -``` - ---- - -### Étape 5 : Résolution lazy des variables - -**Fichier** : `compiler.go` - -Nouvelle méthode pour résoudre les variables en traversant le DAG : - -```go -func (c *Compiler) resolveVariablesFromGraph(t *ast.Task) (*ast.Vars, error) { - result := env.GetEnviron() - - // 1. Variables spéciales - specialVars, _ := c.getSpecialVars(t, call) - - // 2. Variables du Taskfile racine (entrypoint scope) - rootVars := c.getRootTaskfileVars() - - // 3. Variables de la chaîne d'includes jusqu'à cette task (include scope) - // Traverser le DAG en suivant le namespace de la task - includeChainVars := c.getIncludeChainVars(t.Namespace) - - // 4. Variables de la task elle-même (task scope) - taskVars := t.Vars - - // Merger dans l'ordre de priorité - // ... - - return result, nil -} - -func (c *Compiler) getIncludeChainVars(namespace string) *ast.Vars { - // Trouver le vertex de l'include dans le DAG - // Récupérer ses variables - // Récursivement remonter si includes imbriqués -} -``` - ---- - -### Étape 6 : Supporter `{{.namespace.VAR}}` dans le templater - -**Fichier** : `internal/templater/templater.go` - -Deux approches possibles : - -**A) Pré-populer une map avec les namespaces accessibles :** -```go -func (c *Compiler) buildTemplateVars(t *ast.Task) map[string]any { - m := make(map[string]any) - - // Variables locales - for k, v := range localVars { - m[k] = v - } - - // Namespaces accessibles (depuis la perspective de cette task) - for _, include := range c.getAccessibleIncludes(t) { - m[include.Namespace] = c.getIncludeVarsAsMap(include) - } - - return m -} -``` - -**B) Custom template function :** -```go -// {{ns "db" "HOST"}} au lieu de {{.db.HOST}} -funcMap := template.FuncMap{ - "ns": func(namespace, varName string) any { - return c.resolveNamespacedVar(namespace, varName) - }, -} -``` - -**Recommandation** : Approche A pour garder la syntaxe `{{.db.HOST}}` - ---- - -### Étape 7 : Gérer les includes imbriqués - -Pour `root → a → b`, les variables doivent être accessibles comme : -- `{{.a.VAR}}` depuis root -- `{{.a.b.VAR}}` depuis root (via a) -- `{{.b.VAR}}` depuis a -- `{{.VAR}}` depuis b (ses propres vars) - -Le namespace complet est déjà tracké dans `Task.Namespace` (e.g., `"a:b:taskname"`). - ---- - -### Étape 8 : Tests - -**Nouveau répertoire** : `testdata/includes/scoped_vars/` - -Scénarios : -1. Accès cross-namespace : `{{.db.HOST}}` depuis root -2. Accès local : `{{.HOST}}` depuis l'include -3. Isolation : include ne voit pas les vars du parent -4. Override via `includes.vars:` -5. Pas de conflits entre includes -6. `flatten: true` conserve le comportement legacy -7. Includes imbriqués : `{{.a.b.VAR}}` -8. Variables dynamiques (`sh:`) dans un include - ---- - -## Fichiers à modifier - -| Fichier | Changement | -|---------|------------| -| `experiments/experiments.go` | Ajouter `ScopedVariables` | -| `executor.go` | Ajouter champ `Graph` | -| `setup.go` | Stocker le DAG dans l'Executor | -| `taskfile/ast/taskfile.go` | Conditionner le merge des vars | -| `compiler.go` | Traversée lazy du DAG, accès au Graph | -| `internal/templater/templater.go` | (optionnel) Support `{{.ns.VAR}}` | - ---- - -## Stratégie de migration - -1. **v3.x** : Feature derrière `TASK_X_SCOPED_VARIABLES=1` -2. **v4.0** : Activer par défaut (breaking change documenté) -3. **Migration** : - - Les vars d'includes ne sont plus accessibles directement → utiliser `{{.namespace.VAR}}` - - Les vars du parent ne sont plus visibles dans l'include → passer via `includes.vars:` - ---- - -## Avantages de cette approche - -1. **Performance** : Résolution lazy, on ne calcule que ce qui est nécessaire -2. **Clarté** : Chaque scope est explicite -3. **Pas de conflits** : Les variables ne se marchent plus dessus -4. **Réutilisabilité** : Un include peut être utilisé dans différents contextes -5. **Utilise l'existant** : Le DAG existe déjà, on l'exploite mieux - ---- - -## Risques et considérations - -- **Breaking change** : Important - comportement fondamentalement différent -- **Complexité** : La traversée du DAG est plus complexe que le merge -- **Debug** : Plus difficile de comprendre d'où vient une variable (ajouter du logging) diff --git a/PLAN_SCOPED_VARIABLES_V3_LAZY_SIMPLE.md b/PLAN_SCOPED_VARIABLES_V3_LAZY_SIMPLE.md deleted file mode 100644 index dd1a38b90e..0000000000 --- a/PLAN_SCOPED_VARIABLES_V3_LAZY_SIMPLE.md +++ /dev/null @@ -1,208 +0,0 @@ -# Plan V3 : Lazy DAG avec Isolation Stricte - -## Objectif - -Implémenter le scoping des variables via **lazy resolution** et **isolation stricte** (pas de `{{.namespace.VAR}}`). - -## Décisions - -- **Architecture** : Lazy resolution via DAG -- **Isolation stricte** : Pas d'accès cross-namespace -- **Héritage** : Include hérite du parent, pas l'inverse -- **Experiment** : `TASK_X_SCOPED_VARIABLES=1` - ---- - -## Modèle de scopes - -``` -Environment → Shell + CLI vars - ↓ hérite -Entrypoint → Root Taskfile vars - ↓ hérite -Include(s) → Vars de chaque include (dans l'ordre) - ↓ hérite -Task → Vars de la task -``` - -Chaque scope hérite du précédent et peut override. - ---- - -## Visibilité - -| Depuis | Voit | Ne voit PAS | -|--------|------|-------------| -| Root | Ses vars | Vars des includes | -| Include | Ses vars + parent | Vars des siblings | -| Task | Toute la chaîne | - | - -**Communication unidirectionnelle** : parent → enfant via `includes.vars:` - ---- - -## Changements requis - -### 1. Experiment flag - -**Fichier** : `experiments/experiments.go` - -```go -var ScopedVariables Experiment - -func ParseWithConfig(dir string, config *ast.TaskRC) { - ScopedVariables = New("SCOPED_VARIABLES", config, 1) -} -``` - ---- - -### 2. Préserver le DAG - -**Fichier** : `executor.go` - -```go -type Executor struct { - Taskfile *ast.Taskfile - Graph *ast.TaskfileGraph // NOUVEAU -} -``` - -**Fichier** : `setup.go` - -```go -func (e *Executor) readTaskfile(node taskfile.Node) error { - graph, err := reader.Read(ctx, node) - e.Graph = graph - e.Taskfile, err = graph.Merge() - return nil -} -``` - ---- - -### 3. Ne pas merger les vars - -**Fichier** : `taskfile/ast/taskfile.go` - -```go -func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include) error { - if !experiments.ScopedVariables.Enabled() { - // Legacy : merge global - t1.Vars.Merge(t2.Vars, include) - t1.Env.Merge(t2.Env, include) - } - // Les vars restent dans le DAG, pas de merge - - return t1.Tasks.Merge(t2.Tasks, include, t1.Vars) -} -``` - ---- - -### 4. Résolution lazy dans le Compiler - -**Fichier** : `compiler.go` - -```go -type Compiler struct { - Graph *ast.TaskfileGraph // NOUVEAU -} - -func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*ast.Vars, error) { - if experiments.ScopedVariables.Enabled() { - return c.getVariablesLazy(t, call, evaluateShVars) - } - // Legacy behavior... -} - -func (c *Compiler) getVariablesLazy(t *ast.Task, call *Call, evaluateShVars bool) (*ast.Vars, error) { - result := env.GetEnviron() - - // 1. Special vars - specialVars, _ := c.getSpecialVars(t, call) - - // 2. Root taskfile vars (depuis le DAG) - rootVertex := c.Graph.Root() - for k, v := range rootVertex.Taskfile.Vars.All() { - result.Set(k, v) - } - - // 3. Include chain vars (traverser le DAG jusqu'à cette task) - if t != nil && t.Namespace != "" { - includeVars := c.resolveIncludeChain(t.Namespace) - for k, v := range includeVars.All() { - result.Set(k, v) - } - } - - // 4. Task vars - if t != nil { - for k, v := range t.Vars.All() { - result.Set(k, v) - } - } - - return result, nil -} - -func (c *Compiler) resolveIncludeChain(namespace string) *ast.Vars { - // namespace = "a:b:taskname" → trouver les vars de a, puis b - // Traverser le DAG en suivant les edges -} -``` - ---- - -### 5. Helper pour trouver un vertex par namespace - -**Fichier** : `taskfile/ast/graph.go` - -```go -func (tfg *TaskfileGraph) GetVertexByNamespace(namespace string) (*TaskfileVertex, error) { - // Trouver le vertex correspondant au namespace -} - -func (tfg *TaskfileGraph) Root() *TaskfileVertex { - // Retourner le vertex racine -} -``` - ---- - -## Fichiers à modifier - -| Fichier | Changement | -|---------|------------| -| `experiments/experiments.go` | Ajouter `ScopedVariables` | -| `executor.go` | Ajouter `Graph` | -| `setup.go` | Stocker le DAG | -| `taskfile/ast/taskfile.go` | Conditionner le merge | -| `taskfile/ast/graph.go` | Helpers pour accès au DAG | -| `compiler.go` | `getVariablesLazy()` | - ---- - -## Tests - -1. **Héritage** : Include voit les vars du parent -2. **Override** : Include peut override une var du parent -3. **Isolation** : Parent ne voit pas les vars de l'include -4. **Siblings** : Un include ne voit pas les vars d'un autre -5. **Chaîne** : `root → a → b` - b voit les vars de a et root -6. **Legacy** : Sans le flag, comportement inchangé - ---- - -## Avantages - -1. **Simple** : Pas de mapping cross-namespace -2. **Performance** : Lazy, résout que le nécessaire -3. **Clair** : Héritage linéaire, facile à comprendre -4. **Safe** : Experiment flag, rollback facile - ---- - -## Inconvénient - -Le parent ne peut pas "lire" les vars d'un include. Si nécessaire, il faudrait ajouter `{{.namespace.VAR}}` plus tard (V2). diff --git a/compiler.go b/compiler.go index 4a10bf28a8..d8ffc061e3 100644 --- a/compiler.go +++ b/compiler.go @@ -78,7 +78,6 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (* 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 @@ -87,35 +86,26 @@ func (c *Compiler) getScopedVariables(t *ast.Task, call *Call, evaluateShVars bo 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 @@ -126,7 +116,6 @@ func (c *Compiler) getScopedVariables(t *ast.Task, call *Call, evaluateShVars bo } 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} @@ -138,40 +127,30 @@ func (c *Compiler) getScopedVariables(t *ast.Task, call *Call, evaluateShVars bo taskRangeFunc = getRangeFunc(dir) } - // 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 { @@ -187,29 +166,24 @@ func (c *Compiler) getScopedVariables(t *ast.Task, call *Call, evaluateShVars bo 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 } - // Build parent chain (excluding root, already applied) var parentChain []*ast.TaskfileVertex currentURI := t.Location.Taskfile for { @@ -233,9 +207,7 @@ func (c *Compiler) getScopedVariables(t *ast.Task, call *Call, evaluateShVars bo 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 { @@ -251,12 +223,10 @@ func (c *Compiler) getScopedVariables(t *ast.Task, call *Call, evaluateShVars bo } } - // 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 { @@ -271,7 +241,6 @@ func (c *Compiler) getScopedVariables(t *ast.Task, call *Call, evaluateShVars bo } } - // 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 { @@ -280,14 +249,12 @@ func (c *Compiler) getScopedVariables(t *ast.Task, call *Call, evaluateShVars bo } } - // 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 @@ -295,14 +262,12 @@ func (c *Compiler) getScopedVariables(t *ast.Task, call *Call, evaluateShVars bo } } - // 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 @@ -313,7 +278,6 @@ func (c *Compiler) getScopedVariables(t *ast.Task, call *Call, evaluateShVars bo 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 @@ -322,35 +286,26 @@ func (c *Compiler) getLegacyVariables(t *ast.Task, call *Call, evaluateShVars bo 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 @@ -361,7 +316,6 @@ func (c *Compiler) getLegacyVariables(t *ast.Task, call *Call, evaluateShVars bo } 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} @@ -373,7 +327,6 @@ func (c *Compiler) getLegacyVariables(t *ast.Task, call *Call, evaluateShVars bo 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 @@ -402,7 +355,6 @@ func (c *Compiler) getLegacyVariables(t *ast.Task, call *Call, evaluateShVars bo return result, nil } - // Legacy order: CLI vars, then task vars (task vars override CLI) for k, v := range call.Vars.All() { if err := rangeFunc(k, v); err != nil { return nil, err @@ -421,7 +373,6 @@ func (c *Compiler) HandleDynamicVar(v ast.Var, dir string, e []string) (string, c.muDynamicCache.Lock() defer c.muDynamicCache.Unlock() - // If the variable is not dynamic or it is empty, return an empty string if v.Sh == nil || *v.Sh == "" { return "", nil } diff --git a/website/src/docs/experiments/scoped-taskfiles.md b/website/src/docs/experiments/scoped-taskfiles.md index 72fb652eae..d2d35b7785 100644 --- a/website/src/docs/experiments/scoped-taskfiles.md +++ b/website/src/docs/experiments/scoped-taskfiles.md @@ -1,11 +1,11 @@ --- -title: 'Scoped Taskfiles' +title: 'Scoped Taskfiles (#2035)' description: Experiment for variable isolation and env namespace in included Taskfiles outline: deep --- -# Scoped Taskfiles +# Scoped Taskfiles (#2035) ::: warning From 7323fe8009900f40be4696f8ec344582f7de4d25 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 25 Jan 2026 20:45:46 +0100 Subject: [PATCH 14/15] fix: resolve lint issues after rebase - Fix import order in setup.go (gci) - Fix variable alignment in experiments.go (gofmt) - Add nolint:paralleltest directive for TestScopedTaskfiles --- executor_test.go | 4 +--- experiments/experiments.go | 8 ++++---- setup.go | 2 +- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/executor_test.go b/executor_test.go index e4e125643d..ef4ab033bd 100644 --- a/executor_test.go +++ b/executor_test.go @@ -1181,10 +1181,8 @@ func TestIf(t *testing.T) { } } +//nolint:paralleltest // enableExperimentForTest modifies global state func TestScopedTaskfiles(t *testing.T) { - // NOTE: Don't use t.Parallel() here because enableExperimentForTest modifies - // global state that can affect other tests running in parallel. - // Legacy tests (without experiment) - vars should be merged globally t.Run("legacy", func(t *testing.T) { // Test with scoped taskfiles disabled (legacy) - vars should be merged globally diff --git a/experiments/experiments.go b/experiments/experiments.go index 0e0139d182..7b4e0a4c09 100644 --- a/experiments/experiments.go +++ b/experiments/experiments.go @@ -16,10 +16,10 @@ const envPrefix = "TASK_X_" // Active experiments. var ( - GentleForce Experiment - RemoteTaskfiles Experiment - EnvPrecedence Experiment - ScopedTaskfiles Experiment + GentleForce Experiment + RemoteTaskfiles Experiment + EnvPrecedence Experiment + ScopedTaskfiles Experiment ) // Inactive experiments. These are experiments that cannot be enabled, but are diff --git a/setup.go b/setup.go index bf252fb740..de56cbe46a 100644 --- a/setup.go +++ b/setup.go @@ -13,6 +13,7 @@ import ( "github.com/sajari/fuzzy" "github.com/go-task/task/v3/errors" + "github.com/go-task/task/v3/experiments" "github.com/go-task/task/v3/internal/env" "github.com/go-task/task/v3/internal/execext" "github.com/go-task/task/v3/internal/filepathext" @@ -20,7 +21,6 @@ import ( "github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/output" "github.com/go-task/task/v3/internal/version" - "github.com/go-task/task/v3/experiments" "github.com/go-task/task/v3/taskfile" "github.com/go-task/task/v3/taskfile/ast" ) From 81fbca3420d0f585a99f8afedc1667cf94843778 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 25 Jan 2026 20:54:49 +0100 Subject: [PATCH 15/15] refactor(compiler): remove unnecessary closure comments --- compiler.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/compiler.go b/compiler.go index d8ffc061e3..4689e41062 100644 --- a/compiler.go +++ b/compiler.go @@ -86,7 +86,6 @@ func (c *Compiler) getScopedVariables(t *ast.Task, call *Call, evaluateShVars bo result.Set(k, ast.Var{Value: v}) } - // 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} @@ -286,7 +285,6 @@ func (c *Compiler) getLegacyVariables(t *ast.Task, call *Call, evaluateShVars bo result.Set(k, ast.Var{Value: v}) } - // 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}