Add structured validation failures

This commit is contained in:
2026-10-07 18:27:36 +00:00
parent 1de7555b3e
commit 136a97fb6c
6 changed files with 162 additions and 5 deletions

View File

@@ -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. |

View File

@@ -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.

View File

@@ -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)
}
})
}
}

View File

@@ -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 "<nil>"
}
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)

View File

@@ -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)
}
}
}

View File

@@ -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
}