From f866ca98f95fbd8cc092b1b805a5b5c66c48c021 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 7 Oct 2026 16:48:37 +0000 Subject: [PATCH] Retain validation report delivery errors in outcomes --- configkit_test.go | 63 ++++++++++++++++++++++++++++++++++++- docs/roadmap/remediation.md | 3 ++ validate.go | 8 +++-- 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/configkit_test.go b/configkit_test.go index 1b9acbb..73ef2e0 100644 --- a/configkit_test.go +++ b/configkit_test.go @@ -639,7 +639,7 @@ func TestConfigValidationYAMLWarningsAndShortWrites(t *testing.T) { } outcome = app.Execute(context.Background(), []string{"config", "validate"}, commandkit.Streams{Out: configKitPartialWriter{}, Err: io.Discard}) - if outcome.Kind != commandkit.OutcomeOperational || !outcome.ReportIncomplete || !errors.Is(outcome.Cause, io.ErrShortWrite) { + if outcome.Kind != commandkit.OutcomeOperational || !outcome.ReportIncomplete || !errors.Is(outcome.Cause, io.ErrShortWrite) || !errors.Is(outcome.DeliveryError, io.ErrShortWrite) { t.Fatalf("report short write outcome = %#v", outcome) } var delivered bytes.Buffer @@ -657,3 +657,64 @@ func (configKitPartialWriter) Write(data []byte) (int, error) { } return len(data) - 1, nil } + +func TestValidationReportDeliveryErrorsAtEachWriteBoundary(t *testing.T) { + reportErr := errors.New("report failed") + diagnosticErr := errors.New("diagnostic failed") + app, _ := commandkit.New(commandkit.Identity{Name: "example"}) + acquire := func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) { + return commandkit.Acquired[commandkit.ConfigView]{}, nil + } + if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{ + {Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: acquire}, + {Name: "promptkit", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: acquire}, + }}); err != nil { + t.Fatal(err) + } + for _, format := range []string{"text", "yaml"} { + writes := 2 + if format == "yaml" { + writes = 3 + } + for at := 1; at <= writes; at++ { + for _, brokenErr := range []bool{false, true} { + out := &validationBoundaryWriter{failAt: at, err: reportErr} + errStream := io.Writer(io.Discard) + if brokenErr { + errStream = failingWriter{diagnosticErr} + } + result := app.Execute(context.Background(), []string{"config", "validate", "--scope", "all", "--format", format}, commandkit.Streams{Out: out, Err: errStream}) + if result.Kind != commandkit.OutcomeOperational || !result.ReportIncomplete || !errors.Is(result.Cause, reportErr) || !errors.Is(result.DeliveryError, reportErr) || (brokenErr && !errors.Is(result.DeliveryError, diagnosticErr)) { + t.Fatalf("format=%s at=%d brokenErr=%t result=%#v", format, at, brokenErr, result) + } + if at > 1 && out.written.Len() == 0 { + t.Fatal("lost completed prefix") + } + } + } + } + failedApp, _ := commandkit.New(commandkit.Identity{Name: "example"}) + if err := commandkit.RegisterConfig(failedApp, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) { + return commandkit.Acquired[commandkit.ConfigView]{}, reportErr + }}}}); err != nil { + t.Fatal(err) + } + result := failedApp.Execute(context.Background(), []string{"config", "validate"}, commandkit.Streams{}) + if !errors.Is(result.Cause, reportErr) || result.DeliveryError != nil { + t.Fatalf("acquisition failure=%#v", result) + } +} + +type validationBoundaryWriter struct { + failAt, calls int + err error + written bytes.Buffer +} + +func (w *validationBoundaryWriter) Write(p []byte) (int, error) { + w.calls++ + if w.calls == w.failAt { + return 0, w.err + } + return w.written.Write(p) +} diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 72d0702..f681c27 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -57,6 +57,9 @@ architecture. The issues below affect behavior within the implemented structure. - R-04 complete: every help entry form finalizes report failures through the common diagnostic presentation path once. +- R-05 complete: validation header/record write failures retain `DeliveryError` + separately from acquisition failures and combined diagnostic-stream failures. + ## Findings and priority P1 means a supported invocation is blocked or its report content is corrupted. diff --git a/validate.go b/validate.go index d7ddc06..ba08773 100644 --- a/validate.go +++ b/validate.go @@ -68,11 +68,15 @@ func runConfigValidation[C any](invocation Invocation, suite ConfigSuite[C], inp } if input.Format == "yaml" && !writtenRecord { if err := writeReport(invocation.Streams.Out, []byte("scopes:\n")); err != nil { - return validationFailure(err, diagnostics, true) + outcome := validationFailure(err, diagnostics, true) + outcome.DeliveryError = err + return outcome } } if err := writeReport(invocation.Streams.Out, record); err != nil { - return validationFailure(err, diagnostics, true) + outcome := validationFailure(err, diagnostics, true) + outcome.DeliveryError = err + return outcome } writtenRecord = true }