From edee501b6b17d4a6b08073d4435fd97243274e0e Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Mon, 29 Dec 2025 16:31:51 +0100 Subject: [PATCH] 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 f42425d6..f6a97814 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 89c28777..dd268330 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 b3f07baa..0e0139d1 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 e0aa5965..bf252fb7 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 d8b76ccc..00000000 --- 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 384c81af..00000000 --- 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 662d325f..00000000 --- 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 00000000..739cd481 --- /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 00000000..f1ecadb2 --- /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 00000000..e1c5643e --- /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 00000000..6f3d5391 --- /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 00000000..9a4d6ae2 --- /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 00000000..28650ffb --- /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