Implement advice operators for formula composition (gt-8tmz.2)
Add Lisp-style advice operators to the formula DSL:
- before(target, step) - insert step before target
- after(target, step) - insert step after target
- around(target, wrapper) - wrap target with before/after
Features:
- Glob pattern matching for targets (*.implement, shiny.*, etc)
- Pointcut matching by type or label
- Step reference substitution ({step.id}, {step.title})
- Automatic dependency chaining
New types: AdviceRule, AdviceStep, AroundAdvice, Pointcut
New functions: ApplyAdvice, MatchGlob, MatchPointcut
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
package formula
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMatchGlob(t *testing.T) {
|
||||
tests := []struct {
|
||||
pattern string
|
||||
stepID string
|
||||
want bool
|
||||
}{
|
||||
// Exact matches
|
||||
{"design", "design", true},
|
||||
{"design", "implement", false},
|
||||
{"design", "design.draft", false},
|
||||
|
||||
// Wildcard all
|
||||
{"*", "design", true},
|
||||
{"*", "implement.draft", true},
|
||||
{"*", "", true},
|
||||
|
||||
// Suffix patterns (*.suffix)
|
||||
{"*.implement", "shiny.implement", true},
|
||||
{"*.implement", "design.implement", true},
|
||||
{"*.implement", "implement", false},
|
||||
{"*.implement", "shiny.design", false},
|
||||
|
||||
// Prefix patterns (prefix.*)
|
||||
{"shiny.*", "shiny.design", true},
|
||||
{"shiny.*", "shiny.implement", true},
|
||||
{"shiny.*", "shiny", false},
|
||||
{"shiny.*", "enterprise.design", false},
|
||||
|
||||
// Complex patterns
|
||||
{"*.refine-*", "implement.refine-1", true},
|
||||
{"*.refine-*", "implement.refine-2", true},
|
||||
{"*.refine-*", "implement.draft", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.pattern+"_"+tt.stepID, func(t *testing.T) {
|
||||
got := MatchGlob(tt.pattern, tt.stepID)
|
||||
if got != tt.want {
|
||||
t.Errorf("MatchGlob(%q, %q) = %v, want %v", tt.pattern, tt.stepID, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAdvice_Before(t *testing.T) {
|
||||
steps := []*Step{
|
||||
{ID: "design", Title: "Design"},
|
||||
{ID: "implement", Title: "Implement"},
|
||||
}
|
||||
|
||||
advice := []*AdviceRule{
|
||||
{
|
||||
Target: "implement",
|
||||
Before: &AdviceStep{
|
||||
ID: "lint-{step.id}",
|
||||
Title: "Lint before {step.id}",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := ApplyAdvice(steps, advice)
|
||||
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 steps, got %d", len(result))
|
||||
}
|
||||
|
||||
// Check order: design, lint-implement, implement
|
||||
if result[0].ID != "design" {
|
||||
t.Errorf("expected first step 'design', got %q", result[0].ID)
|
||||
}
|
||||
if result[1].ID != "lint-implement" {
|
||||
t.Errorf("expected second step 'lint-implement', got %q", result[1].ID)
|
||||
}
|
||||
if result[2].ID != "implement" {
|
||||
t.Errorf("expected third step 'implement', got %q", result[2].ID)
|
||||
}
|
||||
|
||||
// Check that implement now depends on lint-implement
|
||||
if !contains(result[2].Needs, "lint-implement") {
|
||||
t.Errorf("implement should depend on lint-implement, got needs: %v", result[2].Needs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAdvice_After(t *testing.T) {
|
||||
steps := []*Step{
|
||||
{ID: "implement", Title: "Implement"},
|
||||
{ID: "submit", Title: "Submit"},
|
||||
}
|
||||
|
||||
advice := []*AdviceRule{
|
||||
{
|
||||
Target: "implement",
|
||||
After: &AdviceStep{
|
||||
ID: "test-{step.id}",
|
||||
Title: "Test after {step.id}",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := ApplyAdvice(steps, advice)
|
||||
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 steps, got %d", len(result))
|
||||
}
|
||||
|
||||
// Check order: implement, test-implement, submit
|
||||
if result[0].ID != "implement" {
|
||||
t.Errorf("expected first step 'implement', got %q", result[0].ID)
|
||||
}
|
||||
if result[1].ID != "test-implement" {
|
||||
t.Errorf("expected second step 'test-implement', got %q", result[1].ID)
|
||||
}
|
||||
if result[2].ID != "submit" {
|
||||
t.Errorf("expected third step 'submit', got %q", result[2].ID)
|
||||
}
|
||||
|
||||
// Check that test-implement depends on implement
|
||||
if !contains(result[1].Needs, "implement") {
|
||||
t.Errorf("test-implement should depend on implement, got needs: %v", result[1].Needs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAdvice_Around(t *testing.T) {
|
||||
steps := []*Step{
|
||||
{ID: "implement", Title: "Implement"},
|
||||
}
|
||||
|
||||
advice := []*AdviceRule{
|
||||
{
|
||||
Target: "implement",
|
||||
Around: &AroundAdvice{
|
||||
Before: []*AdviceStep{
|
||||
{ID: "pre-scan", Title: "Pre-scan"},
|
||||
},
|
||||
After: []*AdviceStep{
|
||||
{ID: "post-scan", Title: "Post-scan"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := ApplyAdvice(steps, advice)
|
||||
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 steps, got %d", len(result))
|
||||
}
|
||||
|
||||
// Check order: pre-scan, implement, post-scan
|
||||
if result[0].ID != "pre-scan" {
|
||||
t.Errorf("expected first step 'pre-scan', got %q", result[0].ID)
|
||||
}
|
||||
if result[1].ID != "implement" {
|
||||
t.Errorf("expected second step 'implement', got %q", result[1].ID)
|
||||
}
|
||||
if result[2].ID != "post-scan" {
|
||||
t.Errorf("expected third step 'post-scan', got %q", result[2].ID)
|
||||
}
|
||||
|
||||
// Check dependencies
|
||||
if !contains(result[1].Needs, "pre-scan") {
|
||||
t.Errorf("implement should depend on pre-scan, got needs: %v", result[1].Needs)
|
||||
}
|
||||
if !contains(result[2].Needs, "implement") {
|
||||
t.Errorf("post-scan should depend on implement, got needs: %v", result[2].Needs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAdvice_GlobPattern(t *testing.T) {
|
||||
steps := []*Step{
|
||||
{ID: "design", Title: "Design"},
|
||||
{ID: "shiny.implement", Title: "Implement"},
|
||||
{ID: "shiny.review", Title: "Review"},
|
||||
}
|
||||
|
||||
advice := []*AdviceRule{
|
||||
{
|
||||
Target: "shiny.*",
|
||||
Before: &AdviceStep{
|
||||
ID: "log-{step.id}",
|
||||
Title: "Log {step.id}",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := ApplyAdvice(steps, advice)
|
||||
|
||||
// Should have: design, log-shiny.implement, shiny.implement, log-shiny.review, shiny.review
|
||||
if len(result) != 5 {
|
||||
t.Fatalf("expected 5 steps, got %d", len(result))
|
||||
}
|
||||
|
||||
if result[0].ID != "design" {
|
||||
t.Errorf("expected first step 'design', got %q", result[0].ID)
|
||||
}
|
||||
if result[1].ID != "log-shiny.implement" {
|
||||
t.Errorf("expected second step 'log-shiny.implement', got %q", result[1].ID)
|
||||
}
|
||||
if result[2].ID != "shiny.implement" {
|
||||
t.Errorf("expected third step 'shiny.implement', got %q", result[2].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAdvice_NoMatch(t *testing.T) {
|
||||
steps := []*Step{
|
||||
{ID: "design", Title: "Design"},
|
||||
{ID: "implement", Title: "Implement"},
|
||||
}
|
||||
|
||||
advice := []*AdviceRule{
|
||||
{
|
||||
Target: "nonexistent",
|
||||
Before: &AdviceStep{
|
||||
ID: "lint",
|
||||
Title: "Lint",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := ApplyAdvice(steps, advice)
|
||||
|
||||
// No changes expected
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 steps, got %d", len(result))
|
||||
}
|
||||
|
||||
if result[0].ID != "design" || result[1].ID != "implement" {
|
||||
t.Errorf("steps should be unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAdvice_EmptyAdvice(t *testing.T) {
|
||||
steps := []*Step{
|
||||
{ID: "design", Title: "Design"},
|
||||
}
|
||||
|
||||
result := ApplyAdvice(steps, nil)
|
||||
|
||||
if len(result) != 1 || result[0].ID != "design" {
|
||||
t.Errorf("empty advice should return original steps")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchPointcut(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pc *Pointcut
|
||||
step *Step
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "glob match",
|
||||
pc: &Pointcut{Glob: "*.implement"},
|
||||
step: &Step{ID: "shiny.implement"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "glob no match",
|
||||
pc: &Pointcut{Glob: "*.implement"},
|
||||
step: &Step{ID: "shiny.design"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "type match",
|
||||
pc: &Pointcut{Type: "bug"},
|
||||
step: &Step{ID: "fix", Type: "bug"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "type no match",
|
||||
pc: &Pointcut{Type: "bug"},
|
||||
step: &Step{ID: "fix", Type: "task"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "label match",
|
||||
pc: &Pointcut{Label: "security"},
|
||||
step: &Step{ID: "audit", Labels: []string{"security", "review"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "label no match",
|
||||
pc: &Pointcut{Label: "security"},
|
||||
step: &Step{ID: "audit", Labels: []string{"review"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "combined match",
|
||||
pc: &Pointcut{Glob: "*.implement", Type: "task"},
|
||||
step: &Step{ID: "shiny.implement", Type: "task"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "combined partial fail",
|
||||
pc: &Pointcut{Glob: "*.implement", Type: "task"},
|
||||
step: &Step{ID: "shiny.implement", Type: "bug"},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := MatchPointcut(tt.pc, tt.step)
|
||||
if got != tt.want {
|
||||
t.Errorf("MatchPointcut() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user