diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 0e4e120..bfbbb2b 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -46,7 +46,7 @@ these implemented packages: | `internal/tempdir` | Disposable system temporary storage outside source worktrees. | | `internal/process` | Cancellable child-process execution with per-command environment overlays and removals. | | `internal/preparation` | Coordination for expected-commit tag checkout and Go module dependency preparation. | -| `internal/validation` | Shared default Go validation for captured candidates; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. | +| `internal/validation` | Shared default Go validation and structured check failures for captured candidates; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. | | `internal/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. | | `internal/runtimeinfo` | Private version lookup used by the runtime facade. | | `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. | diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 2384b26..b7ae2cf 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Validation model implementation plan -Status: In progress; the shared validation executor is complete. +Status: In progress; the shared validation executor and internal diagnostics are complete. This plan implements the enabling work in [model.md](model.md), guided by [ADR 0003](../adr/0003-declarative-validation.md). It is written for a Luna @@ -206,6 +206,10 @@ inventory to reflect the implemented executor. ## Stage 2: Introduce internal validation diagnostics +**Completion:** Complete. Shared Go setup and test failures carry the internal +`go-test` identity and preserve their underlying errors, including cancellation +and subprocess exit errors. + **Outcome:** The executor returns a small structured failure with an underlying cause, independently of public error conversion. diff --git a/internal/candidate/check_test.go b/internal/candidate/check_test.go index e49098c..1167ac8 100644 --- a/internal/candidate/check_test.go +++ b/internal/candidate/check_test.go @@ -9,6 +9,8 @@ import ( "path/filepath" "strings" "testing" + + "gitea.maximumdirect.net/eric/ReleaseKit/internal/validation" ) const testVersion = "v1.2.3" @@ -123,6 +125,14 @@ func TestDefaultValidationDoesNotInheritGOFLAGSRunFilter(t *testing.T) { if err == nil || !strings.Contains(output.String(), "TestExpectedFailure") { t.Fatalf("Check() error = %v, output = %q; wanted the failing standard test to run", err, output.String()) } + var failure *validation.Failure + if !errors.As(err, &failure) || failure.Check != validation.GoTest { + t.Fatalf("Check() error = %v; want a go-test validation failure", err) + } + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("Check() error = %v; want the Go process exit error preserved", err) + } }) } } diff --git a/internal/validation/diagnostic.go b/internal/validation/diagnostic.go new file mode 100644 index 0000000..516f8cd --- /dev/null +++ b/internal/validation/diagnostic.go @@ -0,0 +1,92 @@ +package validation + +import ( + "fmt" + "strings" +) + +// Check identifies one shared validation check. +type Check string + +const ( + // GoTest identifies the base Go test suite. + GoTest Check = "go-test" +) + +// Finding describes a validation observation at an optional source location. +type Finding struct { + Path string + Line int + Column int + Message string + Target string + Directive string +} + +// FileSizeMeasurement describes a measured file size and its selected limit. +type FileSizeMeasurement struct { + Bytes int64 + LimitBytes int64 +} + +// Failure describes a failed shared validation check and preserves its cause. +type Failure struct { + Check Check + Findings []Finding + Measurement *FileSizeMeasurement + Cause error +} + +// Error returns a useful summary of the failed check. +func (failure *Failure) Error() string { + return formatFailure(failure) +} + +// Unwrap returns the underlying validation error. +func (failure *Failure) Unwrap() error { + if failure == nil { + return nil + } + return failure.Cause +} + +func formatFailure(failure *Failure) string { + if failure == nil { + return "" + } + var summary strings.Builder + if failure.Check == "" { + summary.WriteString("validation failed") + } else { + fmt.Fprintf(&summary, "%s validation failed", failure.Check) + } + for _, finding := range failure.Findings { + summary.WriteString(": ") + if finding.Path != "" { + summary.WriteString(finding.Path) + if finding.Line > 0 { + fmt.Fprintf(&summary, ":%d", finding.Line) + if finding.Column > 0 { + fmt.Fprintf(&summary, ":%d", finding.Column) + } + } + summary.WriteString(": ") + } + summary.WriteString(finding.Message) + } + if failure.Measurement != nil { + fmt.Fprintf(&summary, ": measured %d bytes against a %d byte limit", failure.Measurement.Bytes, failure.Measurement.LimitBytes) + } + if failure.Cause != nil { + if len(failure.Findings) == 0 && failure.Measurement == nil { + summary.WriteString(": ") + } else { + summary.WriteString("; ") + } + summary.WriteString(failure.Cause.Error()) + } + return summary.String() +} + +var _ error = (*Failure)(nil) +var _ interface{ Unwrap() error } = (*Failure)(nil) diff --git a/internal/validation/diagnostic_test.go b/internal/validation/diagnostic_test.go new file mode 100644 index 0000000..57d3cdd --- /dev/null +++ b/internal/validation/diagnostic_test.go @@ -0,0 +1,49 @@ +package validation + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestRunPreservesCanceledGoSetup(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := Run(ctx, Request{RepositoryDir: t.TempDir()}) + var failure *Failure + if !errors.As(err, &failure) { + t.Fatalf("Run() error = %v, want a validation failure", err) + } + if failure.Check != GoTest { + t.Fatalf("failure check = %q, want %q", failure.Check, GoTest) + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want context cancellation preserved", err) + } +} + +func TestFailureFormatsDiagnosticsAndUnwrapsCause(t *testing.T) { + wantCause := errors.New("underlying check error") + failure := &Failure{ + Check: GoTest, + Findings: []Finding{{ + Path: "README.md", + Line: 4, + Column: 2, + Message: "broken destination", + }}, + Measurement: &FileSizeMeasurement{Bytes: 12, LimitBytes: 10}, + Cause: wantCause, + } + + if !errors.Is(failure, wantCause) { + t.Fatalf("Failure does not unwrap its cause") + } + for _, part := range []string{"go-test", "README.md:4:2", "broken destination", "12 bytes", "10 byte limit", wantCause.Error()} { + if !strings.Contains(failure.Error(), part) { + t.Errorf("Failure.Error() = %q; want it to include %q", failure.Error(), part) + } + } +} diff --git a/internal/validation/validation.go b/internal/validation/validation.go index 0b1ee6b..890ca70 100644 --- a/internal/validation/validation.go +++ b/internal/validation/validation.go @@ -3,7 +3,6 @@ package validation import ( "context" - "fmt" "io" "gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool" @@ -25,9 +24,12 @@ type Request struct { func Run(ctx context.Context, request Request) error { tool, err := gotool.New(ctx, request.RepositoryDir) if err != nil { - return fmt.Errorf("prepare Go toolchain: %w", err) + return &Failure{Check: GoTest, Cause: err} } _, err = tool.Run(ctx, request.RepositoryDir, gotool.Native, gotool.Target{}, request.Output, "test", "-mod=readonly", "-count=1", "./...") - return err + if err != nil { + return &Failure{Check: GoTest, Cause: err} + } + return nil }