diff --git a/check.go b/check.go index 4200136..dfa3b86 100644 --- a/check.go +++ b/check.go @@ -51,7 +51,7 @@ func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) { Output: request.Output, }) if err != nil { - return Candidate{}, err + return Candidate{}, convertValidationError(err) } return Candidate{ RepositoryDir: checked.RepositoryDir, diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 65e2b97..c817472 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -76,7 +76,7 @@ not a separate release matrix: | Capability | Implementation owner | Existing evidence | | --- | --- | --- | | Runtime version lookup and injected application version | `buildinfo`, `internal/runtimeinfo`, `internal/versioncontract`, `internal/build` | `TestVersionInConsumerExecutable`, `TestVersion` | -| Candidate capture, shared validation, additional checks, and rechecks | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation`, `TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate`, `TestPublishCommandPublishesConfiguredCandidate` | +| Candidate capture, shared validation, public diagnostics, additional checks, and rechecks | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestCheckReturnsPublicValidationError`, `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation`, `TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate`, `TestPublishCommandPublishesConfiguredCandidate` | | Shared Go settings for validation and builds | `internal/gotool`, `internal/process`, `internal/candidate`, `internal/build` | `TestDefaultValidationDoesNotInheritGOFLAGSRunFilter`, `TestNativeAndStaticCommandsUseOwnedBaseline`, `TestPersistentSettingsPreserveCachesAndNativeCTools`, `TestBuildIgnoresAmbientAndPersistentArchitectureTuning` | | Authoritative endpoint and exact annotated tag publication | `releasekit.Publish`, `internal/publish`, `internal/git` | `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestResolveUsesPushURLAndQueriesWithoutMutation` | | Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` | diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index c46bb0b..965aafd 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Validation model implementation plan -Status: In progress; the shared executor, diagnostics, committed-file reader, and destination resolver are complete. +Status: In progress; shared execution, committed-file inspection, destination resolution, and facade 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 @@ -280,6 +280,10 @@ syntax, anchors, and selected failure policy. ## Stage 5: Add facade declarations and shared request conversion +**Completion:** Complete. The root facade exposes its own validation +diagnostic types, and both `Check` and `Publish` convert shared failures while +preserving candidate context and the original error chain. + **Outcome:** The public model has an agreed representation, shared conversion, and cause-preserving errors across `Check` and `Publish`. diff --git a/internal/apicheck/main.go b/internal/apicheck/main.go index 083bb98..ea06582 100644 --- a/internal/apicheck/main.go +++ b/internal/apicheck/main.go @@ -70,10 +70,91 @@ func checkRepository(ctx context.Context, dir string) error { if violations := checkExports(pkg); len(violations) != 0 { return fmt.Errorf("facade %s has invalid public contracts:\n %s", path, strings.Join(violations, "\n ")) } + if path == modulePath { + if violations := checkValidationTypes(pkg); len(violations) != 0 { + return fmt.Errorf("facade %s has invalid validation diagnostics:\n %s", path, strings.Join(violations, "\n ")) + } + } } return nil } +func checkValidationTypes(pkg *types.Package) []string { + var violations []string + for _, name := range []string{"ValidationCheck", "ValidationFinding", "FileSizeMeasurement", "ValidationError"} { + object, ok := pkg.Scope().Lookup(name).(*types.TypeName) + if !ok { + violations = append(violations, fmt.Sprintf("missing facade-owned type %s", name)) + continue + } + named, ok := types.Unalias(object.Type()).(*types.Named) + if !ok || named.Obj().Pkg() == nil || named.Obj().Pkg().Path() != pkg.Path() { + violations = append(violations, fmt.Sprintf("%s is not a facade-owned named type", name)) + } + } + validationError, ok := pkg.Scope().Lookup("ValidationError").(*types.TypeName) + if !ok { + return violations + } + named, ok := types.Unalias(validationError.Type()).(*types.Named) + if !ok { + return violations + } + structure, ok := named.Underlying().(*types.Struct) + if !ok { + return append(violations, "ValidationError is not a struct") + } + fields := map[string]string{ + "Check": "ValidationCheck", + "Findings": "[]ValidationFinding", + "Measurement": "*FileSizeMeasurement", + } + for fieldName, expected := range fields { + fieldIndex := -1 + for index := 0; index < structure.NumFields(); index++ { + if structure.Field(index).Name() == fieldName && structure.Field(index).Exported() { + fieldIndex = index + break + } + } + if fieldIndex < 0 || !validationFieldMatches(structure.Field(fieldIndex).Type(), expected, pkg.Path()) { + violations = append(violations, fmt.Sprintf("ValidationError.%s must expose %s", fieldName, expected)) + } + } + if cause := fieldByName(structure, "Cause"); cause == nil || !cause.Exported() || !types.Identical(cause.Type(), types.Universe.Lookup("error").Type()) { + violations = append(violations, "ValidationError.Cause must expose error") + } + return violations +} + +func fieldByName(structure *types.Struct, name string) *types.Var { + for index := 0; index < structure.NumFields(); index++ { + if structure.Field(index).Name() == name { + return structure.Field(index) + } + } + return nil +} + +func validationFieldMatches(fieldType types.Type, expected, packagePath string) bool { + switch expected { + case "[]ValidationFinding": + slice, ok := types.Unalias(fieldType).(*types.Slice) + if !ok { + return false + } + fieldType = slice.Elem() + case "*FileSizeMeasurement": + pointer, ok := types.Unalias(fieldType).(*types.Pointer) + if !ok { + return false + } + fieldType = pointer.Elem() + } + named, ok := types.Unalias(fieldType).(*types.Named) + return ok && named.Obj().Pkg() != nil && named.Obj().Pkg().Path() == packagePath && named.Obj().Name() == strings.TrimLeft(expected, "*[]") +} + func listPackages(ctx context.Context, dir string) ([]listedPackage, error) { cmd := exec.CommandContext(ctx, "go", "list", "-deps", "-export", "-json", "./...") cmd.Dir = dir diff --git a/internal/apicheck/main_test.go b/internal/apicheck/main_test.go index 9ba47ed..a21696a 100644 --- a/internal/apicheck/main_test.go +++ b/internal/apicheck/main_test.go @@ -101,6 +101,35 @@ func Open(context.Context) *Handle { return nil } } } +func TestValidationDiagnosticTypesBelongToFacade(t *testing.T) { + valid := `package facade +type ValidationCheck string +type ValidationFinding struct{} +type FileSizeMeasurement struct{} +type ValidationError struct { + Check ValidationCheck + Findings []ValidationFinding + Measurement *FileSizeMeasurement + Cause error +} +` + pkg := typecheck(t, token.NewFileSet(), modulePath, valid) + if violations := checkValidationTypes(pkg); len(violations) != 0 { + t.Fatalf("checkValidationTypes() = %v, want facade-owned diagnostics", violations) + } + + implementation := typecheck(t, token.NewFileSet(), "example.com/implementation", `package implementation +type State string +`) + fset := token.NewFileSet() + fixtureImporter := singlePackageImporter{path: "example.com/implementation", pkg: implementation} + invalid := strings.Replace(valid, "type ValidationCheck string", "import \"example.com/implementation\"\ntype ValidationCheck = implementation.State", 1) + pkg = typecheckWithImporter(t, fset, modulePath, invalid, fixtureImporter) + if violations := checkValidationTypes(pkg); !strings.Contains(strings.Join(violations, "\n"), "ValidationCheck is not a facade-owned named type") { + t.Fatalf("checkValidationTypes() = %v, want ownership violation", violations) + } +} + func TestActualRepositoryBoundaries(t *testing.T) { if err := checkRepository(t.Context(), "../.."); err != nil { t.Fatal(err) diff --git a/publish.go b/publish.go index eb8988a..7976ba8 100644 --- a/publish.go +++ b/publish.go @@ -76,5 +76,5 @@ func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, er Phase: PublicationPhase(result.Phase), TagObject: result.TagObject, State: PublicationState(result.State), - }, err + }, convertValidationError(err) } diff --git a/publish_test.go b/publish_test.go index 64391cf..a2dbb98 100644 --- a/publish_test.go +++ b/publish_test.go @@ -109,6 +109,17 @@ func TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation(t *testin if err == nil || !strings.Contains(err.Error(), "fixture standard suite failure") { t.Fatalf("Publish() error = %v, want fixture standard suite failure", err) } + var validationError *releasekit.ValidationError + if !errors.As(err, &validationError) || validationError.Check != "go-test" { + t.Fatalf("Publish() error = %v, want a public go-test validation error", err) + } + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("Publish() error = %v, want the Go process error preserved", err) + } + if !errors.Is(err, exitErr) { + t.Fatal("Publish() error does not preserve the Go process error identity") + } if checks != 0 || result.TagObject != "" || result.State != releasekit.PublicationNotAttempted { t.Fatalf("failed standard suite result=%#v additional checks=%d", result, checks) } @@ -122,6 +133,27 @@ func TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation(t *testin } } +func TestCheckReturnsPublicValidationError(t *testing.T) { + repo := newPublishFixture(t) + t.Setenv("RELEASEKIT_FAIL_FIXTURE_SUITE", "1") + _, err := releasekit.Check(context.Background(), releasekit.ReleaseRequest{ + RepositoryDir: repo.work, + Product: "Sample App", + Version: "v1.2.3", + }) + var validationError *releasekit.ValidationError + if !errors.As(err, &validationError) || validationError.Check != "go-test" { + t.Fatalf("Check() error = %v, want a public go-test validation error", err) + } + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("Check() error = %v, want the Go process error preserved", err) + } + if !errors.Is(err, exitErr) { + t.Fatal("Check() error does not preserve the Go process error identity") + } +} + func TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation(t *testing.T) { repo := newPublishFixture(t) suiteRuns := filepath.Join(t.TempDir(), "suite-runs") @@ -140,6 +172,10 @@ func TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation(t *te if !errors.Is(err, wantErr) { t.Fatalf("Publish() error = %v, want wrapped additional-check error", err) } + var validationError *releasekit.ValidationError + if errors.As(err, &validationError) { + t.Fatalf("Publish() callback error was reclassified as validation failure: %v", err) + } if runs, readErr := os.ReadFile(suiteRuns); readErr != nil || string(runs) != "ran\n" { t.Fatalf("standard suite runs = %q, error = %v; want one successful run", runs, readErr) } diff --git a/validation_error.go b/validation_error.go new file mode 100644 index 0000000..9989277 --- /dev/null +++ b/validation_error.go @@ -0,0 +1,121 @@ +package releasekit + +import ( + "errors" + "fmt" + "strings" + + "gitea.maximumdirect.net/eric/ReleaseKit/internal/validation" +) + +// ValidationCheck identifies a shared validation check that failed. +// Current checks include "go-test". +type ValidationCheck string + +// ValidationFinding describes a failed check observation at an optional +// repository-relative source location. +type ValidationFinding struct { + Path string // repository-relative path when available + Line int // one-based source line, or zero when unavailable + Column int // one-based source column, or zero when unavailable + Message string // useful detail that is not promised to have exact wording + Target string // applicable target for a file check + Directive string // applicable directive for a file check +} + +// FileSizeMeasurement reports a measured file size and its selected limit. +type FileSizeMeasurement struct { + Bytes int64 + LimitBytes int64 +} + +// ValidationError describes a failed shared validation check returned by +// [Check] or [Publish]. Cause preserves the contextual and underlying error chain. +type ValidationError struct { + Check ValidationCheck // failed shared check + Findings []ValidationFinding // actionable observations, when available + Measurement *FileSizeMeasurement // measured file size, when applicable + Cause error // original contextual and underlying error chain +} + +// Error returns the contextual validation error summary. +func (failure *ValidationError) Error() string { + if failure == nil { + return "" + } + if failure.Cause != nil { + return failure.Cause.Error() + } + return formatPublicValidationError(failure) +} + +// Unwrap returns the original validation error chain. +func (failure *ValidationError) Unwrap() error { + if failure == nil { + return nil + } + return failure.Cause +} + +func convertValidationError(err error) error { + if err == nil { + return nil + } + var failure *validation.Failure + if !errors.As(err, &failure) { + return err + } + result := &ValidationError{ + Check: ValidationCheck(failure.Check), + Cause: err, + } + if len(failure.Findings) != 0 { + result.Findings = make([]ValidationFinding, len(failure.Findings)) + for index, finding := range failure.Findings { + result.Findings[index] = ValidationFinding{ + Path: finding.Path, + Line: finding.Line, + Column: finding.Column, + Message: finding.Message, + Target: finding.Target, + Directive: finding.Directive, + } + } + } + if failure.Measurement != nil { + result.Measurement = &FileSizeMeasurement{ + Bytes: failure.Measurement.Bytes, + LimitBytes: failure.Measurement.LimitBytes, + } + } + return result +} + +func formatPublicValidationError(failure *ValidationError) string { + 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) + } + return summary.String() +} + +var _ error = (*ValidationError)(nil)