Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions cmd/wfctl/compat.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ type CompatibilityInfo struct {

// compatCheckResult holds the result of a compatibility check.
type compatCheckResult struct {
EngineVersion string `json:"engineVersion"`
RequiredModules []compatItem `json:"requiredModules"`
RequiredSteps []compatItem `json:"requiredSteps"`
Compatible bool `json:"compatible"`
Issues []string `json:"issues,omitempty"`
EngineVersion string `json:"engineVersion"`
RequiredModules []compatItem `json:"requiredModules"`
RequiredSteps []compatItem `json:"requiredSteps"`
Compatible bool `json:"compatible"`
Issues []string `json:"issues,omitempty"`
}

// compatItem represents a single required type and whether it's available.
Expand Down
30 changes: 15 additions & 15 deletions cmd/wfctl/contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import (

// Contract is a snapshot of what a workflow application config exposes.
type Contract struct {
Version string `json:"version"`
ConfigHash string `json:"configHash"`
GeneratedAt string `json:"generatedAt"`
Version string `json:"version"`
ConfigHash string `json:"configHash"`
GeneratedAt string `json:"generatedAt"`
Endpoints []EndpointContract `json:"endpoints"`
Modules []ModuleContract `json:"modules"`
Steps []string `json:"steps"`
Expand Down Expand Up @@ -59,25 +59,25 @@ type contractComparison struct {
type changeType string

const (
changeAdded changeType = "ADDED"
changeRemoved changeType = "REMOVED"
changeChanged changeType = "CHANGED"
changeAdded changeType = "ADDED"
changeRemoved changeType = "REMOVED"
changeChanged changeType = "CHANGED"
changeUnchanged changeType = "UNCHANGED"
)

type endpointChange struct {
Method string
Path string
Pipeline string
Change changeType
Detail string
IsBreaking bool
Method string
Path string
Pipeline string
Change changeType
Detail string
IsBreaking bool
}

type moduleChange struct {
Name string
Type string
Change changeType
Name string
Type string
Change changeType
}

type eventChange struct {
Expand Down
2 changes: 1 addition & 1 deletion cmd/wfctl/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ type PipelineDiff struct {

// BreakingChangeSummary aggregates breaking-change warnings across the diff.
type BreakingChangeSummary struct {
ModuleName string `json:"moduleName"`
ModuleName string `json:"moduleName"`
Changes []BreakingChange `json:"changes"`
}

Expand Down
64 changes: 64 additions & 0 deletions cmd/wfctl/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"path/filepath"
"strings"
"testing"

"github.com/GoCodeAlone/workflow/schema"
)

func writeTestConfig(t *testing.T, dir, name, content string) string {
Expand Down Expand Up @@ -185,6 +187,33 @@ modules:
}
}

func TestRunValidateSnakeCaseConfig(t *testing.T) {
dir := t.TempDir()
// "content_type" is the snake_case form of the known camelCase field "contentType"
snakeCaseConfig := `
modules:
- name: handler
type: http.handler
config:
content_type: "application/json"
triggers:
http:
port: 8080
`
path := writeTestConfig(t, dir, "snake.yaml", snakeCaseConfig)
// validateFile returns the detailed error; runValidate returns a summary
err := validateFile(path, false, false, false)
if err == nil {
t.Fatal("expected error for snake_case config field")
}
if !strings.Contains(err.Error(), "content_type") {
t.Errorf("expected error to mention snake_case field 'content_type', got: %v", err)
}
if !strings.Contains(err.Error(), "contentType") {
t.Errorf("expected error to suggest camelCase 'contentType', got: %v", err)
}
}

func TestRunPluginMissingSubcommand(t *testing.T) {
err := runPlugin([]string{})
if err == nil {
Expand Down Expand Up @@ -255,3 +284,38 @@ func TestRunPluginDocsMissingDir(t *testing.T) {
t.Fatal("expected error when no directory given")
}
}

func TestRunValidatePluginDir(t *testing.T) {
// Create a fake external plugin directory with a plugin.json declaring a custom module type.
pluginsDir := t.TempDir()
pluginSubdir := filepath.Join(pluginsDir, "my-ext-plugin")
if err := os.MkdirAll(pluginSubdir, 0755); err != nil {
t.Fatal(err)
}
manifest := `{"moduleTypes": ["custom.ext.validate.testonly"]}`
if err := os.WriteFile(filepath.Join(pluginSubdir, "plugin.json"), []byte(manifest), 0644); err != nil {
t.Fatal(err)
}

// Config using the external plugin module type
dir := t.TempDir()
configContent := `
modules:
- name: ext-mod
type: custom.ext.validate.testonly
`
path := writeTestConfig(t, dir, "workflow.yaml", configContent)

// Without --plugin-dir: should fail (unknown type)
if err := runValidate([]string{path}); err == nil {
t.Fatal("expected error for unknown external module type without --plugin-dir")
}

// With --plugin-dir: should pass
if err := runValidate([]string{"--plugin-dir", pluginsDir, path}); err != nil {
t.Errorf("expected valid config with --plugin-dir, got: %v", err)
}
t.Cleanup(func() {
schema.UnregisterModuleType("custom.ext.validate.testonly")
})
}
6 changes: 3 additions & 3 deletions cmd/wfctl/multi_registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ func TestMultiRegistryFetchPriority(t *testing.T) {
func TestMultiRegistryFetchFallback(t *testing.T) {
// Source A errors for "unique-plugin", source B has it.
srcA := &mockRegistrySource{
name: "primary",
name: "primary",
manifests: map[string]*RegistryManifest{},
fetchErr: map[string]error{
"unique-plugin": fmt.Errorf("not found"),
Expand Down Expand Up @@ -439,14 +439,14 @@ func TestMultiRegistryListDedup(t *testing.T) {
srcA := &mockRegistrySource{
name: "primary",
manifests: map[string]*RegistryManifest{
"shared": {Name: "shared"},
"shared": {Name: "shared"},
"only-in-a": {Name: "only-in-a"},
},
}
srcB := &mockRegistrySource{
name: "secondary",
manifests: map[string]*RegistryManifest{
"shared": {Name: "shared"},
"shared": {Name: "shared"},
"only-in-b": {Name: "only-in-b"},
},
}
Expand Down
28 changes: 14 additions & 14 deletions cmd/wfctl/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,21 @@ const (

// RegistryManifest is the manifest format for the GoCodeAlone/workflow-registry.
type RegistryManifest struct {
Name string `json:"name"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Source string `json:"source,omitempty"`
Type string `json:"type"`
Tier string `json:"tier"`
License string `json:"license"`
MinEngineVersion string `json:"minEngineVersion,omitempty"`
Repository string `json:"repository,omitempty"`
Keywords []string `json:"keywords,omitempty"`
Homepage string `json:"homepage,omitempty"`
Name string `json:"name"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Source string `json:"source,omitempty"`
Type string `json:"type"`
Tier string `json:"tier"`
License string `json:"license"`
MinEngineVersion string `json:"minEngineVersion,omitempty"`
Repository string `json:"repository,omitempty"`
Keywords []string `json:"keywords,omitempty"`
Homepage string `json:"homepage,omitempty"`
Capabilities *RegistryCapabilities `json:"capabilities,omitempty"`
Downloads []PluginDownload `json:"downloads,omitempty"`
Assets *PluginAssets `json:"assets,omitempty"`
Downloads []PluginDownload `json:"downloads,omitempty"`
Assets *PluginAssets `json:"assets,omitempty"`
}

// RegistryCapabilities describes what module/step/trigger types a plugin provides.
Expand Down
1 change: 0 additions & 1 deletion cmd/wfctl/registry_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,4 +177,3 @@ func FormatValidationErrors(errs []ValidationError) string {
}
return b.String()
}

34 changes: 30 additions & 4 deletions cmd/wfctl/template_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"text/template"

"github.com/GoCodeAlone/workflow/config"
"github.com/GoCodeAlone/workflow/schema"
"gopkg.in/yaml.v3"
)

Expand Down Expand Up @@ -77,6 +78,7 @@ func runTemplateValidate(args []string) error {
configFile := fs2.String("config", "", "Validate a specific config file instead of templates")
strict := fs2.Bool("strict", false, "Fail on warnings (not just errors)")
format := fs2.String("format", "text", "Output format: text or json")
pluginDir := fs2.String("plugin-dir", "", "Directory of installed external plugins; their types are loaded before validation")
fs2.Usage = func() {
fmt.Fprintf(fs2.Output(), `Usage: wfctl template validate [options]

Expand All @@ -90,6 +92,14 @@ Options:
return err
}

// Load external plugin types before validation so their module/trigger/workflow
// types are recognised and don't cause false "unknown type" errors.
if *pluginDir != "" {
if err := schema.LoadPluginTypesFromDir(*pluginDir); err != nil {
return fmt.Errorf("failed to load plugins from %s: %w", *pluginDir, err)
}
}

knownModules := KnownModuleTypes()
knownSteps := KnownStepTypes()
knownTriggers := KnownTriggerTypes()
Expand Down Expand Up @@ -353,15 +363,23 @@ func validateWorkflowConfig(name string, cfg *config.WorkflowConfig, knownModule
result.Errors = append(result.Errors, fmt.Sprintf("module %q uses unknown type %q", mod.Name, mod.Type))
} else {
result.ModuleValid++
// Warn on unknown config fields
// Warn on unknown config fields (with snake_case hint)
if mod.Config != nil && len(info.ConfigKeys) > 0 {
knownKeys := make(map[string]bool)
snakeToCamel := make(map[string]string)
for _, k := range info.ConfigKeys {
knownKeys[k] = true
if snake := schema.CamelToSnake(k); snake != k {
snakeToCamel[snake] = k
}
}
for key := range mod.Config {
if !knownKeys[key] {
result.Warnings = append(result.Warnings, fmt.Sprintf("module %q (%s) config field %q not in known fields", mod.Name, mod.Type, key))
if camel, ok := snakeToCamel[key]; ok {
result.Warnings = append(result.Warnings, fmt.Sprintf("module %q (%s) config field %q uses snake_case; use camelCase %q instead", mod.Name, mod.Type, key, camel))
} else {
result.Warnings = append(result.Warnings, fmt.Sprintf("module %q (%s) config field %q not in known fields", mod.Name, mod.Type, key))
}
}
}
}
Expand Down Expand Up @@ -401,15 +419,23 @@ func validateWorkflowConfig(name string, cfg *config.WorkflowConfig, knownModule
result.Errors = append(result.Errors, fmt.Sprintf("pipeline %q step uses unknown type %q", pipelineName, stepType))
} else {
result.StepValid++
// Config key warnings
// Config key warnings (with snake_case hint)
if stepCfg, ok := stepMap["config"].(map[string]any); ok && len(stepInfo.ConfigKeys) > 0 {
knownKeys := make(map[string]bool)
snakeToCamel := make(map[string]string)
for _, k := range stepInfo.ConfigKeys {
knownKeys[k] = true
if snake := schema.CamelToSnake(k); snake != k {
snakeToCamel[snake] = k
}
}
for key := range stepCfg {
if !knownKeys[key] {
result.Warnings = append(result.Warnings, fmt.Sprintf("pipeline %q step %q (%s) config field %q not in known fields", pipelineName, stepMap["name"], stepType, key))
if camel, ok := snakeToCamel[key]; ok {
result.Warnings = append(result.Warnings, fmt.Sprintf("pipeline %q step %q (%s) config field %q uses snake_case; use camelCase %q instead", pipelineName, stepMap["name"], stepType, key, camel))
} else {
result.Warnings = append(result.Warnings, fmt.Sprintf("pipeline %q step %q (%s) config field %q not in known fields", pipelineName, stepMap["name"], stepType, key))
}
}
}
}
Expand Down
Loading
Loading