diff --git a/docs/roadmap/audit/catalog.md b/docs/roadmap/audit/catalog.md index fc950ea..42090f0 100644 --- a/docs/roadmap/audit/catalog.md +++ b/docs/roadmap/audit/catalog.md @@ -1,6 +1,6 @@ # Consolidated audit catalog -Status: **23 distinct open findings: one P2 and 22 P3**, consolidated from 30 +Status: **22 distinct open findings: 22 P3**, consolidated from 30 numbered entries in seven audit documents. Reviewed audit state: `db732e3a1de55c844f77870e1c6db683c2685d5d`. @@ -122,6 +122,11 @@ Also noted in the [documentation cross-references](13-documentation.md#previousl ## F05 — P2: Configuration-show cancellation is also recorded as a rendering failure +**Complete.** A cancellation checkpoint stops buffered show preparation without +reporting cancellation as a render error. A regression failed before the fix; +focused tests now verify no partial output, cancellation identity, and retained +native rendering errors when both occur. + ckconfig checks cancellation during buffered show preparation inside the `RenderOutput` callback. Returning that cancellation from the renderer records both the correct operation cause and a false presentation failure, conflating diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 3596db8..5237757 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Audit remediation implementation plan -Status: in progress; Stages 1–4 complete. +Status: in progress; Stages 1–5 complete. Baseline: `d492c7747175c01a53261206c2b4356e53453621`. Before this sprint, intervening changes recorded decisions and retired the completed older @@ -223,6 +223,12 @@ Complete when empty successful acquisition cannot masquerade as prepared preview ## Stage 5 — Separate show preparation cancellation from rendering failures +**Complete.** Buffered show preparation carries cancellation outside managed +rendering without emitting the incomplete report. The two-scope regression +failed against the original behavior and now passes, including simultaneous +native rendering failure; focused show, buffered cancellation and write-failure +tests passed in the offline aligned Go 1.27.0 workspace with `-mod=readonly`. + Findings: F05. Source: ckconfig `show.go`; reference `operations.go`'s renderPreparedConfigReport checkpoint pattern. diff --git a/integrations/ckconfig/report_cancellation_test.go b/integrations/ckconfig/report_cancellation_test.go index fb9aa7d..8731ae2 100644 --- a/integrations/ckconfig/report_cancellation_test.go +++ b/integrations/ckconfig/report_cancellation_test.go @@ -1,6 +1,7 @@ package ckconfig import ( + "bytes" "context" "errors" "io" @@ -67,3 +68,39 @@ func TestBufferedReportDoesNotRejectCancellationAfterDeliveryStarts(t *testing.T t.Fatalf("post-delivery cancellation changed the operation outcome: %#v", got) } } + +type effectiveWriterFunc func(io.Writer) error + +func (write effectiveWriterFunc) WriteEffective(w io.Writer) error { return write(w) } + +func TestShowCancellationDuringBufferedPreparation(t *testing.T) { + renderErr := errors.New("native rendering failed") + for _, tc := range []struct { + name string + renderErr error + }{{name: "cancellation"}, {name: "cancellation and rendering failure", renderErr: renderErr}} { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + acquired, secondWrites := 0, 0 + scope := func(name string, writer EffectiveWriter) Scope[struct{}, struct{}] { + return Scope[struct{}, struct{}]{Name: name, Operations: []Operation{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + acquired++ + return commandkit.Acquired[View]{Value: View{Effective: writer}}, nil + }} + } + app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{ + scope("app", effectiveWriterFunc(func(w io.Writer) error { _, _ = io.WriteString(w, "name: app\n"); cancel(); return tc.renderErr })), + scope("promptkit", effectiveWriterFunc(func(w io.Writer) error { secondWrites++; _, err := io.WriteString(w, "name: promptkit\n"); return err })), + }})) + var output bytes.Buffer + got := app.Execute(ctx, []string{"config", "show", "--scope=all"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if acquired != 2 || secondWrites != 0 || output.Len() != 0 || !errors.Is(got.Cause, context.Canceled) || got.Presentation.Output.WriteError != nil { + t.Fatalf("acquired=%d second=%d output=%q outcome=%#v", acquired, secondWrites, output.String(), got) + } + if tc.renderErr == nil && got.Presentation.Output.RenderError != nil || tc.renderErr != nil && !errors.Is(got.Presentation.Output.RenderError, tc.renderErr) { + t.Fatalf("render evidence = %#v", got.Presentation.Output) + } + }) + } +} diff --git a/integrations/ckconfig/show.go b/integrations/ckconfig/show.go index 68d864e..96e4ea4 100644 --- a/integrations/ckconfig/show.go +++ b/integrations/ckconfig/show.go @@ -66,11 +66,13 @@ func runConfigShow[I, S any](invocation commandkit.Invocation, plan *configOpera return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err) } var value any + var canceled error err := reporter.RenderOutput(func(io.Writer) error { values := make(map[string]any, len(documents)) for _, document := range documents { if err := invocation.Context.Err(); err != nil { - return err + canceled = err + return nil } var native bytes.Buffer if err := document.view.Effective.WriteEffective(&native); err != nil { @@ -89,6 +91,9 @@ func runConfigShow[I, S any](invocation commandkit.Invocation, plan *configOpera } return nil }) + if canceled != nil { + return configFailure("Configuration show was canceled.", commandkit.Explanation{}, canceled) + } if err != nil { if canceled := invocation.Context.Err(); canceled != nil { return configFailure("Configuration show was canceled.", commandkit.Explanation{}, canceled)