diff --git a/config/aggregate_test.go b/config/aggregate_test.go index 43b7d03f..ded6476e 100644 --- a/config/aggregate_test.go +++ b/config/aggregate_test.go @@ -105,7 +105,7 @@ func TestAggregate(t *testing.T) { Name: "nil aggregate", Error: multiline( `aggregate is invalid:`, - ` - could not evaluate entire configuration`, + ` - could not evaluate entire configuration: handler is nil`, ` - no identity`, ` - no handles-command routes`, ` - no records-event routes`, diff --git a/config/application_test.go b/config/application_test.go index d10574ab..c369d526 100644 --- a/config/application_test.go +++ b/config/application_test.go @@ -76,7 +76,7 @@ func TestApplication(t *testing.T) { Name: "nil application", Error: multiline( `application is invalid:`, - ` - could not evaluate entire configuration`, + ` - could not evaluate entire configuration: application is nil`, ` - no identity`, ), Component: runtimeconfig.FromApplication(nil), diff --git a/config/component.go b/config/component.go index b985b6b5..f59a53a6 100644 --- a/config/component.go +++ b/config/component.go @@ -2,6 +2,9 @@ package config import ( "fmt" + "strings" + + "github.com/dogmatiq/enginekit/config/internal/renderer" ) // Component is the "top-level" interface for the individual elements that form @@ -24,12 +27,12 @@ type ComponentCommon struct { // not be evaluated at configuration time. IsSpeculative bool - // IsPartial indicates that the configuration could not be loaded in its - // entirety. The configuration may be valid, but cannot be safely used to - // execute an application. + // IsPartialReasons is a list of reasons that the configuration could not be + // loaded in its entirety. The configuration may be valid, but cannot be + // safely used to execute an application. // - // A value of false does not imply a complete configuration. - IsPartial bool + // An empty slice does not imply a complete or valid configuration. + IsPartialReasons []string } // ComponentProperties returns the properties common to all [Component] types. @@ -40,8 +43,8 @@ func (p *ComponentCommon) ComponentProperties() *ComponentCommon { func validateComponent(ctx *validateContext) { p := ctx.Component.ComponentProperties() - if p.IsPartial { - ctx.Invalid(PartialConfigurationError{}) + if len(p.IsPartialReasons) != 0 { + ctx.Invalid(PartialConfigurationError{p.IsPartialReasons}) } if ctx.Options.ForExecution && p.IsSpeculative { @@ -63,16 +66,35 @@ func (e ConfigurationUnavailableError) Error() string { // PartialConfigurationError indicates that a [Component]'s configuration could // not be loaded in its entirety. -type PartialConfigurationError struct{} +type PartialConfigurationError struct { + Reasons []string +} func (e PartialConfigurationError) Error() string { - return "could not evaluate entire configuration" + w := &strings.Builder{} + r := &renderer.Renderer{Target: w} + + r.Print("could not evaluate entire configuration:") + + if len(e.Reasons) == 1 { + r.Print(" ", e.Reasons[0]) + } else if len(e.Reasons) > 1 { + for _, reason := range e.Reasons { + r.Print("\n") + r.StartChild() + r.Print(reason) + r.EndChild() + } + } + + return w.String() } // SpeculativeConfigurationError indicates that a [Component]'s inclusion in the // configuration is subject to some condition that could not be evaluated at the // time the configuration was built. -type SpeculativeConfigurationError struct{} +type SpeculativeConfigurationError struct { +} func (e SpeculativeConfigurationError) Error() string { return "conditions for the component's inclusion in the configuration could not be evaluated" diff --git a/config/describe.go b/config/describe.go index d9e86a83..4829ebb8 100644 --- a/config/describe.go +++ b/config/describe.go @@ -85,7 +85,7 @@ func hasError[T error](ctx *describeContext) bool { func (ctx *describeContext) DescribeFidelity() { p := ctx.Component.ComponentProperties() - if p.IsPartial || hasError[PartialConfigurationError](ctx) || hasError[ConfigurationUnavailableError](ctx) { + if len(p.IsPartialReasons) != 0 || hasError[PartialConfigurationError](ctx) || hasError[ConfigurationUnavailableError](ctx) { ctx.Print("incomplete ") } else if !ctx.options.ValidationResult.IsPresent() { ctx.Print("unvalidated ") diff --git a/config/entity_test.go b/config/entity_test.go index 30f668a2..740053a0 100644 --- a/config/entity_test.go +++ b/config/entity_test.go @@ -53,7 +53,7 @@ func testEntity[ t.Run("it panics if the entity is partially configured", func(t *testing.T) { entity := build( func(b B) { - b.Partial() + b.Partial("") b.Identity( func(b *configbuilder.IdentityBuilder) { b.Name("name") @@ -65,7 +65,7 @@ func testEntity[ test.ExpectPanic( t, - "could not evaluate entire configuration", + "could not evaluate entire configuration: ", func() { entity.Identity() }, diff --git a/config/handler_test.go b/config/handler_test.go index 1f2951a1..825059cd 100644 --- a/config/handler_test.go +++ b/config/handler_test.go @@ -65,14 +65,14 @@ func testHandler[ handler := build(func(b B) { b.Disabled( func(b *configbuilder.FlagBuilder[Disabled]) { - b.Partial() + b.Partial("") }, ) }) test.ExpectPanic( t, - `flag:disabled is invalid: could not evaluate entire configuration`, + `flag:disabled is invalid: could not evaluate entire configuration: `, func() { handler.IsDisabled() }, diff --git a/config/identity_test.go b/config/identity_test.go index 8cd4e603..46a83e66 100644 --- a/config/identity_test.go +++ b/config/identity_test.go @@ -63,14 +63,14 @@ func TestIdentity(t *testing.T) { }, { Name: "partial", - Error: `identity:name/e6b691dd-731c-4c14-8e1c-1622381202dc is invalid: could not evaluate entire configuration`, + Error: `identity:name/e6b691dd-731c-4c14-8e1c-1622381202dc is invalid: could not evaluate entire configuration: `, Component: &Identity{ // It's possibly non-sensical to have an identity that contains // both it's name and key be considered incomplete, but this // allows us to represent a case where the name and key are // build dynamically and we don't have the _entire_ string. ComponentCommon: ComponentCommon{ - IsPartial: true, + IsPartialReasons: []string{""}, }, Name: optional.Some("name"), Key: optional.Some("e6b691dd-731c-4c14-8e1c-1622381202dc"), diff --git a/config/integration_test.go b/config/integration_test.go index f7faa379..5039ccd0 100644 --- a/config/integration_test.go +++ b/config/integration_test.go @@ -92,7 +92,7 @@ func TestIntegration(t *testing.T) { Name: "nil integration", Error: multiline( `integration is invalid:`, - ` - could not evaluate entire configuration`, + ` - could not evaluate entire configuration: handler is nil`, ` - no identity`, ` - no handles-command routes`, ), diff --git a/config/internal/configbuilder/aggregate.go b/config/internal/configbuilder/aggregate.go index 2be1fd8d..374e024f 100644 --- a/config/internal/configbuilder/aggregate.go +++ b/config/internal/configbuilder/aggregate.go @@ -1,6 +1,8 @@ package configbuilder import ( + "fmt" + "github.com/dogmatiq/dogma" "github.com/dogmatiq/enginekit/config" ) @@ -47,8 +49,8 @@ func (b *AggregateBuilder) Disabled(fn func(*FlagBuilder[config.Disabled])) { } // Partial marks the compomnent as partially configured. -func (b *AggregateBuilder) Partial() { - b.target.IsPartial = true +func (b *AggregateBuilder) Partial(format string, args ...any) { + b.target.IsPartialReasons = append(b.target.IsPartialReasons, fmt.Sprintf(format, args...)) } // Speculative marks the component as speculative. diff --git a/config/internal/configbuilder/application.go b/config/internal/configbuilder/application.go index 07030957..b18107b1 100644 --- a/config/internal/configbuilder/application.go +++ b/config/internal/configbuilder/application.go @@ -1,6 +1,8 @@ package configbuilder import ( + "fmt" + "github.com/dogmatiq/dogma" "github.com/dogmatiq/enginekit/config" ) @@ -59,8 +61,8 @@ func (b *ApplicationBuilder) Projection(fn func(*ProjectionBuilder)) { } // Partial marks the compomnent as partially configured. -func (b *ApplicationBuilder) Partial() { - b.target.IsPartial = true +func (b *ApplicationBuilder) Partial(format string, args ...any) { + b.target.IsPartialReasons = append(b.target.IsPartialReasons, fmt.Sprintf(format, args...)) } // Speculative marks the component as speculative. diff --git a/config/internal/configbuilder/builder.go b/config/internal/configbuilder/builder.go index 3f9a0dc3..b72eb1e1 100644 --- a/config/internal/configbuilder/builder.go +++ b/config/internal/configbuilder/builder.go @@ -7,10 +7,16 @@ import ( "github.com/dogmatiq/enginekit/optional" ) +// UntypedComponentBuilder is the interface for builders of some unknown +// [config.Component] type. +type UntypedComponentBuilder interface { + Partial(format string, args ...any) + Speculative() +} + // ComponentBuilder an interface for builders that produce a [config.Component]. type ComponentBuilder[T config.Component] interface { - Partial() - Speculative() + UntypedComponentBuilder Done() T } diff --git a/config/internal/configbuilder/flag.go b/config/internal/configbuilder/flag.go index 47e6468c..b2d7efbf 100644 --- a/config/internal/configbuilder/flag.go +++ b/config/internal/configbuilder/flag.go @@ -1,6 +1,8 @@ package configbuilder import ( + "fmt" + "github.com/dogmatiq/enginekit/config" "github.com/dogmatiq/enginekit/optional" ) @@ -23,8 +25,8 @@ func (b *FlagBuilder[S]) Value(v bool) { } // Partial marks the compomnent as partially configured. -func (b *FlagBuilder[S]) Partial() { - b.target.IsPartial = true +func (b *FlagBuilder[S]) Partial(format string, args ...any) { + b.target.IsPartialReasons = append(b.target.IsPartialReasons, fmt.Sprintf(format, args...)) } // Speculative marks the component as speculative. diff --git a/config/internal/configbuilder/identity.go b/config/internal/configbuilder/identity.go index 50cd230c..5e64fa12 100644 --- a/config/internal/configbuilder/identity.go +++ b/config/internal/configbuilder/identity.go @@ -1,6 +1,8 @@ package configbuilder import ( + "fmt" + "github.com/dogmatiq/enginekit/config" "github.com/dogmatiq/enginekit/optional" ) @@ -33,8 +35,8 @@ func (b *IdentityBuilder) Key(key string) { } // Partial marks the compomnent as partially configured. -func (b *IdentityBuilder) Partial() { - b.target.IsPartial = true +func (b *IdentityBuilder) Partial(format string, args ...any) { + b.target.IsPartialReasons = append(b.target.IsPartialReasons, fmt.Sprintf(format, args...)) } // Speculative marks the component as speculative. diff --git a/config/internal/configbuilder/integration.go b/config/internal/configbuilder/integration.go index 696795a4..ba6cb9d3 100644 --- a/config/internal/configbuilder/integration.go +++ b/config/internal/configbuilder/integration.go @@ -1,6 +1,8 @@ package configbuilder import ( + "fmt" + "github.com/dogmatiq/dogma" "github.com/dogmatiq/enginekit/config" ) @@ -47,8 +49,8 @@ func (b *IntegrationBuilder) Disabled(fn func(*FlagBuilder[config.Disabled])) { } // Partial marks the compomnent as partially configured. -func (b *IntegrationBuilder) Partial() { - b.target.IsPartial = true +func (b *IntegrationBuilder) Partial(format string, args ...any) { + b.target.IsPartialReasons = append(b.target.IsPartialReasons, fmt.Sprintf(format, args...)) } // Speculative marks the component as speculative. diff --git a/config/internal/configbuilder/process.go b/config/internal/configbuilder/process.go index e61f532f..f8c206ba 100644 --- a/config/internal/configbuilder/process.go +++ b/config/internal/configbuilder/process.go @@ -1,6 +1,8 @@ package configbuilder import ( + "fmt" + "github.com/dogmatiq/dogma" "github.com/dogmatiq/enginekit/config" ) @@ -47,8 +49,8 @@ func (b *ProcessBuilder) Disabled(fn func(*FlagBuilder[config.Disabled])) { } // Partial marks the compomnent as partially configured. -func (b *ProcessBuilder) Partial() { - b.target.IsPartial = true +func (b *ProcessBuilder) Partial(format string, args ...any) { + b.target.IsPartialReasons = append(b.target.IsPartialReasons, fmt.Sprintf(format, args...)) } // Speculative marks the component as speculative. diff --git a/config/internal/configbuilder/projection.go b/config/internal/configbuilder/projection.go index ce5c7711..4647f311 100644 --- a/config/internal/configbuilder/projection.go +++ b/config/internal/configbuilder/projection.go @@ -1,6 +1,8 @@ package configbuilder import ( + "fmt" + "github.com/dogmatiq/dogma" "github.com/dogmatiq/enginekit/config" ) @@ -53,8 +55,8 @@ func (b *ProjectionBuilder) DeliveryPolicy(fn func(*ProjectionDeliveryPolicyBuil } // Partial marks the compomnent as partially configured. -func (b *ProjectionBuilder) Partial() { - b.target.IsPartial = true +func (b *ProjectionBuilder) Partial(format string, args ...any) { + b.target.IsPartialReasons = append(b.target.IsPartialReasons, fmt.Sprintf(format, args...)) } // Speculative marks the component as speculative. diff --git a/config/internal/configbuilder/projectiondeliverypolicy.go b/config/internal/configbuilder/projectiondeliverypolicy.go index 41f8f962..d4cdc16b 100644 --- a/config/internal/configbuilder/projectiondeliverypolicy.go +++ b/config/internal/configbuilder/projectiondeliverypolicy.go @@ -1,6 +1,8 @@ package configbuilder import ( + "fmt" + "github.com/dogmatiq/dogma" "github.com/dogmatiq/enginekit/config" "github.com/dogmatiq/enginekit/optional" @@ -46,8 +48,8 @@ func (b *ProjectionDeliveryPolicyBuilder) BroadcastToPrimaryFirst(v bool) { } // Partial marks the compomnent as partially configured. -func (b *ProjectionDeliveryPolicyBuilder) Partial() { - b.target.IsPartial = true +func (b *ProjectionDeliveryPolicyBuilder) Partial(format string, args ...any) { + b.target.IsPartialReasons = append(b.target.IsPartialReasons, fmt.Sprintf(format, args...)) } // Speculative marks the component as speculative. diff --git a/config/internal/configbuilder/route.go b/config/internal/configbuilder/route.go index 69ef3bb3..861299dc 100644 --- a/config/internal/configbuilder/route.go +++ b/config/internal/configbuilder/route.go @@ -1,6 +1,7 @@ package configbuilder import ( + "fmt" "reflect" "github.com/dogmatiq/dogma" @@ -67,8 +68,8 @@ func (b *RouteBuilder) MessageType(t message.Type) { } // Partial marks the compomnent as partially configured. -func (b *RouteBuilder) Partial() { - b.target.IsPartial = true +func (b *RouteBuilder) Partial(format string, args ...any) { + b.target.IsPartialReasons = append(b.target.IsPartialReasons, fmt.Sprintf(format, args...)) } // Speculative marks the component as speculative. diff --git a/config/process_test.go b/config/process_test.go index 8522422d..d5c980bb 100644 --- a/config/process_test.go +++ b/config/process_test.go @@ -106,7 +106,7 @@ func TestProcess(t *testing.T) { Name: "nil process", Error: multiline( `process is invalid:`, - ` - could not evaluate entire configuration`, + ` - could not evaluate entire configuration: handler is nil`, ` - no identity`, ` - no handles-event routes`, ` - no executes-command routes`, diff --git a/config/projection_test.go b/config/projection_test.go index 6e1dd362..12ad2495 100644 --- a/config/projection_test.go +++ b/config/projection_test.go @@ -103,7 +103,7 @@ func TestProjection(t *testing.T) { Name: "nil projection", Error: multiline( `projection is invalid:`, - ` - could not evaluate entire configuration`, + ` - could not evaluate entire configuration: handler is nil`, ` - no identity`, ` - no handles-event routes`, ), @@ -426,13 +426,13 @@ func TestProjection(t *testing.T) { t.Run("it panics if the handler is partially configured", func(t *testing.T) { handler := configbuilder.Projection( func(b *configbuilder.ProjectionBuilder) { - b.Partial() + b.Partial("") }, ) test.ExpectPanic( t, - "could not evaluate entire configuration", + `could not evaluate entire configuration: `, func() { handler.DeliveryPolicy() }, diff --git a/config/runtimeconfig/aggregate.go b/config/runtimeconfig/aggregate.go index 73db3fa3..f037abda 100644 --- a/config/runtimeconfig/aggregate.go +++ b/config/runtimeconfig/aggregate.go @@ -18,7 +18,7 @@ func FromAggregate(h dogma.AggregateMessageHandler) *config.Aggregate { func buildAggregate(b *configbuilder.AggregateBuilder, h dogma.AggregateMessageHandler) { if h == nil { - b.Partial() + b.Partial("handler is nil") } else { b.Source(h) h.Configure(newHandlerConfigurer[dogma.AggregateRoute](b)) diff --git a/config/runtimeconfig/aggregate_test.go b/config/runtimeconfig/aggregate_test.go index 9e43bfaf..f487e18b 100644 --- a/config/runtimeconfig/aggregate_test.go +++ b/config/runtimeconfig/aggregate_test.go @@ -28,7 +28,7 @@ func TestFromAggregate(t *testing.T) { HandlerCommon: config.HandlerCommon{ EntityCommon: config.EntityCommon{ ComponentCommon: config.ComponentCommon{ - IsPartial: true, + IsPartialReasons: []string{"handler is nil"}, }, }, }, diff --git a/config/runtimeconfig/application.go b/config/runtimeconfig/application.go index 29525657..a27021a0 100644 --- a/config/runtimeconfig/application.go +++ b/config/runtimeconfig/application.go @@ -12,7 +12,7 @@ func FromApplication(app dogma.Application) *config.Application { return configbuilder.Application( func(b *configbuilder.ApplicationBuilder) { if app == nil { - b.Partial() + b.Partial("application is nil") } else { b.Source(app) app.Configure(&applicationConfigurer{b}) diff --git a/config/runtimeconfig/application_test.go b/config/runtimeconfig/application_test.go index fa263639..1a0df120 100644 --- a/config/runtimeconfig/application_test.go +++ b/config/runtimeconfig/application_test.go @@ -31,7 +31,7 @@ func TestFromApplication(t *testing.T) { return &config.Application{ EntityCommon: config.EntityCommon{ ComponentCommon: config.ComponentCommon{ - IsPartial: true, + IsPartialReasons: []string{"application is nil"}, }, }, } diff --git a/config/runtimeconfig/integration.go b/config/runtimeconfig/integration.go index d3bf7adf..ed273306 100644 --- a/config/runtimeconfig/integration.go +++ b/config/runtimeconfig/integration.go @@ -18,7 +18,7 @@ func FromIntegration(h dogma.IntegrationMessageHandler) *config.Integration { func buildIntegration(b *configbuilder.IntegrationBuilder, h dogma.IntegrationMessageHandler) { if h == nil { - b.Partial() + b.Partial("handler is nil") } else { b.Source(h) h.Configure(newHandlerConfigurer[dogma.IntegrationRoute](b)) diff --git a/config/runtimeconfig/integration_test.go b/config/runtimeconfig/integration_test.go index 9068eac7..4bb0f859 100644 --- a/config/runtimeconfig/integration_test.go +++ b/config/runtimeconfig/integration_test.go @@ -26,7 +26,7 @@ func TestFromIntegration(t *testing.T) { HandlerCommon: config.HandlerCommon{ EntityCommon: config.EntityCommon{ ComponentCommon: config.ComponentCommon{ - IsPartial: true, + IsPartialReasons: []string{"handler is nil"}, }, }, }, diff --git a/config/runtimeconfig/process.go b/config/runtimeconfig/process.go index bfdcfe5b..bf1bd6aa 100644 --- a/config/runtimeconfig/process.go +++ b/config/runtimeconfig/process.go @@ -18,7 +18,7 @@ func FromProcess(h dogma.ProcessMessageHandler) *config.Process { func buildProcess(b *configbuilder.ProcessBuilder, h dogma.ProcessMessageHandler) { if h == nil { - b.Partial() + b.Partial("handler is nil") } else { b.Source(h) h.Configure(newHandlerConfigurer[dogma.ProcessRoute](b)) diff --git a/config/runtimeconfig/process_test.go b/config/runtimeconfig/process_test.go index b77c60c6..9d0a82f6 100644 --- a/config/runtimeconfig/process_test.go +++ b/config/runtimeconfig/process_test.go @@ -26,7 +26,7 @@ func TestFromProcess(t *testing.T) { HandlerCommon: config.HandlerCommon{ EntityCommon: config.EntityCommon{ ComponentCommon: config.ComponentCommon{ - IsPartial: true, + IsPartialReasons: []string{"handler is nil"}, }, }, }, diff --git a/config/runtimeconfig/projection.go b/config/runtimeconfig/projection.go index 4e303e18..832a552a 100644 --- a/config/runtimeconfig/projection.go +++ b/config/runtimeconfig/projection.go @@ -16,7 +16,7 @@ func FromProjection(h dogma.ProjectionMessageHandler) *config.Projection { func buildProjection(b *configbuilder.ProjectionBuilder, h dogma.ProjectionMessageHandler) { if h == nil { - b.Partial() + b.Partial("handler is nil") } else { b.Source(h) h.Configure(&projectionConfigurer{ diff --git a/config/runtimeconfig/projection_test.go b/config/runtimeconfig/projection_test.go index 5bc11e97..74e5b559 100644 --- a/config/runtimeconfig/projection_test.go +++ b/config/runtimeconfig/projection_test.go @@ -26,7 +26,7 @@ func TestFromProjection(t *testing.T) { HandlerCommon: config.HandlerCommon{ EntityCommon: config.EntityCommon{ ComponentCommon: config.ComponentCommon{ - IsPartial: true, + IsPartialReasons: []string{"handler is nil"}, }, }, }, diff --git a/config/staticconfig/analyze.go b/config/staticconfig/analyze.go new file mode 100644 index 00000000..06ed95cf --- /dev/null +++ b/config/staticconfig/analyze.go @@ -0,0 +1,150 @@ +package staticconfig + +import ( + "cmp" + "iter" + "slices" + + "github.com/dogmatiq/enginekit/config" + "github.com/dogmatiq/enginekit/config/staticconfig/internal/ssax" + "golang.org/x/tools/go/packages" + "golang.org/x/tools/go/ssa" + "golang.org/x/tools/go/ssa/ssautil" +) + +// Analysis encapsulates the results of static analysis. +type Analysis struct { + Applications []*config.Application + Artifacts Artifacts +} + +// Artifacts contains the intermediate results of the analysis. +type Artifacts struct { + Packages []*packages.Package + SSAProgram *ssa.Program + SSAPackages []*ssa.Package +} + +// Errors returns a sequence of errors that occurred during analysis, not +// including errors with the Dogma configuration itself. +func (a Analysis) Errors() iter.Seq[error] { + return func(yield func(error) bool) { + for _, pkg := range a.Artifacts.Packages { + for _, err := range pkg.Errors { + if !yield(err) { + return + } + } + } + } +} + +// PackagesLoadMode is the minimal [packages.LoadMode] required when loading +// packages for analysis by [Analyze]. +const PackagesLoadMode = packages.NeedFiles | + packages.NeedCompiledGoFiles | + packages.NeedImports | + packages.NeedTypes | + packages.NeedSyntax | + packages.NeedTypesInfo | + packages.NeedDeps + +// LoadAndAnalyze returns the configurations of the [dogma.Application] +// implementations in the Go package at the given directory, and its +// subdirectories. +// +// The configurations are built by statically analyzing the code, which is never +// executed. As a result, the returned configurations may be invalid or +// incomplete. See [config.Fidelity]. +func LoadAndAnalyze(dir string) Analysis { + pkgs, err := packages.Load( + &packages.Config{ + Mode: PackagesLoadMode, + Dir: dir, + }, + "./...", + ) + if err != nil { + // According to the documentation of [packages.Load], this error relates + // only to malformed patterns, which should never occur since it's + // hardcoded above. + panic(err) + } + + return Analyze(pkgs) +} + +// Analyze returns the configurations of the [dogma.Application] implementations +// in the given Go packages. +// +// The configurations are built by statically analyzing the code, which is never +// executed. As a result, the returned configurations may be invalid or +// incomplete. See [config.Fidelity]. +// +// The packages must have be loaded from source syntax using the [packages.Load] +// function using [PackagesLoadMode], at a minimum. +func Analyze(pkgs []*packages.Package) Analysis { + prog, ssaPackages := ssautil.AllPackages( + pkgs, + ssa.InstantiateGenerics| // Instantiate generic types so that we can analyze them. + ssa.SanityCheckFunctions, // TODO: document why this is necessary + + ) + + prog.Build() + + ctx := &context{ + Program: prog, + Packages: ssaPackages, + Analysis: &Analysis{ + Artifacts: struct { + Packages []*packages.Package + SSAProgram *ssa.Program + SSAPackages []*ssa.Package + }{ + pkgs, + prog, + ssaPackages, + }, + }, + } + + if !resolveDogmaPackage(ctx) { + // If the dogma package is not found as an import, none of the packages + // can possibly have types that implement [dogma.Application] because + // doing so requires referring to [dogma.ApplicationConfigurer]. + return *ctx.Analysis + } + + for _, pkg := range ctx.Packages { + if pkg == nil { + // Any [packages.Package] that can not be built results in a nil + // [ssa.Package]. We ignore any such packages so that we can still + // obtain information about applications from other valid packages. + continue + } + + // Search through all members of the package to find types that + // implement [dogma.Application]. + for _, m := range pkg.Members { + if t, ok := m.(*ssa.Type); ok { + if r, ok := ssax.Implements(t, ctx.Dogma.Application); ok { + analyzeApplicationType(ctx, r) + } + } + } + } + + // Ensure the applications are in a deterministic order. + slices.SortFunc( + ctx.Analysis.Applications, + func(a, b *config.Application) int { + return cmp.Compare( + a.String(), + b.String(), + ) + }, + ) + + return *ctx.Analysis +} diff --git a/config/staticconfig/analyze_test.go b/config/staticconfig/analyze_test.go new file mode 100644 index 00000000..9ae685b9 --- /dev/null +++ b/config/staticconfig/analyze_test.go @@ -0,0 +1,119 @@ +package staticconfig_test + +import ( + "io" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/dogmatiq/aureus" + "github.com/dogmatiq/enginekit/config" + . "github.com/dogmatiq/enginekit/config/staticconfig" +) + +func TestAnalyzer(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + // Create a single directory for the Go source code used as Aureus test + // inputs. + // + // Since it's under the testdata directory it is ignored by Go's tooling, + // but it is still subject to the same go.mod file, and hence the same + // version of Dogma, etc. + outputDir := filepath.Join( + cwd, + "testdata", + ".aureus", + strconv.Itoa(os.Getpid()), + ) + if err := os.MkdirAll(outputDir, 0700); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + os.RemoveAll(outputDir) + }) + + aureus.Run( + t, + func(t *testing.T, in aureus.Input, out aureus.Output) error { + t.Parallel() + + dir, err := os.MkdirTemp(outputDir, "aureus-") + if err != nil { + return err + } + t.Cleanup(func() { + os.RemoveAll(dir) + }) + + f, err := os.Create(filepath.Join(dir, "main.go")) + if err != nil { + return err + } + defer f.Close() + + if _, err := io.Copy(f, in); err != nil { + return err + } + + if err := f.Close(); err != nil { + return err + } + + result := LoadAndAnalyze(dir) + + hasErrors := false + for err := range result.Errors() { + hasErrors = true + + message := err.Error() + message = strings.ReplaceAll(message, dir+"/", "") + message = strings.ReplaceAll(message, dir, "") + + if _, err := io.WriteString(out, message+"\n"); err != nil { + return err + } + } + + if !hasErrors && len(result.Applications) == 0 { + _, err := io.WriteString(out, "(no applications found)\n") + return err + } + + for i, app := range result.Applications { + if i > 0 { + if _, err := io.WriteString(out, "\n"); err != nil { + return err + } + } + + // Render the details of the application. + err := config.Validate(app) + desc := config.Description( + app, + config.WithValidationResult(err), + ) + + // Remove the random portion of the temporary directory name + // so that the test output is deterministic. + rel, _ := filepath.Rel(cwd, dir) + desc = strings.ReplaceAll( + desc, + "/"+rel+".", + ".", + ) + + if _, err := io.WriteString(out, desc); err != nil { + return err + } + } + + return nil + }, + ) +} diff --git a/config/staticconfig/application.go b/config/staticconfig/application.go new file mode 100644 index 00000000..c35db7e2 --- /dev/null +++ b/config/staticconfig/application.go @@ -0,0 +1,43 @@ +package staticconfig + +import ( + "go/types" + + "github.com/dogmatiq/dogma" + "github.com/dogmatiq/enginekit/config" + "github.com/dogmatiq/enginekit/config/internal/configbuilder" +) + +// analyzeApplicationType analyzes t, which must be an implementation of +// [dogma.Application]. +func analyzeApplicationType(ctx *context, t types.Type) { + app := configbuilder.Application( + func(b *configbuilder.ApplicationBuilder) { + analyzeEntity( + ctx, + t, + b, + analyzeApplicationConfigurerCall, + ) + }, + ) + + ctx.Analysis.Applications = append(ctx.Analysis.Applications, app) +} + +func analyzeApplicationConfigurerCall( + ctx *configurerCallContext[*config.Application, dogma.Application, *configbuilder.ApplicationBuilder], +) { + switch ctx.Method.Name() { + case "RegisterAggregate": + analyzeHandler(ctx, ctx.Builder.Aggregate, nil) + case "RegisterProcess": + analyzeHandler(ctx, ctx.Builder.Process, nil) + case "RegisterIntegration": + analyzeHandler(ctx, ctx.Builder.Integration, nil) + case "RegisterProjection": + analyzeHandler(ctx, ctx.Builder.Projection, analyzeProjectionConfigurerCall) + default: + cannotAnalyzeUnrecognizedConfigurerMethod(ctx) + } +} diff --git a/config/staticconfig/context.go b/config/staticconfig/context.go new file mode 100644 index 00000000..dc715193 --- /dev/null +++ b/config/staticconfig/context.go @@ -0,0 +1,73 @@ +package staticconfig + +import ( + "fmt" + "go/types" + + "github.com/dogmatiq/enginekit/config/staticconfig/internal/ssax" + "golang.org/x/tools/go/ssa" +) + +type context struct { + Program *ssa.Program + Packages []*ssa.Package + + Dogma struct { + Package *ssa.Package + Application *types.Interface + + HandlesCommand *types.Func + ExecutesCommand *types.Func + HandlesEvent *types.Func + RecordsEvent *types.Func + SchedulesTimeout *types.Func + } + + Analysis *Analysis +} + +// resolveDogmaPackage updates ctx with information about the Dogma package. +// +// It returns false if the Dogma package has not been imported. +func resolveDogmaPackage(ctx *context) bool { + for _, pkg := range ctx.Program.AllPackages() { + if pkg.Pkg.Path() != "github.com/dogmatiq/dogma" { + continue + } + + iface := func(n string) *types.Interface { + return pkg.Pkg. + Scope(). + Lookup(n). + Type(). + Underlying().(*types.Interface) + } + + fn := func(n string) *types.Func { + return pkg.Pkg. + Scope(). + Lookup(n).(*types.Func) + } + + ctx.Dogma.Package = pkg + ctx.Dogma.Application = iface("Application") + + ctx.Dogma.HandlesCommand = fn("HandlesCommand") + ctx.Dogma.ExecutesCommand = fn("ExecutesCommand") + ctx.Dogma.HandlesEvent = fn("HandlesEvent") + ctx.Dogma.RecordsEvent = fn("RecordsEvent") + ctx.Dogma.SchedulesTimeout = fn("SchedulesTimeout") + + return true + } + + return false +} + +func (c *context) LookupMethod(t types.Type, name string) *ssa.Function { + fn := c.Program.LookupMethod(t, ssax.Package(t), name) + if fn == nil { + panic(fmt.Sprintf("method not found: %s.%s", t, name)) + } + return fn +} diff --git a/config/staticconfig/doc.go b/config/staticconfig/doc.go new file mode 100644 index 00000000..9ad9c693 --- /dev/null +++ b/config/staticconfig/doc.go @@ -0,0 +1,3 @@ +// Package staticconfig builds configuration by statically analyzing codebases +// that contain [dogma.Application] implementations. +package staticconfig diff --git a/config/staticconfig/entity.go b/config/staticconfig/entity.go new file mode 100644 index 00000000..41f1cd9e --- /dev/null +++ b/config/staticconfig/entity.go @@ -0,0 +1,238 @@ +package staticconfig + +import ( + "go/types" + "runtime" + + "github.com/dogmatiq/enginekit/config" + "github.com/dogmatiq/enginekit/config/internal/configbuilder" + "github.com/dogmatiq/enginekit/config/staticconfig/internal/ssax" + "github.com/dogmatiq/enginekit/internal/typename" + "golang.org/x/tools/go/ssa" +) + +type entityContext[ + T config.Entity, + E any, + B configbuilder.EntityBuilder[T, E], +] struct { + *context + + EntityType types.Type + Builder B + ConfigureMethod *ssa.Function + FunctionUnderAnalysis *ssa.Function + ConfigurerParamIndices []int +} + +func (c *entityContext[T, E, B]) IsConfigurer(v ssa.Value) bool { + for _, i := range c.ConfigurerParamIndices { + if v == c.FunctionUnderAnalysis.Params[i] { + return true + } + } + return false +} + +type configurerCallContext[ + T config.Entity, + E any, + B configbuilder.EntityBuilder[T, E], +] struct { + *entityContext[T, E, B] + *ssa.CallCommon + + Instruction ssa.CallInstruction + IsUnconditional bool +} + +// Apply configures b with any properties that are inferred from the context. +func (c *configurerCallContext[T, E, B]) Apply(b configbuilder.UntypedComponentBuilder) { + if !c.IsUnconditional { + b.Speculative() + } +} + +// configurerCallAnalyzer is a function that analyzes a call to a method on an +// entity's configurer. +type configurerCallAnalyzer[ + T config.Entity, + E any, + B configbuilder.EntityBuilder[T, E], +] func(*configurerCallContext[T, E, B]) + +// analyzeEntity analyzes the Configure() method of the type t, which must be a +// Dogma application or handler. +// +// It calls the analyze function for each call to a method on the configurer, +// other than Identity() which is handled the same in all cases. +func analyzeEntity[ + T config.Entity, + E any, + B configbuilder.EntityBuilder[T, E], +]( + ctx *context, + t types.Type, + builder B, + analyze configurerCallAnalyzer[T, E, B], +) { + builder.TypeName(typename.OfStatic(t)) + configure := ctx.LookupMethod(t, "Configure") + + ectx := &entityContext[T, E, B]{ + context: ctx, + EntityType: t, + Builder: builder, + ConfigureMethod: configure, + FunctionUnderAnalysis: configure, + ConfigurerParamIndices: []int{1}, + } + + fn := func(ctx *configurerCallContext[T, E, B]) { + switch ctx.Method.Name() { + case "Identity": + analyzeIdentity(ctx) + default: + analyze(ctx) + } + } + + analyzeConfigurerCallsInFunc( + ectx, + fn, + ) +} + +// analyzeConfigurerCallsInFunc analyzes calls to methods on the configurer in +// the function under analysis. +func analyzeConfigurerCallsInFunc[ + T config.Entity, + E any, + B configbuilder.EntityBuilder[T, E], +]( + ctx *entityContext[T, E, B], + analyze configurerCallAnalyzer[T, E, B], +) { + for b := range ssax.WalkFunc(ctx.FunctionUnderAnalysis) { + for _, inst := range b.Instrs { + if inst, ok := inst.(ssa.CallInstruction); ok { + analyzeConfigurerCallsInInstruction(ctx, inst, analyze) + } + } + } +} + +// analyzeConfigurerCallsInInstruction analyzes calls to methods on the +// configurer in the given instruction. +func analyzeConfigurerCallsInInstruction[ + T config.Entity, + E any, + B configbuilder.EntityBuilder[T, E], +]( + ctx *entityContext[T, E, B], + inst ssa.CallInstruction, + analyze configurerCallAnalyzer[T, E, B], +) { + call := inst.Common() + + if call.IsInvoke() && ctx.IsConfigurer(call.Value) { + analyze(&configurerCallContext[T, E, B]{ + entityContext: ctx, + CallCommon: call, + Instruction: inst, + IsUnconditional: ssax.IsUnconditional(inst.Block()), + }) + return + } + + // We've found a call to some function or method that does not belong to the + // configurer. If any of the arguments are the configurer we analyze the + // called function as well. + // + // This is an quite naive implementation. There are other ways that the + // callee could gain access to the configurer. For example, it could be + // passed inside a context, or assigned to a field within the entity struct. + // + // First, we build a list of the indices of arguments that are the + // configurer. It doesn't make much sense, but the configurer could be + // passed in multiple positions. + var indices []int + for i, arg := range call.Args { + if ctx.IsConfigurer(arg) { + indices = append(indices, i) + } + } + + // We don't analyze the callee if it is not passed the configurer. + if len(indices) == 0 { + return + } + + // If we can't obtain the callee this is a call to an interface method or + // some other un-analyzable function. + fn := call.StaticCallee() + if fn == nil { + cannotAnalyzeNonStaticCall(ctx.Builder) + return + } + + analyzeConfigurerCallsInFunc( + &entityContext[T, E, B]{ + context: ctx.context, + EntityType: ctx.EntityType, + Builder: ctx.Builder, + ConfigureMethod: ctx.ConfigureMethod, + FunctionUnderAnalysis: fn, + ConfigurerParamIndices: indices, + }, + analyze, + ) +} + +func analyzeIdentity[ + T config.Entity, + E any, + B configbuilder.EntityBuilder[T, E], +]( + ctx *configurerCallContext[T, E, B], +) { + ctx. + Builder. + Identity(func(b *configbuilder.IdentityBuilder) { + ctx.Apply(b) + + if name, ok := ssax.AsString(ctx.Args[0]).TryGet(); ok { + b.Name(name) + } + + if key, ok := ssax.AsString(ctx.Args[1]).TryGet(); ok { + b.Key(key) + } + }) +} + +func cannotAnalyzeUnrecognizedConfigurerMethod[ + T config.Entity, + E any, + B configbuilder.EntityBuilder[T, E], +]( + ctx *configurerCallContext[T, E, B], +) { + ctx.Builder.Partial( + "configuration uses %s.%s(), which is not recognized", + ctx.Value.Type(), + ctx.Method.Name(), + ) +} + +func cannotAnalyzeNonStaticCall(b configbuilder.UntypedComponentBuilder) { + b.Partial("analysis of non-static function call is not possible") +} + +func unimplementedAnalysis(b configbuilder.UntypedComponentBuilder, node any) { + if _, file, line, ok := runtime.Caller(1); ok { + b.Partial("static analysis of %T is not implemented at %s:%d", node, file, line) + } else { + b.Partial("static analysis of %T is not implemented", node) + } +} diff --git a/config/staticconfig/handler.go b/config/staticconfig/handler.go new file mode 100644 index 00000000..884024bf --- /dev/null +++ b/config/staticconfig/handler.go @@ -0,0 +1,67 @@ +package staticconfig + +import ( + "github.com/dogmatiq/dogma" + "github.com/dogmatiq/enginekit/config" + "github.com/dogmatiq/enginekit/config/internal/configbuilder" + "github.com/dogmatiq/enginekit/config/staticconfig/internal/ssax" +) + +func analyzeHandler[ + T config.Handler, + H any, + B configbuilder.HandlerBuilder[T, H], +]( + ctx *configurerCallContext[*config.Application, dogma.Application, *configbuilder.ApplicationBuilder], + build func(func(B)), + analyze configurerCallAnalyzer[T, H, B], +) { + build(func(b B) { + ctx.Apply(b) + + t := ssax.ConcreteType(ctx.Args[0]) + + if !t.IsPresent() { + b.Partial("the handler's type is unknown") + return + } + + analyzeEntity( + ctx.context, + t.Get(), + b, + func(ctx *configurerCallContext[T, H, B]) { + switch ctx.Method.Name() { + case "Routes": + analyzeRoutes(ctx) + + case "Disable": + ctx.Builder.Disabled( + func(b *configbuilder.FlagBuilder[config.Disabled]) { + ctx.Apply(b) + b.Value(true) + }, + ) + + default: + if analyze == nil { + cannotAnalyzeUnrecognizedConfigurerMethod(ctx) + } else { + analyze(ctx) + } + } + }, + ) + }) +} + +func analyzeProjectionConfigurerCall( + ctx *configurerCallContext[*config.Projection, dogma.ProjectionMessageHandler, *configbuilder.ProjectionBuilder], +) { + switch ctx.Method.Name() { + case "DeliveryPolicy": + panic("not implemented") // TODO + default: + cannotAnalyzeUnrecognizedConfigurerMethod(ctx) + } +} diff --git a/config/staticconfig/internal/ssax/block.go b/config/staticconfig/internal/ssax/block.go new file mode 100644 index 00000000..bb4786b2 --- /dev/null +++ b/config/staticconfig/internal/ssax/block.go @@ -0,0 +1,37 @@ +package ssax + +import ( + "iter" + + "github.com/dogmatiq/enginekit/optional" + "golang.org/x/tools/go/ssa" +) + +// Terminator returns the final "transfer of control" instruction in the given +// block. +// +// If the block does not contain any instructions (as is the case for external +// functions), or the terminator instruction is not of type T, ok is false. +// +// The instruction is always [ssa.If], [ssa.Jump], [ssa.Return], or [ssa.Panic]. +func Terminator[T ssa.Instruction](b *ssa.BasicBlock) optional.Optional[T] { + return optional.As[T](optional.Last(b.Instrs)) +} + +// InstructionsBefore yields all instructions in the block that precede the +// given instruction. +// +// It yields all instructions if inst is not in b. +func InstructionsBefore(b *ssa.BasicBlock, inst ssa.Instruction) iter.Seq[ssa.Instruction] { + return func(yield func(ssa.Instruction) bool) { + for _, x := range b.Instrs { + if x == inst { + return + } + + if !yield(x) { + return + } + } + } +} diff --git a/config/staticconfig/internal/ssax/const.go b/config/staticconfig/internal/ssax/const.go new file mode 100644 index 00000000..673f9c58 --- /dev/null +++ b/config/staticconfig/internal/ssax/const.go @@ -0,0 +1,72 @@ +package ssax + +import ( + "go/constant" + "math" + + "github.com/dogmatiq/enginekit/optional" + "golang.org/x/tools/go/ssa" +) + +// IsZeroValue returns true if v is a constant value that represents the zero +// value for its type. +func IsZeroValue(v ssa.Value) bool { + if c, ok := Const(v).TryGet(); ok { + return c == nil + } + return false +} + +// Const returns the singlar constant value of v if possible. +func Const(v ssa.Value) optional.Optional[constant.Value] { + return optional.TryTransform( + StaticValue(v), + func(v ssa.Value) (constant.Value, bool) { + if c, ok := v.(*ssa.Const); ok { + return c.Value, true + } + return nil, false + }, + ) +} + +// AsString returns the singular constant string value of v if possible. +func AsString(v ssa.Value) optional.Optional[string] { + return constAs(constant.StringVal, v) +} + +// AsBool returns the singular constant boolean value of v if possible. +func AsBool(v ssa.Value) optional.Optional[bool] { + return constAs(constant.BoolVal, v) +} + +// AsInt returns the singular constant integer value of v if possible. +func AsInt(v ssa.Value) optional.Optional[int] { + return optional.TryTransform( + Const(v), + func(c constant.Value) (_ int, ok bool) { + i, ok := constant.Int64Val(c) + return int(i), ok && i >= math.MinInt && i <= math.MaxInt + }, + ) +} + +// constAsX returns the constant value of v, converted to type T by fn. +func constAs[T any]( + fn func(constant.Value) T, + v ssa.Value, +) optional.Optional[T] { + return optional.TryTransform( + Const(v), + func(c constant.Value) (_ T, ok bool) { + defer func() { + if recover() != nil { + // ignore panics about type conversion + ok = false + } + }() + + return fn(c), true + }, + ) +} diff --git a/config/staticconfig/internal/ssax/doc.go b/config/staticconfig/internal/ssax/doc.go new file mode 100644 index 00000000..c59a7135 --- /dev/null +++ b/config/staticconfig/internal/ssax/doc.go @@ -0,0 +1,2 @@ +// Package ssax contains general SSA-related utilities. +package ssax diff --git a/config/staticconfig/internal/ssax/flow.go b/config/staticconfig/internal/ssax/flow.go new file mode 100644 index 00000000..41fbf6aa --- /dev/null +++ b/config/staticconfig/internal/ssax/flow.go @@ -0,0 +1,143 @@ +package ssax + +import ( + "iter" + + "golang.org/x/tools/go/ssa" +) + +// WalkFunc recursively yields all reachable blocks in the given function. +func WalkFunc(fn *ssa.Function) iter.Seq[*ssa.BasicBlock] { + return func(yield func(*ssa.BasicBlock) bool) { + if len(fn.Blocks) != 0 { + for b := range WalkBlock(fn.Blocks[0]) { + if !yield(b) { + return + } + } + } + } +} + +// WalkBlock recursively yields b and all reachable successor blocks of b. +// +// A block is considered reachable if there is a control flow path from b to +// that block that does not depend on a condition that is known to be false at +// compile-time. +func WalkBlock(b *ssa.BasicBlock) iter.Seq[*ssa.BasicBlock] { + return walk(b, DirectSuccessors) +} + +// DirectSuccessors yields the reachable direct successors of b. +func DirectSuccessors(b *ssa.BasicBlock) iter.Seq[*ssa.BasicBlock] { + return func(yield func(*ssa.BasicBlock) bool) { + successors := b.Succs + + if inst, ok := Terminator[*ssa.If](b).TryGet(); ok { + if cond, ok := AsBool(inst.Cond).TryGet(); ok { + if cond { + successors = b.Succs[:1] + } else { + successors = b.Succs[1:] + } + } + } + + for _, s := range successors { + if !yield(s) { + return + } + } + } +} + +// IsUnconditional returns true if all control-flow paths through the function +// containing b pass through b at some point. +func IsUnconditional(b *ssa.BasicBlock) bool { + return UnconditionalPathExists(b.Parent().Blocks[0], b) +} + +// PathExists returns true if, after dead-code elimnation, it's possible to +// traverse the control-flow graph from one specific node to another. +func PathExists(from, to *ssa.BasicBlock) bool { + if from.Parent() != to.Parent() { + panic("blocks are not in the same function") + } + + for b := range WalkBlock(from) { + if b == to { + return true + } + } + + return false +} + +// UnconditionalPathExists returns true if, after dead-code elimination, all +// control flow paths from one node always lead to another. +func UnconditionalPathExists(from, to *ssa.BasicBlock) bool { + if from.Parent() != to.Parent() { + panic("blocks are not in the same function") + } + + seen := map[*ssa.BasicBlock]struct{}{} + + var exists func(*ssa.BasicBlock) bool + exists = func(from *ssa.BasicBlock) bool { + if _, ok := seen[from]; ok { + return true + } + seen[from] = struct{}{} + + if from == to { + return true + } + + if len(from.Succs) == 0 { + return false + } + + for s := range DirectSuccessors(from) { + if !exists(s) { + return false + } + } + + return true + } + + return exists(from) +} + +// walk recursively yields b, and the blocks yielded by next(b). It stops +// recursing when a cycle is detected. +func walk( + b *ssa.BasicBlock, + next func(*ssa.BasicBlock) iter.Seq[*ssa.BasicBlock], +) iter.Seq[*ssa.BasicBlock] { + return func(yield func(*ssa.BasicBlock) bool) { + seen := map[*ssa.BasicBlock]struct{}{} + + var emit func(*ssa.BasicBlock) bool + emit = func(b *ssa.BasicBlock) bool { + if _, ok := seen[b]; ok { + return true + } + + seen[b] = struct{}{} + if !yield(b) { + return false + } + + for n := range next(b) { + if !emit(n) { + return false + } + } + + return true + } + + emit(b) + } +} diff --git a/config/staticconfig/internal/ssax/type.go b/config/staticconfig/internal/ssax/type.go new file mode 100644 index 00000000..bedf8a71 --- /dev/null +++ b/config/staticconfig/internal/ssax/type.go @@ -0,0 +1,139 @@ +package ssax + +import ( + "fmt" + "go/types" + + "github.com/dogmatiq/enginekit/internal/typename" + "github.com/dogmatiq/enginekit/optional" + "golang.org/x/tools/go/ssa" +) + +// Implements reports whether t implements i, regardless of whether it +// uses pointer or non-pointer method receivers. +// +// If ok is true, r is the receiver type that implements the interface, which +// may be either t or *t. +func Implements(t *ssa.Type, i *types.Interface) (r types.Type, ok bool) { + r = t.Type() + + if IsAbstract(r) { + return nil, false + } + + if types.Implements(r, i) { + return r, true + } + + r = types.NewPointer(r) + if types.Implements(r, i) { + return r, true + } + + return nil, false +} + +// IsAbstract returns true if t is abstract, either because it refers to an +// interface or because it is a generic type that has not been instantiated. +func IsAbstract(t types.Type) bool { + if types.IsInterface(t) { + return true + } + + // Check if the type is a generic type that has not been instantiated + // (meaning that it has no concrete values for its type parameters). + switch t := t.(type) { + case *types.Named: + return t.Origin() == t && t.TypeParams().Len() != 0 + case *types.Alias: + return t.Origin() == t && t.TypeParams().Len() != 0 + } + + return false +} + +// Package returns the package in which the elemental type of t is declared. +func Package(t types.Type) *types.Package { + switch t := t.(type) { + case *types.Named: + return t.Obj().Pkg() + case *types.Alias: + return t.Obj().Pkg() + case *types.Pointer: + return Package(t.Elem()) + default: + panic(fmt.Sprintf("cannot determine package for anonymous or built-in type %v", t)) + } +} + +// ConcreteType returns the concrete type of v, if it can be determined at +// compile-time. +func ConcreteType(v ssa.Value) optional.Optional[types.Type] { + t := v.Type() + + if !IsAbstract(t) { + return optional.Some(t) + } + + switch v := v.(type) { + case *ssa.Alloc: + case *ssa.BinOp: + case *ssa.Builtin: + case *ssa.Call: + call := v.Common() + r := call.Signature().Results() + + if r.Len() != 1 { + return optional.None[types.Type]() + } + + t := r.At(0).Type() + + if IsAbstract(t) { + return optional.None[types.Type]() + } + + return optional.Some(t) + + case *ssa.ChangeInterface: + case *ssa.ChangeType: + case *ssa.Const: + // We made it past the IsAbstract() check so we know this is a constant + // nil value for an interface, and hence no type information is present. + return optional.None[types.Type]() + + case *ssa.Convert: + case *ssa.Extract: + case *ssa.Field: + case *ssa.FieldAddr: + case *ssa.FreeVar: + case *ssa.Function: + case *ssa.Global: + case *ssa.Index: + case *ssa.IndexAddr: + case *ssa.Lookup: + case *ssa.MakeChan: + case *ssa.MakeClosure: + case *ssa.MakeInterface: + return ConcreteType(v.X) + + case *ssa.MakeMap: + case *ssa.MakeSlice: + case *ssa.MultiConvert: + case *ssa.Next: + case *ssa.Parameter: + case *ssa.Phi: + case *ssa.Slice: + case *ssa.SliceToArrayPointer: + case *ssa.TypeAssert: + case *ssa.UnOp: + _ = v + + case *ssa.Range, *ssa.Select: + // These types implement ssa.Value, but they can not actually be used as + // expressions in Go. + return optional.None[types.Type]() + } + + panic(fmt.Sprintf("unhandled %T of type %s", v, typename.OfStatic(t))) +} diff --git a/config/staticconfig/internal/ssax/value.go b/config/staticconfig/internal/ssax/value.go new file mode 100644 index 00000000..eb62e3c1 --- /dev/null +++ b/config/staticconfig/internal/ssax/value.go @@ -0,0 +1,140 @@ +package ssax + +import ( + "go/constant" + "go/token" + + "github.com/dogmatiq/enginekit/optional" + "golang.org/x/tools/go/ssa" +) + +// StaticValue returns the singular value of v. +// +// If v cannot be resolved to a single value, it returns an empty optional. +func StaticValue(v ssa.Value) optional.Optional[ssa.Value] { + values := staticValues(v) + if len(values) > 1 { + panic("did not expect multiple values") + } + + if len(values) == 1 { + return values[0] + } + + return optional.None[ssa.Value]() +} + +// staticValues returns the static value(s) that result from evaluating the +// given node. +// +// If an individual value within the expression cannot be resolved to a singular +// static value, it is represented as an empty optional in the returned slice. +// +// It returns an empty slice if the expression itself cannot be resolved. +func staticValues(v ssa.Value) []optional.Optional[ssa.Value] { + switch v := v.(type) { + case *ssa.Const: + return optional.Slice[ssa.Value](v) + + case *ssa.Call: + return staticValuesFromCall(v.Common()) + + case *ssa.Extract: + values := staticValues(v.Tuple) + if len(values) <= v.Index { + return nil + } + return values[v.Index : v.Index+1] + + case *ssa.MakeInterface: + return staticValues(v.X) + + case *ssa.UnOp: + if v.Op == token.MUL { // pointer de-reference + return staticValues(v.X) + } + } + + // TODO(jmalloc): This implementation is incomplete. + return nil +} + +// staticValuesFromCall returns the static value(s) that result from evaluating +// a call to a function. +// +// If an individual value within the expression cannot be resolved to a singular +// static value, it is represented as an empty value in the returned slice. +// +// It returns an empty slice if the function itself cannot be resolved. For +// example, if it is a dynamic call to an interface method. +func staticValuesFromCall(call *ssa.CallCommon) []optional.Optional[ssa.Value] { + // TODO: we could use StaticValue or some variant thereof to resolve the + // callee in more cases. + fn := call.StaticCallee() + if fn == nil { + // A call to an interface method. + return nil + } + + if len(fn.Blocks) == 0 { + // Probably an external C function. + return nil + } + + n := fn.Signature.Results().Len() + + if n == 0 { + // The function does not have any output parameters. + return nil + } + + outputs := make([]optional.Optional[ssa.Value], n) + conflicting := make([]bool, n) + + for b := range WalkBlock(fn.Blocks[0]) { + ret, ok := Terminator[*ssa.Return](b).TryGet() + if !ok { + continue + } + + for i, v := range ret.Results { + if conflicting[i] { + continue + } + + v := StaticValue(v) + if !v.IsPresent() { + continue + } + + x := outputs[i] + if !x.IsPresent() { + outputs[i] = v + continue + } + + if !equal(x.Get(), v.Get()) { + conflicting[i] = true + outputs[i] = optional.None[ssa.Value]() + } + } + } + + return outputs +} + +// equal returns true if a and b refer to the same value, or are equal constant +// values. +func equal(a, b ssa.Value) bool { + if a == b { + return true + } + + if a, ok := a.(*ssa.Const); ok { + if b, ok := b.(*ssa.Const); ok { + return constant.Compare(a.Value, token.EQL, b.Value) + } + } + + return false +} diff --git a/config/staticconfig/route.go b/config/staticconfig/route.go new file mode 100644 index 00000000..0ba8df7c --- /dev/null +++ b/config/staticconfig/route.go @@ -0,0 +1,67 @@ +package staticconfig + +import ( + "github.com/dogmatiq/enginekit/config" + "github.com/dogmatiq/enginekit/config/internal/configbuilder" + "github.com/dogmatiq/enginekit/internal/typename" + "golang.org/x/tools/go/ssa" +) + +func analyzeRoutes[ + T config.Handler, + H any, + B configbuilder.HandlerBuilder[T, H], +]( + ctx *configurerCallContext[T, H, B], +) { + analyzeVariadicArguments( + ctx, + ctx.Builder.Route, + analyzeRoute, + ) +} + +func analyzeRoute[ + T config.Handler, + H any, + B configbuilder.HandlerBuilder[T, H], +]( + ctx *configurerCallContext[T, H, B], + b *configbuilder.RouteBuilder, + r ssa.Value, +) { + switch r := r.(type) { + case *ssa.Const: + // We've found a nil route. + + case *ssa.MakeInterface: + analyzeRoute(ctx, b, r.X) + + case *ssa.Call: + call := r.Common() + fn := call.StaticCallee() + + if fn == nil { + cannotAnalyzeNonStaticCall(b) + return + } + + switch fn.Object() { + case ctx.Dogma.HandlesCommand: + b.RouteType(config.HandlesCommandRouteType) + case ctx.Dogma.HandlesEvent: + b.RouteType(config.HandlesEventRouteType) + case ctx.Dogma.ExecutesCommand: + b.RouteType(config.ExecutesCommandRouteType) + case ctx.Dogma.RecordsEvent: + b.RouteType(config.RecordsEventRouteType) + case ctx.Dogma.SchedulesTimeout: + b.RouteType(config.SchedulesTimeoutRouteType) + } + + b.MessageTypeName(typename.OfStatic(fn.TypeArgs()[0])) + + default: + unimplementedAnalysis(b, r) + } +} diff --git a/config/staticconfig/testdata/.gitignore b/config/staticconfig/testdata/.gitignore new file mode 100644 index 00000000..ce4c89b6 --- /dev/null +++ b/config/staticconfig/testdata/.gitignore @@ -0,0 +1 @@ +.aureus/ diff --git a/config/staticconfig/testdata/_handler-adaptor-constructor.md b/config/staticconfig/testdata/_handler-adaptor-constructor.md new file mode 100644 index 00000000..53d230e1 --- /dev/null +++ b/config/staticconfig/testdata/_handler-adaptor-constructor.md @@ -0,0 +1,43 @@ +# Handler adaptor function + +This test ensures that the static analyzer can analyze configuration logic that +is implemented within a type that has a `Configure()` method with the same +signature as the handler interface. + +This is a naive implementation that was originalled added to allow analysis of +the various adaptors in the [projectionkit] module. + +[projectionkit]: https://github.com/dogmatiq/projectionkit + +```au:output au:group=matrix +valid application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - valid identity app/24c4a011-3d9e-493a-95c6-ef9ab059f65f + - valid integration github.com/dogmatiq/enginekit/config/staticconfig.Adaptor[github.com/dogmatiq/enginekit/config/staticconfig.impl] (value unavailable) + - valid identity integration/a57834ad-251a-4672-9b82-f2a538a64655 + - valid handles-command route for github.com/dogmatiq/enginekit/enginetest/stubs.CommandStub[github.com/dogmatiq/enginekit/enginetest/stubs.TypeA] (type unavailable) +``` + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" +import "github.com/dogmatiq/enginekit/enginetest/stubs" + +func NewAdaptor(any) dogma.IntegrationMessageHandler { + panic("not implemented") +} + +type impl struct {} + +func (impl) Configure(c dogma.IntegrationConfigurer) { + c.Identity("integration", "a57834ad-251a-4672-9b82-f2a538a64655") + c.Routes(dogma.HandlesCommand[stubs.CommandStub[stubs.TypeA]]()) +} + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "24c4a011-3d9e-493a-95c6-ef9ab059f65f") + c.RegisterIntegration(NewAdaptor(impl{})) +} +``` diff --git a/config/staticconfig/testdata/_pending/handler-adaptor-test.md b/config/staticconfig/testdata/_pending/handler-adaptor-test.md new file mode 100644 index 00000000..ce495420 --- /dev/null +++ b/config/staticconfig/testdata/_pending/handler-adaptor-test.md @@ -0,0 +1,58 @@ +# Handler Adaptors + +This test verifies that static analysis correctly parses handler adaptors. + +```go au:input au:group=matrix +package app + +import ( + . "github.com/dogmatiq/dogma" + "github.com/dogmatiq/enginekit/enginetest/stubs" +) + +// App implements Application interface. +type App struct{} + +// Configure configures the behavior of the engine as it relates to this +// application. +func (App) Configure(c ApplicationConfigurer) { + c.Identity("", "f610eae4-f5d0-4eea-a9c9-6cbbfa9b2060") + + c.RegisterIntegration(AdaptIntegration(IntegrationHandler{})) +} + +// IntegrationHandler is the type that provides the handler logic, but is not +// itself an implementation of IntegrationMessageHandler. +type IntegrationHandler struct{} + +// Configure configures the behavior of the engine as it relates to this +// handler. +func (IntegrationHandler) Configure(c IntegrationConfigurer) { + c.Identity("", "099b5b8d-9e04-422f-bcc3-bb0d451158c7") + + c.Routes( + HandlesCommand[stubs.CommandStub[stubs.TypeI]](), + RecordsEvent[stubs.EventStub[stubs.TypeI]](), + ) +} + +// PartialIntegrationMessageHandler is the subset of +// IntegrationMessageHandler that must be implemented for a type to be +// detected as a concrete implementation. +type PartialIntegrationMessageHandler interface { + Configure(c IntegrationConfigurer) +} + +// AdaptIntegration adapts the argument to the IntegrationMessageHandler interface. +func AdaptIntegration(PartialIntegrationMessageHandler) IntegrationMessageHandler { + panic("the implementation of this function is irrelevant to the analyzer") +} +``` + +```au:output au:group=matrix +application (f610eae4-f5d0-4eea-a9c9-6cbbfa9b2060) App + + - integration (099b5b8d-9e04-422f-bcc3-bb0d451158c7) IntegrationHandler + handles CommandStub[TypeI]? + records EventStub[TypeI]! +``` diff --git a/config/staticconfig/testdata/_pending/handler-constructor-test.md b/config/staticconfig/testdata/_pending/handler-constructor-test.md new file mode 100644 index 00000000..4ef88110 --- /dev/null +++ b/config/staticconfig/testdata/_pending/handler-constructor-test.md @@ -0,0 +1,59 @@ +# Handler constructors + +This test verifies that static analysis correctly parses handler constructors. + +```go au:input au:group=matrix +package app + +import ( + "context" + . "github.com/dogmatiq/dogma" + "github.com/dogmatiq/enginekit/enginetest/stubs" +) + +// App implements Application interface. +type App struct{} + +// Configure configures the behavior of the engine as it relates to this +// application. +func (App) Configure(c ApplicationConfigurer) { + c.Identity("", "3bc3849b-abe0-4c4e-9db4-e48dc28c9a26") + + c.RegisterIntegration(NewIntegrationHandler()) +} + +// IntegrationHandler is a test implementation of +// IntegrationMessageHandler. +type IntegrationHandler struct{} + +// NewIntegrationHandler returns a new IntegrationHandler. +func NewIntegrationHandler() IntegrationHandler { + panic("the implementation of this function is irrelevant to the analyzer") +} + +// Configure configures the behavior of the engine as it relates to this +// handler. +func (IntegrationHandler) Configure(c IntegrationConfigurer) { + c.Identity("", "099b5b8d-9e04-422f-bcc3-bb0d451158c7") + + c.Routes( + HandlesCommand[stubs.CommandStub[stubs.TypeB]](), + ) +} + +// HandleCommand handles a command message that has been routed to this handler. +func (IntegrationHandler) HandleCommand( + context.Context, + IntegrationCommandScope, + Command, +) error { + return nil +} +``` + +```au:output au:group=matrix +application (3bc3849b-abe0-4c4e-9db4-e48dc28c9a26) App + + - integration (099b5b8d-9e04-422f-bcc3-bb0d451158c7) IntegrationHandler + handles CommandStub[TypeB]? +``` diff --git a/config/staticconfig/testdata/_pending/handler-from-field.md b/config/staticconfig/testdata/_pending/handler-from-field.md new file mode 100644 index 00000000..118b4ccf --- /dev/null +++ b/config/staticconfig/testdata/_pending/handler-from-field.md @@ -0,0 +1,46 @@ +# Handler from struct field + +This test ensures that the static analyzer can recognized the type of a handler +when it is registered using the value of a struct field, rather than constructed +inline. + +```go au:input au:group=matrix +package app + +import ( + "context" + . "github.com/dogmatiq/dogma" + . "github.com/dogmatiq/enginekit/enginetest/stubs" +) + +type App struct { + Field Handler +} + +func (a App) Configure(c ApplicationConfigurer) { + c.Identity("", "7468a57f-20f0-4d11-9aad-48fcd553a908") + c.RegisterIntegration(a.Field) +} + +type Handler struct{} + +func (Handler) Configure(c IntegrationConfigurer) { + c.Identity("", "195ede4a-3f26-4d19-a8fe-41b2a5f92d06") + c.Routes(HandlesCommand[CommandStub[TypeA]]()) +} + +func (Handler) HandleCommand( + context.Context, + IntegrationCommandScope, + Command, +) error{ + return nil +} +``` + +```au:output au:group=matrix +application (7468a57f-20f0-4d11-9aad-48fcd553a908) App + + - integration (195ede4a-3f26-4d19-a8fe-41b2a5f92d06) Handler + handles CommandStub[TypeA]? +``` diff --git a/config/staticconfig/testdata/_pending/non-pointer-handlers-registered-as-pointers.md b/config/staticconfig/testdata/_pending/non-pointer-handlers-registered-as-pointers.md new file mode 100644 index 00000000..260397ac --- /dev/null +++ b/config/staticconfig/testdata/_pending/non-pointer-handlers-registered-as-pointers.md @@ -0,0 +1,58 @@ +# Non-pointer Handlers Registered in a Dogma Application as Pointers. + +This test verifies that static analysis can correctly parse non-pointer handlers +registered in a dogma application as pointers using 'address-of' operator. + +```go au:input au:group=matrix +package app + +import ( + "context" + . "github.com/dogmatiq/dogma" + "github.com/dogmatiq/enginekit/enginetest/stubs" +) + +// App implements Application interface. +type App struct{} + +// Configure configures the behavior of the engine as it relates to this +// application. +func (App) Configure(c ApplicationConfigurer) { + c.Identity( + "", + "282653ad-9343-44f1-889e-a8b2b095b54b", + ) + + c.RegisterIntegration(&IntegrationHandler{}) +} + +// IntegrationHandler is a test implementation of +// IntegrationMessageHandler. +type IntegrationHandler struct{} + +// Configure configures the behavior of the engine as it relates to this +// handler. +func (IntegrationHandler) Configure(c IntegrationConfigurer) { + c.Identity("", "1425ca64-0448-4bfd-b18d-9fe63a95995f") + + c.Routes( + HandlesCommand[stubs.CommandStub[stubs.TypeB]](), + ) +} + +// HandleCommand handles a command message that has been routed to this handler. +func (IntegrationHandler) HandleCommand( + context.Context, + IntegrationCommandScope, + Command, +) error { + return nil +} +``` + +```au:output au:group=matrix +application (282653ad-9343-44f1-889e-a8b2b095b54b) App + + - integration (1425ca64-0448-4bfd-b18d-9fe63a95995f) *IntegrationHandler + handles CommandStub[TypeB]? +``` diff --git a/config/staticconfig/testdata/conditional-excluded-by-const-expr.md b/config/staticconfig/testdata/conditional-excluded-by-const-expr.md new file mode 100644 index 00000000..149c8cb4 --- /dev/null +++ b/config/staticconfig/testdata/conditional-excluded-by-const-expr.md @@ -0,0 +1,58 @@ +# Conditional with constant expression that excludes configuration + +This test verifies that the static analyzer excludes information about an +entity's identity if it appears in an unreachable branch. + +```au:output au:group=matrix +invalid application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - no identity +``` + +## After conditional return + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + if true { + return + } + + c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") +} +``` + +## Within conditional block + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + if false { + c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") + } +} +``` + +## In defer that is never scheduled + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + panic("prevent defer") + defer c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") +} +``` diff --git a/config/staticconfig/testdata/conditional-included-by-const-expr.md b/config/staticconfig/testdata/conditional-included-by-const-expr.md new file mode 100644 index 00000000..209402dc --- /dev/null +++ b/config/staticconfig/testdata/conditional-included-by-const-expr.md @@ -0,0 +1,80 @@ +# Conditional with constant expression that includes configuration + +This test verifies that the static analyzer includes information about an +entity's identity if it appears in a conditional block that is always executed. +Note that the identity is not marked as "speculative". + +```au:output au:group=matrix +valid application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - valid identity app/de142370-93ee-409c-9336-5084d9c5e285 +``` + +## After conditional return + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + if false { + return + } + + c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") +} +``` + +## Within conditional block + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + if true { + c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") + } +} +``` + +## In defer that is scheduled conditionally + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + if true { + defer c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") + } +} +``` + +## If statement with non-const static condition + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + if cond() { + c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") + } +} + +func cond() bool { + return true +} +``` diff --git a/config/staticconfig/testdata/conditional-present-in-method.md b/config/staticconfig/testdata/conditional-present-in-method.md new file mode 100644 index 00000000..cfc36d0b --- /dev/null +++ b/config/staticconfig/testdata/conditional-present-in-method.md @@ -0,0 +1,105 @@ +# Unconditional identity after conditional statement + +This test verifies that the static analyzer includes information about an +entity's identity even if it appears after (but not within) a conditional +statement. + +```au:output au:group=matrix +valid application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - valid identity app/de142370-93ee-409c-9336-5084d9c5e285 +``` + +## If statement + +```go au:input au:group=matrix +package app + +import "math/rand" +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + if rand.Int() == 0 { + } + + c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") +} +``` + +## Else statement + +```go au:input au:group=matrix +package app + +import "math/rand" +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + if rand.Int() == 0 { + } else { + } + + c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") +} +``` + +## Switch statement + +```go au:input au:group=matrix +package app + +import "math/rand" +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + switch rand.Int() { + case 0: + } + + c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") +} +``` + +## For statement + +```go au:input au:group=matrix +package app + +import "math/rand" +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + for range rand.Int() { + } + + c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") +} +``` + +## Select statement + +```go au:input au:group=matrix +package app + +import "math/rand" +import "time" +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + select { + case <-time.After(time.Duration(rand.Int())): + default: + } + + c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") +} +``` diff --git a/config/staticconfig/testdata/conditional.md b/config/staticconfig/testdata/conditional.md new file mode 100644 index 00000000..eb74f120 --- /dev/null +++ b/config/staticconfig/testdata/conditional.md @@ -0,0 +1,154 @@ +# Conditional identity + +This test verifies that the static analyzer includes information about an +entity's identity when it is defined within a conditional statement. + +```au:output au:group=matrix +valid application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - valid speculative identity app/de142370-93ee-409c-9336-5084d9c5e285 +``` + +## If statement + +```go au:input au:group=matrix +package app + +import "math/rand" +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + if rand.Int() == 0 { + c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") + } +} +``` + +## Else statement + +```go au:input au:group=matrix +package app + +import "math/rand" +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + if rand.Int() == 0 { + } else { + c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") + } +} +``` + +## After conditional return + +```go au:input au:group=matrix +package app + +import "math/rand" +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + if rand.Int() == 0 { + return + } + + c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") +} +``` + +## After conditonal panic + +```go au:input au:group=matrix +package app + +import "math/rand" +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + if rand.Int() == 0 { + panic("oh no") + } + + c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") +} +``` + +## Switch statement + +```go au:input au:group=matrix +package app + +import "math/rand" +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + switch rand.Int() { + case 0: + c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") + } +} +``` + +## For statement + +```go au:input au:group=matrix +package app + +import "math/rand" +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + for range rand.Int() { + c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") + } +} +``` + +## Select statement + +```go au:input au:group=matrix +package app + +import "math/rand" +import "time" +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + select { + case <-time.After(time.Duration(rand.Int())): + c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") + default: + } +} +``` + +## Deferred + +```go au:input au:group=matrix +package app + +import "math/rand" +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + if rand.Int() == 0 { + defer c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") + } +} +``` diff --git a/config/staticconfig/testdata/generic-app.md b/config/staticconfig/testdata/generic-app.md new file mode 100644 index 00000000..65b93fea --- /dev/null +++ b/config/staticconfig/testdata/generic-app.md @@ -0,0 +1,43 @@ +# Generic application + +This test ensures that the static analyzer finds an instantiated generic type +that implements the `dogma.Application` interface. + +```au:output au:group=matrix +valid application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - valid identity app/8a6baab1-ee64-402e-a081-e43f4bebc243 +``` + +## Instatiated using a type alias + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +type App = AppImpl[int] + +type AppImpl[T any] struct{} + +func (AppImpl[T]) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "8a6baab1-ee64-402e-a081-e43f4bebc243") +} +``` + +## Instatiated by embedding in a named struct + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +type App struct { + AppImpl[int] +} + +type AppImpl[T any] struct{} + +func (AppImpl[T]) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "8a6baab1-ee64-402e-a081-e43f4bebc243") +} +``` diff --git a/config/staticconfig/testdata/generic-handler-via-alias.md b/config/staticconfig/testdata/generic-handler-via-alias.md new file mode 100644 index 00000000..b9826cd7 --- /dev/null +++ b/config/staticconfig/testdata/generic-handler-via-alias.md @@ -0,0 +1,38 @@ +# Generic handler via alias + +This test ensures that the static analyzer can analyze handlers that are +implemented using generic types when registered using an alias. + +```au:output au:group=matrix +valid application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - valid identity app/3109677f-5ed5-4a30-86a1-9975273c5a38 + - valid integration github.com/dogmatiq/enginekit/config/staticconfig.Alias (value unavailable) + - valid identity handler/40393d25-f95a-46ea-8702-068643c20ed6 + - valid handles-command route for github.com/dogmatiq/enginekit/enginetest/stubs.CommandStub[github.com/dogmatiq/enginekit/enginetest/stubs.TypeA] (type unavailable) +``` + +```go au:input au:group=matrix +package app + +import "context" +import "github.com/dogmatiq/dogma" +import "github.com/dogmatiq/enginekit/enginetest/stubs" + +type Alias = Integration[stubs.CommandStub[stubs.TypeA]] + +type Integration[T dogma.Command] struct{} + +func (Integration[T]) Configure(c dogma.IntegrationConfigurer) { + c.Identity("handler", "40393d25-f95a-46ea-8702-068643c20ed6") + c.Routes(dogma.HandlesCommand[T]()) +} + +func (Integration[T]) HandleCommand(context.Context, dogma.IntegrationCommandScope, dogma.Command) error { return nil } + +type App struct{} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "3109677f-5ed5-4a30-86a1-9975273c5a38") + c.RegisterIntegration(Alias{}) +} +``` diff --git a/config/staticconfig/testdata/generic-handler.md b/config/staticconfig/testdata/generic-handler.md new file mode 100644 index 00000000..1243d4dc --- /dev/null +++ b/config/staticconfig/testdata/generic-handler.md @@ -0,0 +1,36 @@ +# Generic handler + +This test ensures that the static analyzer can analyze handlers that are +implemented using generic types. + +```au:output au:group=matrix +valid application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - valid identity app/3109677f-5ed5-4a30-86a1-9975273c5a38 + - valid integration github.com/dogmatiq/enginekit/config/staticconfig.Integration[github.com/dogmatiq/enginekit/enginetest/stubs.CommandStub[github.com/dogmatiq/enginekit/enginetest/stubs.TypeA]] (value unavailable) + - valid identity handler/40393d25-f95a-46ea-8702-068643c20ed6 + - valid handles-command route for github.com/dogmatiq/enginekit/enginetest/stubs.CommandStub[github.com/dogmatiq/enginekit/enginetest/stubs.TypeA] (type unavailable) +``` + +```go au:input au:group=matrix +package app + +import "context" +import "github.com/dogmatiq/dogma" +import "github.com/dogmatiq/enginekit/enginetest/stubs" + +type Integration[T dogma.Command] struct{} + +func (Integration[T]) Configure(c dogma.IntegrationConfigurer) { + c.Identity("handler", "40393d25-f95a-46ea-8702-068643c20ed6") + c.Routes(dogma.HandlesCommand[T]()) +} + +func (Integration[T]) HandleCommand(context.Context, dogma.IntegrationCommandScope, dogma.Command) error { return nil } + +type App struct{} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "3109677f-5ed5-4a30-86a1-9975273c5a38") + c.RegisterIntegration(Integration[stubs.CommandStub[stubs.TypeA]]{}) +} +``` diff --git a/config/staticconfig/testdata/handler-adaptor-generic.md b/config/staticconfig/testdata/handler-adaptor-generic.md new file mode 100644 index 00000000..8b4f3f51 --- /dev/null +++ b/config/staticconfig/testdata/handler-adaptor-generic.md @@ -0,0 +1,42 @@ +# Generic handler adaptor + +This test ensures that the static analyzer can analyze configuration logic that +is implemented within a type that is passed to a handler as a type parameter. + +```au:output au:group=matrix +valid application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - valid identity app/24c4a011-3d9e-493a-95c6-ef9ab059f65f + - valid integration github.com/dogmatiq/enginekit/config/staticconfig.Adaptor[github.com/dogmatiq/enginekit/config/staticconfig.impl] (value unavailable) + - valid identity integration/a57834ad-251a-4672-9b82-f2a538a64655 + - valid handles-command route for github.com/dogmatiq/enginekit/enginetest/stubs.CommandStub[github.com/dogmatiq/enginekit/enginetest/stubs.TypeA] (type unavailable) +``` + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" +import "github.com/dogmatiq/enginekit/enginetest/stubs" + +type Adaptor[T interface { Configure(dogma.IntegrationConfigurer) }] struct { + dogma.IntegrationMessageHandler + Impl T +} + +func (a Adaptor[T]) Configure(c dogma.IntegrationConfigurer) { + a.Impl.Configure(c) +} + +type impl struct {} + +func (impl) Configure(c dogma.IntegrationConfigurer) { + c.Identity("integration", "a57834ad-251a-4672-9b82-f2a538a64655") + c.Routes(dogma.HandlesCommand[stubs.CommandStub[stubs.TypeA]]()) +} + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "24c4a011-3d9e-493a-95c6-ef9ab059f65f") + c.RegisterIntegration(Adaptor[impl]{}) +} +``` diff --git a/config/staticconfig/testdata/handler-aggregate.md b/config/staticconfig/testdata/handler-aggregate.md new file mode 100644 index 00000000..0faa880a --- /dev/null +++ b/config/staticconfig/testdata/handler-aggregate.md @@ -0,0 +1,43 @@ +# Aggregate message handler + +This test ensures that the static analyzer supports all aspects of configuring +an aggregate handler. + +```au:output au:group=matrix +valid application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - valid identity app/0726ae0d-67e4-4a50-8a19-9f58eae38e51 + - valid aggregate github.com/dogmatiq/enginekit/config/staticconfig.Aggregate (value unavailable) + - valid identity aggregate/916e5e95-70c4-4823-9de2-0f7389d18b4f + - valid handles-command route for github.com/dogmatiq/enginekit/enginetest/stubs.CommandStub[github.com/dogmatiq/enginekit/enginetest/stubs.TypeA] (type unavailable) + - valid records-event route for github.com/dogmatiq/enginekit/enginetest/stubs.EventStub[github.com/dogmatiq/enginekit/enginetest/stubs.TypeA] (type unavailable) + - valid disabled flag, set to true +``` + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" +import "github.com/dogmatiq/enginekit/enginetest/stubs" + +type Aggregate struct {} + +func (Aggregate) Configure(c dogma.AggregateConfigurer) { + c.Identity("aggregate", "916e5e95-70c4-4823-9de2-0f7389d18b4f") + c.Routes( + dogma.HandlesCommand[stubs.CommandStub[stubs.TypeA]](), + dogma.RecordsEvent[stubs.EventStub[stubs.TypeA]](), + ) + c.Disable() +} + +type App struct{} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "0726ae0d-67e4-4a50-8a19-9f58eae38e51") + c.RegisterAggregate(Aggregate{}) +} + +func (Aggregate) New() dogma.AggregateRoot { return nil } +func (Aggregate) RouteCommandToInstance(dogma.Command) string { return "" } +func (Aggregate) HandleCommand(dogma.AggregateRoot, dogma.AggregateCommandScope, dogma.Command) {} +``` diff --git a/config/staticconfig/testdata/handler-integration.md b/config/staticconfig/testdata/handler-integration.md new file mode 100644 index 00000000..0baa37a3 --- /dev/null +++ b/config/staticconfig/testdata/handler-integration.md @@ -0,0 +1,42 @@ +# Integration message handler + +This test ensures that the static analyzer supports all aspects of configuring +an integration handler. + +```au:output au:group=matrix +valid application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - valid identity app/0726ae0d-67e4-4a50-8a19-9f58eae38e51 + - valid integration github.com/dogmatiq/enginekit/config/staticconfig.Integration (value unavailable) + - valid identity integration/b92431e6-3a7d-4235-a76f-541622c487ee + - valid handles-command route for github.com/dogmatiq/enginekit/enginetest/stubs.CommandStub[github.com/dogmatiq/enginekit/enginetest/stubs.TypeA] (type unavailable) + - valid records-event route for github.com/dogmatiq/enginekit/enginetest/stubs.EventStub[github.com/dogmatiq/enginekit/enginetest/stubs.TypeA] (type unavailable) + - valid disabled flag, set to true +``` + +```go au:input au:group=matrix +package app + +import "context" +import "github.com/dogmatiq/dogma" +import "github.com/dogmatiq/enginekit/enginetest/stubs" + +type Integration struct {} + +func (Integration) Configure(c dogma.IntegrationConfigurer) { + c.Identity("integration", "b92431e6-3a7d-4235-a76f-541622c487ee") + c.Routes( + dogma.HandlesCommand[stubs.CommandStub[stubs.TypeA]](), + dogma.RecordsEvent[stubs.EventStub[stubs.TypeA]](), + ) + c.Disable() +} + +type App struct{} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "0726ae0d-67e4-4a50-8a19-9f58eae38e51") + c.RegisterIntegration(Integration{}) +} + +func (Integration) HandleCommand(context.Context, dogma.IntegrationCommandScope, dogma.Command) error { return nil } +``` diff --git a/config/staticconfig/testdata/handler-process.md b/config/staticconfig/testdata/handler-process.md new file mode 100644 index 00000000..adcd082b --- /dev/null +++ b/config/staticconfig/testdata/handler-process.md @@ -0,0 +1,47 @@ +# Process message handler + +This test ensures that the static analyzer supports all aspects of configuring +a process handler. + +```au:output au:group=matrix +valid application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - valid identity app/0726ae0d-67e4-4a50-8a19-9f58eae38e51 + - valid process github.com/dogmatiq/enginekit/config/staticconfig.Process (value unavailable) + - valid identity process/4ff1b1c1-5c64-49bc-a547-c13f5bafad7d + - valid handles-event route for github.com/dogmatiq/enginekit/enginetest/stubs.EventStub[github.com/dogmatiq/enginekit/enginetest/stubs.TypeA] (type unavailable) + - valid executes-command route for github.com/dogmatiq/enginekit/enginetest/stubs.CommandStub[github.com/dogmatiq/enginekit/enginetest/stubs.TypeA] (type unavailable) + - valid schedules-timeout route for github.com/dogmatiq/enginekit/enginetest/stubs.TimeoutStub[github.com/dogmatiq/enginekit/enginetest/stubs.TypeA] (type unavailable) + - valid disabled flag, set to true +``` + +```go au:input au:group=matrix +package app + +import "context" +import "github.com/dogmatiq/dogma" +import "github.com/dogmatiq/enginekit/enginetest/stubs" + +type Process struct {} + +func (Process) Configure(c dogma.ProcessConfigurer) { + c.Identity("process", "4ff1b1c1-5c64-49bc-a547-c13f5bafad7d") + c.Routes( + dogma.HandlesEvent[stubs.EventStub[stubs.TypeA]](), + dogma.ExecutesCommand[stubs.CommandStub[stubs.TypeA]](), + dogma.SchedulesTimeout[stubs.TimeoutStub[stubs.TypeA]](), + ) + c.Disable() +} + +type App struct{} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "0726ae0d-67e4-4a50-8a19-9f58eae38e51") + c.RegisterProcess(Process{}) +} + +func (Process) New() dogma.ProcessRoot { return nil } +func (Process) RouteEventToInstance(context.Context, dogma.Event) (string, bool, error) { return "", false, nil } +func (Process) HandleEvent(context.Context, dogma.ProcessRoot, dogma.ProcessEventScope, dogma.Event) error { return nil } +func (Process) HandleTimeout(context.Context, dogma.ProcessRoot, dogma.ProcessTimeoutScope, dogma.Timeout) error { return nil } +``` diff --git a/config/staticconfig/testdata/handler-projection.md b/config/staticconfig/testdata/handler-projection.md new file mode 100644 index 00000000..49025093 --- /dev/null +++ b/config/staticconfig/testdata/handler-projection.md @@ -0,0 +1,46 @@ +# Projection message handler + +This test ensures that the static analyzer supports all aspects of configuring +a projection handler. + +```au:output au:group=matrix +valid application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - valid identity app/0726ae0d-67e4-4a50-8a19-9f58eae38e51 + - valid projection github.com/dogmatiq/enginekit/config/staticconfig.Projection (value unavailable) + - valid identity projection/238d7498-515b-44b5-b6a8-914a08762ecc + - valid handles-event route for github.com/dogmatiq/enginekit/enginetest/stubs.EventStub[github.com/dogmatiq/enginekit/enginetest/stubs.TypeA] (type unavailable) + - valid disabled flag, set to true +``` + +```go au:input au:group=matrix +package app + +import "context" +import "github.com/dogmatiq/dogma" +import "github.com/dogmatiq/enginekit/enginetest/stubs" + +type Projection struct {} + +func (Projection) Configure(c dogma.ProjectionConfigurer) { + c.Identity("projection", "238d7498-515b-44b5-b6a8-914a08762ecc") + c.Routes( + dogma.HandlesEvent[stubs.EventStub[stubs.TypeA]](), + ) + // c.DeliveryPolicy(dogma.BroadcastProjectionDeliveryPolicy{ + // PrimaryFirst: true, + // }) + c.Disable() +} + +type App struct{} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "0726ae0d-67e4-4a50-8a19-9f58eae38e51") + c.RegisterProjection(Projection{}) +} + +func (Projection) HandleEvent(context.Context, []byte, []byte, []byte, dogma.ProjectionEventScope, dogma.Event) (bool, error) { return false, nil } +func (Projection) Compact(context.Context, dogma.ProjectionCompactScope) error { return nil } +func (Projection) ResourceVersion(context.Context, []byte) ([]byte, error) +func (Projection) CloseResource(context.Context, []byte) error +``` diff --git a/config/staticconfig/testdata/handler-with-conditional-routes.md b/config/staticconfig/testdata/handler-with-conditional-routes.md new file mode 100644 index 00000000..d00ee0ad --- /dev/null +++ b/config/staticconfig/testdata/handler-with-conditional-routes.md @@ -0,0 +1,143 @@ +# Handler with conditional routes + +This test verifies that the static analyzer correctly identifies when routes are +added to a handler conditionally. + +```au:output au:group=matrix +valid application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - valid identity app/e4183527-c234-42d5-8709-3dc8b9d5caa4 + - valid integration github.com/dogmatiq/enginekit/config/staticconfig.Integration (value unavailable) + - valid identity handler/5752bb84-0b65-4a7f-b2fa-bfb77a53a97f + - valid speculative handles-command route for github.com/dogmatiq/enginekit/enginetest/stubs.CommandStub[github.com/dogmatiq/enginekit/enginetest/stubs.TypeA] (type unavailable) + - valid speculative records-event route for github.com/dogmatiq/enginekit/enginetest/stubs.EventStub[github.com/dogmatiq/enginekit/enginetest/stubs.TypeA] (type unavailable) + - valid speculative handles-command route for github.com/dogmatiq/enginekit/enginetest/stubs.CommandStub[github.com/dogmatiq/enginekit/enginetest/stubs.TypeB] (type unavailable) + - valid speculative records-event route for github.com/dogmatiq/enginekit/enginetest/stubs.EventStub[github.com/dogmatiq/enginekit/enginetest/stubs.TypeB] (type unavailable) +``` + +## Routes() call within conditional block + +```go au:input au:group=matrix +package app + +import "context" +import "math/rand" +import "github.com/dogmatiq/dogma" +import "github.com/dogmatiq/enginekit/enginetest/stubs" + +type Integration struct{} + +func (Integration) Configure(c dogma.IntegrationConfigurer) { + c.Identity("handler", "5752bb84-0b65-4a7f-b2fa-bfb77a53a97f") + + if rand.Int() == 0 { + c.Routes( + dogma.HandlesCommand[stubs.CommandStub[stubs.TypeA]](), + dogma.RecordsEvent[stubs.EventStub[stubs.TypeA]](), + ) + } else { + c.Routes( + dogma.HandlesCommand[stubs.CommandStub[stubs.TypeB]](), + dogma.RecordsEvent[stubs.EventStub[stubs.TypeB]](), + ) + } +} + +func (Integration) HandleCommand(context.Context, dogma.IntegrationCommandScope, dogma.Command) error { return nil } + +type App struct{} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "e4183527-c234-42d5-8709-3dc8b9d5caa4") + c.RegisterIntegration(Integration{}) +} +``` + +## Slice built within conditional block + +```go au:input au:group=matrix +package app + +import "context" +import "math/rand" +import "github.com/dogmatiq/dogma" +import "github.com/dogmatiq/enginekit/enginetest/stubs" + +type Integration struct{} + +func (Integration) Configure(c dogma.IntegrationConfigurer) { + c.Identity("handler", "5752bb84-0b65-4a7f-b2fa-bfb77a53a97f") + + var routes []dogma.IntegrationRoute + + if rand.Int() == 0 { + routes = append( + routes, + dogma.HandlesCommand[stubs.CommandStub[stubs.TypeA]](), + dogma.RecordsEvent[stubs.EventStub[stubs.TypeA]](), + ) + } else { + routes = append( + routes, + dogma.HandlesCommand[stubs.CommandStub[stubs.TypeB]](), + dogma.RecordsEvent[stubs.EventStub[stubs.TypeB]](), + ) + } + + c.Routes(routes...) +} + +func (Integration) HandleCommand(context.Context, dogma.IntegrationCommandScope, dogma.Command) error { return nil } + +type App struct{} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "e4183527-c234-42d5-8709-3dc8b9d5caa4") + c.RegisterIntegration(Integration{}) +} +``` + +## Slice built within multiple conditional blocks + +```go au:input au:group=matrix +package app + +import "context" +import "math/rand" +import "github.com/dogmatiq/dogma" +import "github.com/dogmatiq/enginekit/enginetest/stubs" + +type Integration struct{} + +func (Integration) Configure(c dogma.IntegrationConfigurer) { + c.Identity("handler", "5752bb84-0b65-4a7f-b2fa-bfb77a53a97f") + + var routes []dogma.IntegrationRoute + + if rand.Int() == 0 { + routes = append( + routes, + dogma.HandlesCommand[stubs.CommandStub[stubs.TypeA]](), + dogma.RecordsEvent[stubs.EventStub[stubs.TypeA]](), + ) + } + + if rand.Int() == 0 { + routes = append( + routes, + dogma.HandlesCommand[stubs.CommandStub[stubs.TypeB]](), + dogma.RecordsEvent[stubs.EventStub[stubs.TypeB]](), + ) + } + + c.Routes(routes...) +} + +func (Integration) HandleCommand(context.Context, dogma.IntegrationCommandScope, dogma.Command) error { return nil } + +type App struct{} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "e4183527-c234-42d5-8709-3dc8b9d5caa4") + c.RegisterIntegration(Integration{}) +} +``` diff --git a/config/staticconfig/testdata/handler-with-nil-route.md b/config/staticconfig/testdata/handler-with-nil-route.md new file mode 100644 index 00000000..fd2170f4 --- /dev/null +++ b/config/staticconfig/testdata/handler-with-nil-route.md @@ -0,0 +1,37 @@ +# Handler with nil route + +This test verifies that the static analyzer correctly detects a `nil` route. + +```au:output au:group=matrix +valid application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - valid identity app/e4183527-c234-42d5-8709-3dc8b9d5caa4 + - invalid integration github.com/dogmatiq/enginekit/config/staticconfig.Integration (value unavailable) + - no handles-command routes + - valid identity handler/5752bb84-0b65-4a7f-b2fa-bfb77a53a97f + - incomplete route + - route type is unavailable + - message type name is unavailable +``` + +```go au:input au:group=matrix +package app + +import "context" +import "github.com/dogmatiq/dogma" + +type Integration struct{} + +func (Integration) Configure(c dogma.IntegrationConfigurer) { + c.Identity("handler", "5752bb84-0b65-4a7f-b2fa-bfb77a53a97f") + c.Routes(nil) +} + +func (Integration) HandleCommand(context.Context, dogma.IntegrationCommandScope, dogma.Command) error { return nil } + +type App struct{} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "e4183527-c234-42d5-8709-3dc8b9d5caa4") + c.RegisterIntegration(Integration{}) +} +``` diff --git a/config/staticconfig/testdata/handler-with-speculative-routes.md b/config/staticconfig/testdata/handler-with-speculative-routes.md new file mode 100644 index 00000000..442e5c0d --- /dev/null +++ b/config/staticconfig/testdata/handler-with-speculative-routes.md @@ -0,0 +1,83 @@ +# Handler with speculative routes + +This test verifies that the static analyzer correctly identifies routes as +"speculative" under various complex conditions. + +Some of these scenarios could be improved, potentially avoiding false positives +for the "speculative" flag. In general, however, it is preferred that the +analyzer errs on the side of caution and marks routes as "speculative" when it +is unsure. + +```au:output au:group=matrix +valid application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - valid identity app/e4183527-c234-42d5-8709-3dc8b9d5caa4 + - valid integration github.com/dogmatiq/enginekit/config/staticconfig.Integration (value unavailable) + - valid identity handler/5752bb84-0b65-4a7f-b2fa-bfb77a53a97f + - valid speculative handles-command route for github.com/dogmatiq/enginekit/enginetest/stubs.CommandStub[github.com/dogmatiq/enginekit/enginetest/stubs.TypeA] (type unavailable) + - valid speculative records-event route for github.com/dogmatiq/enginekit/enginetest/stubs.EventStub[github.com/dogmatiq/enginekit/enginetest/stubs.TypeA] (type unavailable) +``` + +## Random index + +```go au:input au:group=matrix +package app + +import "context" +import "math/rand" +import "github.com/dogmatiq/dogma" +import "github.com/dogmatiq/enginekit/enginetest/stubs" + +type Integration struct{} + +func (Integration) Configure(c dogma.IntegrationConfigurer) { + c.Identity("handler", "5752bb84-0b65-4a7f-b2fa-bfb77a53a97f") + + routes := make([]dogma.IntegrationRoute, 0) + + routes[0] = dogma.HandlesCommand[stubs.CommandStub[stubs.TypeA]]() + routes[rand.Int()] = dogma.RecordsEvent[stubs.EventStub[stubs.TypeA]]() + + c.Routes(routes...) +} + +func (Integration) HandleCommand(context.Context, dogma.IntegrationCommandScope, dogma.Command) error { return nil } + +type App struct{} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "e4183527-c234-42d5-8709-3dc8b9d5caa4") + c.RegisterIntegration(Integration{}) +} +``` + +## Colliding indices + +```go au:input au:group=matrix +package app + +import "context" +import "github.com/dogmatiq/dogma" +import "github.com/dogmatiq/enginekit/enginetest/stubs" + +type Integration struct{} + +func (Integration) Configure(c dogma.IntegrationConfigurer) { + c.Identity("handler", "5752bb84-0b65-4a7f-b2fa-bfb77a53a97f") + + routes := make([]dogma.IntegrationRoute, 0) + + routes[0] = dogma.HandlesCommand[stubs.CommandStub[stubs.TypeA]]() + routes[0] = dogma.RecordsEvent[stubs.EventStub[stubs.TypeA]]() + + c.Routes(routes...) +} + +func (Integration) HandleCommand(context.Context, dogma.IntegrationCommandScope, dogma.Command) error { return nil } + +type App struct{} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "e4183527-c234-42d5-8709-3dc8b9d5caa4") + c.RegisterIntegration(Integration{}) +} +``` diff --git a/config/staticconfig/testdata/handler-with-unregistered-routes.md b/config/staticconfig/testdata/handler-with-unregistered-routes.md new file mode 100644 index 00000000..8f87c457 --- /dev/null +++ b/config/staticconfig/testdata/handler-with-unregistered-routes.md @@ -0,0 +1,79 @@ +# Handler with unregistered routes + +This test verifies that static analyzer does not include information about +routes that are constructed but never passed to the configurer's `Routes()` +method. + +```au:output au:group=matrix +valid application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - valid identity app/f2c08525-623e-4c76-851c-3172953269e3 + - invalid integration github.com/dogmatiq/enginekit/config/staticconfig.Integration (value unavailable) + - no handles-command routes + - valid identity handler/ac391765-da58-4e7c-a478-e4725eb2b0e9 +``` + +## No call to Routes() + +```go au:input au:group=matrix +package app + +import ( + "context" + "github.com/dogmatiq/dogma" + "github.com/dogmatiq/enginekit/enginetest/stubs" +) + +type Integration struct{} + +func (Integration) Configure(c dogma.IntegrationConfigurer) { + c.Identity("handler", "ac391765-da58-4e7c-a478-e4725eb2b0e9") + dogma.HandlesCommand[stubs.CommandStub[stubs.TypeX]]() +} + +type App struct{} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "f2c08525-623e-4c76-851c-3172953269e3") + c.RegisterIntegration(Integration{}) +} + +func (Integration) HandleCommand(context.Context, dogma.IntegrationCommandScope, dogma.Command) error { return nil } +``` + +## Route appended to slice after calling Routes() + +```go au:input au:group=matrix +package app + +import ( + "context" + "github.com/dogmatiq/dogma" + "github.com/dogmatiq/enginekit/enginetest/stubs" +) + +type Integration struct{} + +func (Integration) Configure(c dogma.IntegrationConfigurer) { + c.Identity("handler", "ac391765-da58-4e7c-a478-e4725eb2b0e9") + + var routes []dogma.IntegrationRoute + + c.Routes(routes...) + + routes = append( + routes, + dogma.HandlesCommand[stubs.CommandStub[stubs.TypeX]](), + ) +} + +type App struct{} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "f2c08525-623e-4c76-851c-3172953269e3") + + + c.RegisterIntegration(Integration{}) +} + +func (Integration) HandleCommand(context.Context, dogma.IntegrationCommandScope, dogma.Command) error { return nil } +``` diff --git a/config/staticconfig/testdata/handler-with-variadic-routes.md b/config/staticconfig/testdata/handler-with-variadic-routes.md new file mode 100644 index 00000000..ac12c63a --- /dev/null +++ b/config/staticconfig/testdata/handler-with-variadic-routes.md @@ -0,0 +1,111 @@ +# Handler with variadic routes + +This test verifies that the static analyzer correctly identifies routes that are +configured via a slice which is used as the variadic parameter to the `Routes()` +method. + +```au:output au:group=matrix +valid application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - valid identity app/e4183527-c234-42d5-8709-3dc8b9d5caa4 + - valid integration github.com/dogmatiq/enginekit/config/staticconfig.Integration (value unavailable) + - valid identity handler/5752bb84-0b65-4a7f-b2fa-bfb77a53a97f + - valid handles-command route for github.com/dogmatiq/enginekit/enginetest/stubs.CommandStub[github.com/dogmatiq/enginekit/enginetest/stubs.TypeA] (type unavailable) + - valid records-event route for github.com/dogmatiq/enginekit/enginetest/stubs.EventStub[github.com/dogmatiq/enginekit/enginetest/stubs.TypeA] (type unavailable) +``` + +## Appended + +```go au:input au:group=matrix +package app + +import "context" +import "github.com/dogmatiq/dogma" +import "github.com/dogmatiq/enginekit/enginetest/stubs" + +type Integration struct{} + +func (Integration) Configure(c dogma.IntegrationConfigurer) { + c.Identity("handler", "5752bb84-0b65-4a7f-b2fa-bfb77a53a97f") + + var routes []dogma.IntegrationRoute + + routes = append( + routes, + dogma.HandlesCommand[stubs.CommandStub[stubs.TypeA]](), + dogma.RecordsEvent[stubs.EventStub[stubs.TypeA]](), + ) + + c.Routes(routes...) +} + +func (Integration) HandleCommand(context.Context, dogma.IntegrationCommandScope, dogma.Command) error { return nil } + +type App struct{} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "e4183527-c234-42d5-8709-3dc8b9d5caa4") + c.RegisterIntegration(Integration{}) +} +``` + +## Assigned to index + +```go au:input au:group=matrix +package app + +import "context" +import "github.com/dogmatiq/dogma" +import "github.com/dogmatiq/enginekit/enginetest/stubs" + +type Integration struct{} + +func (Integration) Configure(c dogma.IntegrationConfigurer) { + c.Identity("handler", "5752bb84-0b65-4a7f-b2fa-bfb77a53a97f") + + routes := make([]dogma.IntegrationRoute, 1) + routes[0] = dogma.HandlesCommand[stubs.CommandStub[stubs.TypeA]]() + routes[1] = dogma.RecordsEvent[stubs.EventStub[stubs.TypeA]]() + + c.Routes(routes...) +} + +func (Integration) HandleCommand(context.Context, dogma.IntegrationCommandScope, dogma.Command) error { return nil } + +type App struct{} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "e4183527-c234-42d5-8709-3dc8b9d5caa4") + c.RegisterIntegration(Integration{}) +} +``` + +## Assigned to index of sub-slice + +```go au:input au:group=matrix +package app + +import "context" +import "github.com/dogmatiq/dogma" +import "github.com/dogmatiq/enginekit/enginetest/stubs" + +type Integration struct{} + +func (Integration) Configure(c dogma.IntegrationConfigurer) { + c.Identity("handler", "5752bb84-0b65-4a7f-b2fa-bfb77a53a97f") + + routes := make([]dogma.IntegrationRoute, 1) + routes[:1][0] = dogma.HandlesCommand[stubs.CommandStub[stubs.TypeA]]() + routes[1:][0] = dogma.RecordsEvent[stubs.EventStub[stubs.TypeA]]() + + c.Routes(routes...) +} + +func (Integration) HandleCommand(context.Context, dogma.IntegrationCommandScope, dogma.Command) error { return nil } + +type App struct{} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "e4183527-c234-42d5-8709-3dc8b9d5caa4") + c.RegisterIntegration(Integration{}) +} +``` diff --git a/config/staticconfig/testdata/identity-from-const.md b/config/staticconfig/testdata/identity-from-const.md new file mode 100644 index 00000000..e4255e38 --- /dev/null +++ b/config/staticconfig/testdata/identity-from-const.md @@ -0,0 +1,26 @@ +# Identity built from constants + +This test verifies that the static analyzer can discover the values within an +entity's identity when they are sourced from non-literal constant expressions. + +```au:output au:group=matrix +valid application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - valid identity app/d0de39ba-aaaf-43fd-8e8f-7c4e3be309ec +``` + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +const ( + Name = "app" + Key = "d0de39ba-aaaf-43fd-8e8f-7c4e3be309ec" +) + +type App struct{} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity(Name, Key) +} +``` diff --git a/config/staticconfig/testdata/identity-from-non-const-static.md b/config/staticconfig/testdata/identity-from-non-const-static.md new file mode 100644 index 00000000..e248fa0d --- /dev/null +++ b/config/staticconfig/testdata/identity-from-non-const-static.md @@ -0,0 +1,69 @@ +# Identity built from non-constant values that can be resolved statically + +This test verifies that the static analyzer includes an entity's identity, even +if it cannot determine the values used. + +```au:output au:group=matrix +valid application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - valid identity app/a0a0edb7-ce45-4eb4-940c-0f77459ae2a0 +``` + +## Function call + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +type App struct { +} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity(name(), "a0a0edb7-ce45-4eb4-940c-0f77459ae2a0") +} + +func name() string { + return "app" +} +``` + +## Function call with tuple extraction + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +type App struct { +} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity(ident()) +} + +func ident() (string,string) { + return "app", "a0a0edb7-ce45-4eb4-940c-0f77459ae2a0" +} +``` + +## Function call with multiple branches that return the same values + +```go au:input au:group=matrix +package app + +import "math/rand" +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity(ident()) +} + +func ident() (string, string) { + if rand.Int() == 0 { + return "app", "a0a0edb7-ce45-4eb4-940c-0f77459ae2a0" + } + return "app", "a0a0edb7-ce45-4eb4-940c-0f77459ae2a0" +} +``` diff --git a/config/staticconfig/testdata/identity-from-non-const.md b/config/staticconfig/testdata/identity-from-non-const.md new file mode 100644 index 00000000..47ebd144 --- /dev/null +++ b/config/staticconfig/testdata/identity-from-non-const.md @@ -0,0 +1,67 @@ +# Identity built from non-constant values + +This test verifies that the static analyzer includes an entity's identity, even +if it cannot determine the values used. + +```au:output au:group=matrix +valid application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - incomplete identity + - name is unavailable + - key is unavailable +``` + +## Variables + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +type App struct { + Name string + Key string +} + +func (a App) Configure(c dogma.ApplicationConfigurer) { + c.Identity(a.Name, a.Key) +} +``` + +## Function call + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +type App struct { + name func() string + key func() string +} + +func (a App) Configure(c dogma.ApplicationConfigurer) { + c.Identity(a.name(), a.key()) +} +``` + +## Function call with a non-deterministic return value + +```go au:input au:group=matrix +package app + +import "math/rand" +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity(ident()) +} + +func ident() (string, string) { + if rand.Int() == 0 { + return "app1", "a0a0edb7-ce45-4eb4-940c-0f77459ae2a0" + } + return "app2", "08905dce-9059-4601-a48f-f449c6fba70b" +} +``` diff --git a/config/staticconfig/testdata/incomplete-entity.md b/config/staticconfig/testdata/incomplete-entity.md new file mode 100644 index 00000000..b1fab971 --- /dev/null +++ b/config/staticconfig/testdata/incomplete-entity.md @@ -0,0 +1,47 @@ +# Incomplete entity configuration + +This test verifies that the static analyzer marks configuration of an entity as +incomplete if the `Configure()` method calls into code that is unable to be +analyzed. + +```au:output au:group=matrix +incomplete application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - could not evaluate entire configuration: analysis of non-static function call is not possible + - valid identity app/de142370-93ee-409c-9336-5084d9c5e285 +``` + +## Call to closure + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +type App struct { + setup func(dogma.ApplicationConfigurer) +} + +func (a App) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") + a.setup(c) +} +``` + +## Call to method on interface + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +type App struct { + iface interface { + setup(dogma.ApplicationConfigurer) + } +} + +func (a App) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") + a.iface.setup(c) +} +``` diff --git a/config/staticconfig/testdata/indirect.md b/config/staticconfig/testdata/indirect.md new file mode 100644 index 00000000..e37acce2 --- /dev/null +++ b/config/staticconfig/testdata/indirect.md @@ -0,0 +1,96 @@ +# Indirect configuration + +This test verifies that the static analyzer traverses into code called from the +`Configure()` method if that method is given access to the +`ApplicationConfigurer` interface. + +```au:output au:group=matrix +valid application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - valid identity app/de142370-93ee-409c-9336-5084d9c5e285 +``` + +## Method call + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (a App) Configure(c dogma.ApplicationConfigurer) { + a.setup(c) +} + +func (App) setup(c dogma.ApplicationConfigurer) { + c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") +} +``` + +## Function call + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + setup(c) +} + +func setup(c dogma.ApplicationConfigurer) { + c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") +} +``` + +## Generic function call + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + setup(c) +} + +func setup[T dogma.ApplicationConfigurer](c T) { + c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") +} +``` + +## Deferred + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + defer c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") +} +``` + +## Separate goroutine + +This test guarantees that the identity configured in a separate goroutine is +detected by the static analyzer, but this usage would like introduce a race +condition in any real `ApplicationConfigurer` implementation. + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +type App struct {} + +func (App) Configure(c dogma.ApplicationConfigurer) { + go c.Identity("app", "de142370-93ee-409c-9336-5084d9c5e285") +} +``` diff --git a/config/staticconfig/testdata/invalid-syntax.md b/config/staticconfig/testdata/invalid-syntax.md new file mode 100644 index 00000000..9acac8f9 --- /dev/null +++ b/config/staticconfig/testdata/invalid-syntax.md @@ -0,0 +1,22 @@ +# Invalid syntax + +This test verifies that static analyzer does not fail catastrophically when the +analyzed code does not compile. + +```au:output au:group=matrix +main.go:10:1: expected declaration, found '<' +``` + +```go au:input au:group=matrix +package app + +// Even though this file has invalid syntax the import statements are still +// parsed. This import necessary so that the test still considers it a +// possibility that this package has valid Dogma application implementations. +import _ "github.com/dogmatiq/dogma" + +// Below is the deliberate illegal Go syntax to test loading of the packages +// with errors. + + +``` diff --git a/config/staticconfig/testdata/multiple-apps.md b/config/staticconfig/testdata/multiple-apps.md new file mode 100644 index 00000000..792cf40b --- /dev/null +++ b/config/staticconfig/testdata/multiple-apps.md @@ -0,0 +1,31 @@ +# Multiple applications in a single package + +This test verifies that the static analyzer discovers multiple Dogma application +types defined within the same Go package. + +```au:output au:group=matrix +valid application github.com/dogmatiq/enginekit/config/staticconfig.One (value unavailable) + - valid identity one/4fec74a1-6ed4-46f4-8417-01e0910be8f1 + +valid application github.com/dogmatiq/enginekit/config/staticconfig.Two (value unavailable) + - valid identity two/6e97d403-3cb8-4a59-a7ec-74e8e219a7bc +``` + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +type ( + One struct{} + Two struct{} +) + +func (One) Configure(c dogma.ApplicationConfigurer) { + c.Identity("one", "4fec74a1-6ed4-46f4-8417-01e0910be8f1") +} + +func (Two) Configure(c dogma.ApplicationConfigurer) { + c.Identity("two", "6e97d403-3cb8-4a59-a7ec-74e8e219a7bc") +} +``` diff --git a/config/staticconfig/testdata/multiple-handlers.md b/config/staticconfig/testdata/multiple-handlers.md new file mode 100644 index 00000000..150f7255 --- /dev/null +++ b/config/staticconfig/testdata/multiple-handlers.md @@ -0,0 +1,54 @@ +# Multiple handlers of the same type + +This test ensures that the static analyzer supports multiple handlers of the +same handler type. + +```au:output au:group=matrix +valid application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - valid identity app/c0d4a0fc-2075-4a41-a7bf-7d1870dc0de9 + - valid aggregate github.com/dogmatiq/enginekit/config/staticconfig.One (value unavailable) + - valid identity one/62e0efa9-c5a0-4b5c-a237-9b51533a6963 + - valid handles-command route for github.com/dogmatiq/enginekit/enginetest/stubs.CommandStub[github.com/dogmatiq/enginekit/enginetest/stubs.TypeA] (type unavailable) + - valid records-event route for github.com/dogmatiq/enginekit/enginetest/stubs.EventStub[github.com/dogmatiq/enginekit/enginetest/stubs.TypeA] (type unavailable) + - valid aggregate github.com/dogmatiq/enginekit/config/staticconfig.Two (value unavailable) + - valid identity two/0c3e2f49-acd0-4d82-800d-5d6d839535de + - valid handles-command route for github.com/dogmatiq/enginekit/enginetest/stubs.CommandStub[github.com/dogmatiq/enginekit/enginetest/stubs.TypeB] (type unavailable) + - valid records-event route for github.com/dogmatiq/enginekit/enginetest/stubs.EventStub[github.com/dogmatiq/enginekit/enginetest/stubs.TypeB] (type unavailable) +``` + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" +import "github.com/dogmatiq/enginekit/enginetest/stubs" + +type ( + One struct { dogma.AggregateMessageHandler } + Two struct { dogma.AggregateMessageHandler } +) + +func (One) Configure(c dogma.AggregateConfigurer) { + c.Identity("one", "62e0efa9-c5a0-4b5c-a237-9b51533a6963") + c.Routes( + dogma.HandlesCommand[stubs.CommandStub[stubs.TypeA]](), + dogma.RecordsEvent[stubs.EventStub[stubs.TypeA]](), + ) +} + + +func (Two) Configure(c dogma.AggregateConfigurer) { + c.Identity("two", "0c3e2f49-acd0-4d82-800d-5d6d839535de") + c.Routes( + dogma.HandlesCommand[stubs.CommandStub[stubs.TypeB]](), + dogma.RecordsEvent[stubs.EventStub[stubs.TypeB]](), + ) +} + +type App struct{} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "c0d4a0fc-2075-4a41-a7bf-7d1870dc0de9") + c.RegisterAggregate(One{}) + c.RegisterAggregate(Two{}) +} +``` diff --git a/config/staticconfig/testdata/nil-handlers.md b/config/staticconfig/testdata/nil-handlers.md new file mode 100644 index 00000000..6d885377 --- /dev/null +++ b/config/staticconfig/testdata/nil-handlers.md @@ -0,0 +1,44 @@ +# Nil handlers + +This test ensures that the static analyzer includes basic information about the +presence of `nil` handlers. + +```au:output au:group=matrix +valid application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - valid identity app/0726ae0d-67e4-4a50-8a19-9f58eae38e51 + - incomplete aggregate + - could not evaluate entire configuration: the handler's type is unknown + - no identity + - no handles-command routes + - no records-event routes + - incomplete process + - could not evaluate entire configuration: the handler's type is unknown + - no identity + - no handles-event routes + - no executes-command routes + - incomplete integration + - could not evaluate entire configuration: the handler's type is unknown + - no identity + - no handles-command routes + - incomplete projection + - could not evaluate entire configuration: the handler's type is unknown + - no identity + - no handles-event routes +``` + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +type App struct{} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "0726ae0d-67e4-4a50-8a19-9f58eae38e51") + + c.RegisterAggregate(nil) + c.RegisterProcess(nil) + c.RegisterIntegration(nil) + c.RegisterProjection(nil) +} +``` diff --git a/config/staticconfig/testdata/no-apps.md b/config/staticconfig/testdata/no-apps.md new file mode 100644 index 00000000..d5bd9014 --- /dev/null +++ b/config/staticconfig/testdata/no-apps.md @@ -0,0 +1,70 @@ +# No applications + +This test ensures that the static analyzer does not fail when the analyzed code +does not contain any Dogma applications. + +```au:output au:group=matrix +(no applications found) +``` + +## Empty package + +```go au:input au:group=matrix +package app +``` + +## Concrete type with similar structure to a dogma.Application + +```go au:input au:group=matrix +package app + +import _ "github.com/dogmatiq/dogma" + +// App looks a lot like a [dogma.Application], but does not actually +// implement the Dogma interface because the local [ApplicationConfigurer] +// interface is not the same type as [dogma.ApplicationConfigurer], even though +// it's compatible. +type App struct{} + +func (App) Configure(c ApplicationConfigurer) { + c.Identity("name", "ee6ca834-34a3-4e59-8c36-7aeb796401d7") +} + +type ApplicationConfigurer interface { + Identity(name, key string) +} +``` + +## Interface that is compatible with dogma.Application + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +// App does implement [dogma.Application], but it is not a concrete type so +// there's nothing to analyze. +type App interface { + Configure(c dogma.ApplicationConfigurer) +} +``` + +## Uninstantiated generic application + +We can't analyze this code because the application is generic and not +instantiated, meaning that we have no concrete type for `T`. We _could_ chose a +compatible type for `T` and analyze the result of instantiating the generic +type, but the assumption is that the reason the type is generic is because the +application is intended to be used with multiple types. + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +type App[T any] struct{} + +func (App[T]) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "8a6baab1-ee64-402e-a081-e43f4bebc243") +} +``` diff --git a/config/staticconfig/testdata/no-handlers.md b/config/staticconfig/testdata/no-handlers.md new file mode 100644 index 00000000..1fb8888d --- /dev/null +++ b/config/staticconfig/testdata/no-handlers.md @@ -0,0 +1,21 @@ +# Applications with no handlers + +This test ensures that the static analyzer includes Dogma applications that have +no handlers. + +```au:output au:group=matrix +valid application github.com/dogmatiq/enginekit/config/staticconfig.App (value unavailable) + - valid identity app/8a6baab1-ee64-402e-a081-e43f4bebc243 +``` + +```go au:input au:group=matrix +package app + +import "github.com/dogmatiq/dogma" + +type App struct{} + +func (App) Configure(c dogma.ApplicationConfigurer) { + c.Identity("app", "8a6baab1-ee64-402e-a081-e43f4bebc243") +} +``` diff --git a/config/staticconfig/varargs.go b/config/staticconfig/varargs.go new file mode 100644 index 00000000..a2e6736c --- /dev/null +++ b/config/staticconfig/varargs.go @@ -0,0 +1,148 @@ +package staticconfig + +import ( + "github.com/dogmatiq/enginekit/collections/sets" + "github.com/dogmatiq/enginekit/config" + "github.com/dogmatiq/enginekit/config/internal/configbuilder" + "github.com/dogmatiq/enginekit/config/staticconfig/internal/ssax" + "github.com/dogmatiq/enginekit/optional" + "golang.org/x/tools/go/ssa" +) + +// analyzeVariadicArguments analyzes the variadic arguments of a method call. +func analyzeVariadicArguments[ + T config.Entity, + E any, + B configbuilder.EntityBuilder[T, E], + TC config.Component, + BC configbuilder.ComponentBuilder[TC], +]( + ctx *configurerCallContext[T, E, B], + buildChild func(func(BC)), + analyzeChild func(*configurerCallContext[T, E, B], BC, ssa.Value), +) { + allocs := collectVariadicAllocations( + ctx.Builder, + ctx.Args[len(ctx.Args)-1], // varadic slice is always the last argument + ) + + var isVarArg func(v ssa.Value) (optional.Optional[int], bool) + + isVarArg = func(v ssa.Value) (optional.Optional[int], bool) { + if allocs.Has(v) { + return optional.Some(0), true + } + + switch v := v.(type) { + case *ssa.Slice: + if index, ok := isVarArg(v.X); ok { + if v.Low == nil { + return index, true + } + + return optional.Sum( + index, + ssax.AsInt(v.Low), + ), true + } + case *ssa.IndexAddr: + if index, ok := isVarArg(v.X); ok { + return optional.Sum( + index, + ssax.AsInt(v.Index), + ), true + } + default: + unimplementedAnalysis(ctx.Builder, v) + } + + return optional.None[int](), false + } + + indexCounts := map[int]int{} + hasUnknownIndices := false + var children []func(BC) + + for block := range ssax.WalkFunc(ctx.FunctionUnderAnalysis) { + if !ssax.PathExists(block, ctx.Instruction.Block()) { + continue + } + + unconditional := ssax.IsUnconditional(block) + + for inst := range ssax.InstructionsBefore(block, ctx.Instruction) { + if inst, ok := inst.(*ssa.Store); ok { + index, ok := isVarArg(inst.Addr) + if !ok { + continue + } + + if i, ok := index.TryGet(); ok { + indexCounts[i]++ + } else { + hasUnknownIndices = true + } + + children = append(children, func(b BC) { + ctx.Apply(b) + + if hasUnknownIndices || !unconditional { + b.Speculative() + } else if i, ok := index.TryGet(); ok && indexCounts[i] > 1 { + b.Speculative() + } + + analyzeChild(ctx, b, inst.Val) + }) + } + } + } + + for _, child := range children { + buildChild(child) + } +} + +func collectVariadicAllocations( + b configbuilder.UntypedComponentBuilder, + v ssa.Value, +) *sets.Set[ssa.Value] { + allocs := sets.New[ssa.Value]() + + var collect func(v ssa.Value) + collect = func(v ssa.Value) { + switch v := v.(type) { + case *ssa.Alloc: + allocs.Add(v) + + case *ssa.Slice: + collect(v.X) + + case *ssa.Const: + // We've found a nil slice. + + case *ssa.Phi: + for _, edge := range v.Edges { + collect(edge) + } + + case *ssa.Call: + call := v.Common() + + if fn, ok := call.Value.(*ssa.Builtin); ok { + if fn.Name() == "append" { + for _, arg := range call.Args { + collect(arg) + } + } + } + + default: + unimplementedAnalysis(b, v) + } + } + + collect(v) + + return allocs +} diff --git a/config/validate.go b/config/validate.go index 46c043e2..d63b4eab 100644 --- a/config/validate.go +++ b/config/validate.go @@ -138,8 +138,10 @@ func newResolutionContext(c Component, allowPartial bool) *validateContext { }, } - if !allowPartial && c.ComponentProperties().IsPartial { - ctx.Invalid(PartialConfigurationError{}) + p := c.ComponentProperties() + + if !allowPartial && len(p.IsPartialReasons) > 0 { + ctx.Invalid(PartialConfigurationError{p.IsPartialReasons}) } return ctx diff --git a/go.mod b/go.mod index 97dfe1a3..4fca7b08 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,12 @@ module github.com/dogmatiq/enginekit go 1.23 require ( + github.com/dogmatiq/aureus v0.2.2 github.com/dogmatiq/dapper v0.6.0 github.com/dogmatiq/dogma v0.15.0 github.com/dogmatiq/primo v0.3.1 github.com/google/go-cmp v0.6.0 + golang.org/x/tools v0.26.0 google.golang.org/grpc v1.67.1 google.golang.org/protobuf v1.35.1 pgregory.net/rapid v1.1.0 @@ -14,8 +16,11 @@ require ( require ( github.com/dogmatiq/jumble v0.1.0 // indirect - golang.org/x/net v0.28.0 // indirect - golang.org/x/sys v0.24.0 // indirect - golang.org/x/text v0.17.0 // indirect + github.com/yuin/goldmark v1.7.8 // indirect + golang.org/x/mod v0.21.0 // indirect + golang.org/x/net v0.30.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.19.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect ) diff --git a/go.sum b/go.sum index d673679f..dfc26aa7 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/cncf/xds/go v0.0.0-20240723142845-024c85f92f20 h1:N+3sFI5GUjRKBi+i0Tx github.com/cncf/xds/go v0.0.0-20240723142845-024c85f92f20/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/dave/jennifer v1.7.0 h1:uRbSBH9UTS64yXbh4FrMHfgfY762RD+C7bUPKODpSJE= github.com/dave/jennifer v1.7.0/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= +github.com/dogmatiq/aureus v0.2.2 h1:3SMF+GMntHyO5Y3cSbKVgX1w4I0+B6llV0zU8p+D6QQ= +github.com/dogmatiq/aureus v0.2.2/go.mod h1:ZHusLaF9NnCPt2nZOBjYCRcJpZ66PTMpZkpCfbEsRdU= github.com/dogmatiq/dapper v0.6.0 h1:hnWUsjnt3nUiC9hmkPvuxrnMd7fYNz1i+/GS3gOx0Xs= github.com/dogmatiq/dapper v0.6.0/go.mod h1:ubRHWzt73s0MsPpGhWvnfW/Z/1YPnrkCsQv6CUOZVEw= github.com/dogmatiq/dogma v0.15.0 h1:aXOTd2K4wLvlwHc1D9OsFREp0BusNJ9o9KssxURftmg= @@ -32,24 +34,28 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= -golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= -golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= -golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= -golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= -golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= -golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= -golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457 h1:zf5N6UOrA487eEFacMePxjXAJctxKmyjKUsjA11Uzuk= +golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= +golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= +golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8= diff --git a/internal/typename/typename.go b/internal/typename/typename.go index 02970bb8..52f20fe3 100644 --- a/internal/typename/typename.go +++ b/internal/typename/typename.go @@ -22,6 +22,8 @@ func OfStatic(t types.Type) string { switch t := t.(type) { case *types.Named: return t.String() + case *types.Alias: + return t.String() case *types.Pointer: return "*" + OfStatic(t.Elem()) default: diff --git a/optional/container.go b/optional/container.go index 90b2fffc..ab5abc4b 100644 --- a/optional/container.go +++ b/optional/container.go @@ -32,3 +32,12 @@ func Key[K comparable, V any, M ~map[K]V](m M, k K) Optional[V] { } return None[V]() } + +// Slice returns a slice of Optional[T] values. +func Slice[T any](elems ...T) []Optional[T] { + slice := make([]Optional[T], len(elems)) + for i, elem := range elems { + slice[i] = Some(elem) + } + return slice +} diff --git a/optional/numeric.go b/optional/numeric.go new file mode 100644 index 00000000..05df2fb3 --- /dev/null +++ b/optional/numeric.go @@ -0,0 +1,25 @@ +package optional + +type numeric interface { + ~int | ~int8 | ~int16 | ~int32 | ~int64 | + ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | + ~float32 | ~float64 | + ~complex64 | ~complex128 +} + +// Sum returns the sum of all of the given values. If are of the values are +// none, then the result is also none. +func Sum[T numeric](values ...Optional[T]) Optional[T] { + var sum T + + for _, value := range values { + v, ok := value.TryGet() + if !ok { + return None[T]() + } + + sum += v + } + + return Some(sum) +}