From c8efbc2f4aa2f24f1d2e1fa732d086d9e362ce5e Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 25 Jan 2026 19:08:45 +0100 Subject: [PATCH] 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 e718b214..00000000 --- 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 abdb6e09..00000000 --- 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 e0e68167..00000000 --- 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 dd1a38b9..00000000 --- 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 4a10bf28..d8ffc061 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 72fb652e..d2d35b77 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