diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 5044443..a89b2a2 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Invocation reporting implementation plan -Status: Stages 1–12 complete. Stage 13 is next. +Status: Stages 1–13 complete. Stage 14 is next. ## Objective and authority @@ -496,7 +496,7 @@ old invocation API and is scheduled for migration in Stage 17. ## Stage 13: Migrate PromptKit acquisition adapters -Status: pending. +Status: complete. Primary files: [adapters.go](../../integrations/ckprompt/adapters.go), [validation.go](../../integrations/ckprompt/validation.go), @@ -521,6 +521,18 @@ Validation: establish correct forwarding, passive data, separated context, and supported error identity. Preserve offline fixtures and existing builder/ acquisition lifecycle protections. No live provider or credentials are needed. +PromptKit engine loading now separates independent warning batches from native +terminal records and carries both through catalogs, inspection, requirements, +ConfigScope comparison, concrete request validation, and smoke acquisition. +Native rendering retains scope/side headings, source legends, and diagnostic +locations; original errors and reports returned with errors remain available. +Offline adapter tests cover mixed record separation, writer propagation, +comparison-side context, error identity, and existing request lifetimes. The +ckprompt suite cannot execute yet: compiler diagnostics identify legacy +reporting calls in `command.go`, `smoke_command.go`, and their tests, which are +scheduled for Stages 14 and 15. No compile errors were reported in the migrated +adapter files; Stage 20 will run the assembled suite. + ## Stage 14: Migrate PromptKit listing and inspection commands Status: pending. diff --git a/integrations/ckprompt/adapters.go b/integrations/ckprompt/adapters.go index a1606e5..cbd6bbd 100644 --- a/integrations/ckprompt/adapters.go +++ b/integrations/ckprompt/adapters.go @@ -88,26 +88,33 @@ type Suite[I, S any] struct { // CheckInput validates selected inspection, requirements, validation, smoke, // or catalog input before state creation. CheckInput func(ckconfig.Selection, I) error - // InspectPrompt inspects the selected prompt after input and state preparation. + // InspectPrompt inspects the selected prompt after input and state + // preparation. Its acquisition carries independent loader diagnostics and + // terminal context alongside any native inspection result. InspectPrompt func(context.Context, PromptRequest, I, S) (commandkit.Acquired[*promptkit.PromptInspection], error) - // InspectProfile inspects the selected profile after input and state preparation. + // InspectProfile inspects the selected profile after input and state + // preparation. Its acquisition carries independent loader diagnostics and + // terminal context alongside any native inspection result. InspectProfile func(context.Context, ProfileRequest, I, S) (commandkit.Acquired[*promptkit.ProfileInspection], error) // InspectRequirements inspects native PromptKit requirements after input and - // state preparation. It is optional; a nil callback omits - // `promptkit requirements show` without disabling validation or other - // capabilities. + // state preparation. Its acquisition carries independent loader diagnostics + // and terminal context alongside any native inspection result. It is + // optional; a nil callback omits `promptkit requirements show` without + // disabling validation or other capabilities. InspectRequirements func(context.Context, RequirementsRequest, I, S) (commandkit.Acquired[*promptkit.RequirementInspection], error) // ValidateRequest validates a concrete PromptKit request after input and // state preparation. It is optional; a nil callback omits // `promptkit requirements validate` without disabling discovery or other - // capabilities. The acquisition carries diagnostics and an empty value on - // success; PromptKit errors are returned unchanged where supported. + // capabilities. The acquisition carries independent diagnostics, terminal + // context, and an empty value on success; PromptKit errors are returned + // unchanged where supported. ValidateRequest func(context.Context, ValidationRequest, I, S) (commandkit.Acquired[struct{}], error) // SmokeTest runs one concrete native smoke-test operation after input and // state preparation. It is optional; nil omits only the smoke-test command. - // The acquisition retains a native report even when the callback also - // returns an error. For `test all`, CommandKit calls the captured callback - // sequentially for each selected kind after preparing input and state once. + // The acquisition retains a native report, independent diagnostics, and + // terminal context even when the callback also returns an error. For + // `test all`, CommandKit calls the captured callback sequentially for each + // selected kind after preparing input and state once. SmokeTest func(context.Context, SmokeRequest, I, S) (commandkit.Acquired[*promptkit.SmokeTestReport], error) // ListPrompts lists prompt identities after input and state preparation. // It is optional independently of the other catalogs; nil omits only this @@ -143,9 +150,10 @@ type Suite[I, S any] struct { // selection. A nil builder forwards the seed request unchanged. A builder // failure prevents engine loading and preserves its supported error identity. // Native validation errors are returned with supported identity, and -// configuration diagnostics from engine loading remain in the acquisition. A -// nil loader is retained as a suite wiring issue and does not install a -// validation callback. Callback references and objects remain caller-owned. +// configuration diagnostics and terminal context from engine loading remain +// separate in the acquisition. A nil loader is retained as a suite wiring +// issue and does not install a validation callback. Callback references and +// objects remain caller-owned. func WithRequestValidation[I, S any]( suite Suite[I, S], load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error), @@ -175,34 +183,39 @@ func WithRequestValidation[I, S any]( return commandkit.Acquired[struct{}]{}, fmt.Errorf("PromptKit request validation after building: %w", err) } - engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load) + engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load) if err != nil { - return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, err + return commandkit.Acquired[struct{}]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err } if err := promptKitContextError(ctx); err != nil { - return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit request validation before native validation: %w", err) + return commandkit.Acquired[struct{}]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit request validation before native validation: %w", err) } if err := engine.ValidateRequest(ctx, concreteRequest); err != nil { - return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, err + return commandkit.Acquired[struct{}]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err } if err := promptKitContextError(ctx); err != nil { - return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit request validation after native validation: %w", err) + return commandkit.Acquired[struct{}]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit request validation after native validation: %w", err) } - return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, nil + return commandkit.Acquired[struct{}]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, nil } return suite } type engineLoader[I, S any] func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error) +type promptKitAcquisition struct { + diagnostics []commandkit.Diagnostic + explanation commandkit.Explanation +} + // ConfigScope adapts PromptKit's captured configuration inspection to the // standard configuration commands, including native comparison when the scope // declares Diff. The loader is not called during declaration construction or // installation; it is called only when a config operation selects the // promptkit scope, with that operation's input and shared state. PromptKit -// diagnostics and supported error identity are returned through ckconfig's -// acquisition and comparison contracts. +// independent diagnostics, terminal context, and supported error identity are +// returned through ckconfig's acquisition and comparison contracts. func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error)) ckconfig.Scope[I, S] { scope := ckconfig.Scope[I, S]{ Name: "promptkit", @@ -214,9 +227,9 @@ func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S) } scope.Acquire = func(ctx context.Context, request ckconfig.Request, input I, state S) (commandkit.Acquired[ckconfig.View], error) { - _, inspection, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load) + _, inspection, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load) if err != nil { - return commandkit.Acquired[ckconfig.View]{Diagnostics: diagnostics}, err + return commandkit.Acquired[ckconfig.View]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err } return commandkit.Acquired[ckconfig.View]{ Value: ckconfig.View{ @@ -224,7 +237,8 @@ func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S) Sources: inspection, Explanation: inspection, }, - Diagnostics: diagnostics, + Diagnostics: messages.diagnostics, + Explanation: messages.explanation, }, nil } scope.Compare = func(ctx context.Context, request ckconfig.ComparisonRequest[I], state S) (commandkit.Acquired[[]configkit.Difference], error) { @@ -232,31 +246,30 @@ func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S) return commandkit.Acquired[[]configkit.Difference]{}, fmt.Errorf("PromptKit scope %q comparison before left load: %w", scope.Name, err) } - _, leftInspection, leftDiagnostics, err := loadPromptKitEngine(ctx, request.Left.Selection, request.Left.Input, state, "left", load) - diagnostics := append([]commandkit.Diagnostic(nil), leftDiagnostics...) + _, leftInspection, messages, err := loadPromptKitEngine(ctx, request.Left.Selection, request.Left.Input, state, "left", load) if err != nil { - return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit scope %q left comparison load: %w", scope.Name, err) + return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q left comparison load: %w", scope.Name, err) } if err := promptKitContextError(ctx); err != nil { - return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit scope %q comparison after left load: %w", scope.Name, err) + return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q comparison after left load: %w", scope.Name, err) } - _, rightInspection, rightDiagnostics, err := loadPromptKitEngine(ctx, request.Right.Selection, request.Right.Input, state, "right", load) - diagnostics = append(diagnostics, rightDiagnostics...) + _, rightInspection, rightMessages, err := loadPromptKitEngine(ctx, request.Right.Selection, request.Right.Input, state, "right", load) + messages = appendPromptKitAcquisition(messages, rightMessages) if err != nil { - return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit scope %q right comparison load: %w", scope.Name, err) + return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q right comparison load: %w", scope.Name, err) } if err := promptKitContextError(ctx); err != nil { - return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit scope %q comparison before native diff: %w", scope.Name, err) + return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q comparison before native diff: %w", scope.Name, err) } differences, err := leftInspection.Diff(rightInspection) if err != nil { - return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit scope %q native configuration diff: %w", scope.Name, err) + return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q native configuration diff: %w", scope.Name, err) } - return commandkit.Acquired[[]configkit.Difference]{Value: differences, Diagnostics: diagnostics}, nil + return commandkit.Acquired[[]configkit.Difference]{Value: differences, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, nil } return scope } @@ -280,8 +293,8 @@ func promptKitContextError(ctx context.Context) error { // shared invocation state to the loader before calling PromptKit's public // listing or inspection method. Applications can narrow, wrap, or replace these // callbacks before Command captures them. A nil loader is retained as an -// adapter issue. Each callback returns loader diagnostics with the native -// operation's value and error. +// adapter issue. Each callback returns independent loader diagnostics and +// terminal context alongside the native operation's value and error. func FromEngine[I, S any](load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error)) Suite[I, S] { if load == nil { return Suite[I, S]{issues: []error{errors.New("PromptKit engine loader is nil")}} @@ -289,68 +302,68 @@ func FromEngine[I, S any](load func(context.Context, ckconfig.Selection, I, S) ( return Suite[I, S]{ ListPrompts: func(ctx context.Context, request CatalogRequest, input I, state S) (commandkit.Acquired[[]promptkit.PromptIdentity], error) { - engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load) + engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load) if err != nil { - return commandkit.Acquired[[]promptkit.PromptIdentity]{Diagnostics: diagnostics}, err + return commandkit.Acquired[[]promptkit.PromptIdentity]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err } prompts, err := engine.ListPrompts(ctx, request.Filter) - return commandkit.Acquired[[]promptkit.PromptIdentity]{Value: prompts, Diagnostics: diagnostics}, err + return commandkit.Acquired[[]promptkit.PromptIdentity]{Value: prompts, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err }, ListProfiles: func(ctx context.Context, request CatalogRequest, input I, state S) (commandkit.Acquired[[]string], error) { - engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load) + engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load) if err != nil { - return commandkit.Acquired[[]string]{Diagnostics: diagnostics}, err + return commandkit.Acquired[[]string]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err } profiles, err := engine.ListProfiles(ctx, request.Filter) - return commandkit.Acquired[[]string]{Value: profiles, Diagnostics: diagnostics}, err + return commandkit.Acquired[[]string]{Value: profiles, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err }, ListBackends: func(ctx context.Context, request CatalogRequest, input I, state S) (commandkit.Acquired[[]string], error) { - engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load) + engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load) if err != nil { - return commandkit.Acquired[[]string]{Diagnostics: diagnostics}, err + return commandkit.Acquired[[]string]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err } backends, err := engine.ListBackends(ctx, request.Filter) - return commandkit.Acquired[[]string]{Value: backends, Diagnostics: diagnostics}, err + return commandkit.Acquired[[]string]{Value: backends, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err }, ListNamespaces: func(ctx context.Context, request NamespaceRequest, input I, state S) (commandkit.Acquired[[]string], error) { - engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load) + engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load) if err != nil { - return commandkit.Acquired[[]string]{Diagnostics: diagnostics}, err + return commandkit.Acquired[[]string]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err } namespaces, err := engine.ListNamespaces(ctx, request.Kinds...) - return commandkit.Acquired[[]string]{Value: namespaces, Diagnostics: diagnostics}, err + return commandkit.Acquired[[]string]{Value: namespaces, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err }, ListCollections: func(ctx context.Context, selection ckconfig.Selection, input I, state S) (commandkit.Acquired[[]promptkit.CollectionInfo], error) { - engine, _, diagnostics, err := loadPromptKitEngine(ctx, selection, input, state, "", load) + engine, _, messages, err := loadPromptKitEngine(ctx, selection, input, state, "", load) if err != nil { - return commandkit.Acquired[[]promptkit.CollectionInfo]{Diagnostics: diagnostics}, err + return commandkit.Acquired[[]promptkit.CollectionInfo]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err } collections, err := engine.ListCollections(ctx) - return commandkit.Acquired[[]promptkit.CollectionInfo]{Value: collections, Diagnostics: diagnostics}, err + return commandkit.Acquired[[]promptkit.CollectionInfo]{Value: collections, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err }, InspectPrompt: func(ctx context.Context, request PromptRequest, input I, state S) (commandkit.Acquired[*promptkit.PromptInspection], error) { - engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load) + engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load) if err != nil { - return commandkit.Acquired[*promptkit.PromptInspection]{Diagnostics: diagnostics}, err + return commandkit.Acquired[*promptkit.PromptInspection]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err } inspection, err := engine.InspectPrompt(ctx, request.ID) - return commandkit.Acquired[*promptkit.PromptInspection]{Value: inspection, Diagnostics: diagnostics}, err + return commandkit.Acquired[*promptkit.PromptInspection]{Value: inspection, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err }, InspectProfile: func(ctx context.Context, request ProfileRequest, input I, state S) (commandkit.Acquired[*promptkit.ProfileInspection], error) { - engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load) + engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load) if err != nil { - return commandkit.Acquired[*promptkit.ProfileInspection]{Diagnostics: diagnostics}, err + return commandkit.Acquired[*promptkit.ProfileInspection]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err } inspection, err := engine.InspectProfile(ctx, request.ID) - return commandkit.Acquired[*promptkit.ProfileInspection]{Value: inspection, Diagnostics: diagnostics}, err + return commandkit.Acquired[*promptkit.ProfileInspection]{Value: inspection, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err }, InspectRequirements: func(ctx context.Context, request RequirementsRequest, input I, state S) (commandkit.Acquired[*promptkit.RequirementInspection], error) { - engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load) + engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load) if err != nil { - return commandkit.Acquired[*promptkit.RequirementInspection]{Diagnostics: diagnostics}, err + return commandkit.Acquired[*promptkit.RequirementInspection]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err } inspection, err := engine.InspectRequirements(ctx, request.Inspection) - return commandkit.Acquired[*promptkit.RequirementInspection]{Value: inspection, Diagnostics: diagnostics}, err + return commandkit.Acquired[*promptkit.RequirementInspection]{Value: inspection, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err }, } } @@ -362,41 +375,44 @@ func loadPromptKitEngine[I, S any]( state S, side string, load engineLoader[I, S], -) (*promptkit.Engine, promptkit.ConfigurationInspection, []commandkit.Diagnostic, error) { +) (*promptkit.Engine, promptkit.ConfigurationInspection, promptKitAcquisition, error) { engine, err := load(ctx, selection, input, state) if err != nil { var loadErr *configkit.LoadError if errors.As(err, &loadErr) { - return nil, promptkit.ConfigurationInspection{}, promptKitDiagnostics("promptkit", side, loadErr.Sources(), loadErr.Diagnostics()), err + messages := promptKitDiagnostics("promptkit", side, loadErr.Sources(), loadErr.Diagnostics()) + return nil, promptkit.ConfigurationInspection{}, messages, err } - return nil, promptkit.ConfigurationInspection{}, nil, err + return nil, promptkit.ConfigurationInspection{}, promptKitAcquisition{}, err } if engine == nil { - return nil, promptkit.ConfigurationInspection{}, nil, errors.New("PromptKit configuration loader returned a nil engine") + return nil, promptkit.ConfigurationInspection{}, promptKitAcquisition{}, errors.New("PromptKit configuration loader returned a nil engine") } inspection, err := engine.InspectConfiguration() + messages := promptKitDiagnostics("promptkit", side, inspection.Sources(), inspection.Diagnostics()) if err != nil { - return nil, promptkit.ConfigurationInspection{}, nil, err + return nil, inspection, messages, err } - diagnostics := promptKitDiagnostics("promptkit", side, inspection.Sources(), inspection.Diagnostics()) if ctx != nil { if err := ctx.Err(); err != nil { - return nil, promptkit.ConfigurationInspection{}, diagnostics, err + return nil, promptkit.ConfigurationInspection{}, messages, err } } - return engine, inspection, diagnostics, nil + return engine, inspection, messages, nil } -func promptKitDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic { - if len(records) == 0 { - return nil +func appendPromptKitAcquisition(left, right promptKitAcquisition) promptKitAcquisition { + left.diagnostics = append(left.diagnostics, right.diagnostics...) + left.explanation.Details = append(left.explanation.Details, right.explanation.Details...) + if left.explanation.Message == "" { + left.explanation.Message = right.explanation.Message } - severity := commandkit.SeverityWarning - for _, record := range records { - if record.Severity == configkit.SeverityError { - severity = commandkit.SeverityError - break - } + return left +} + +func promptKitDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) promptKitAcquisition { + if len(records) == 0 { + return promptKitAcquisition{} } // Snapshot provider-owned slices before deferring their report to invocation. sources = append([]configkit.SourceInfo(nil), sources...) @@ -408,27 +424,48 @@ func promptKitDiagnostics(scope, side string, sources []configkit.SourceInfo, re } records[index].Related = append([]configkit.Location(nil), records[index].Related...) } - return []commandkit.Diagnostic{{ - Severity: severity, - Write: func(writer io.Writer) error { - heading := "Configuration scope " + strconv.Quote(scope) - if side != "" { - heading += " (" + side + ")" - } - if _, err := fmt.Fprintf(writer, "%s:\n", heading); err != nil { + var warnings, failures []configkit.Diagnostic + for _, record := range records { + if record.Severity == configkit.SeverityError { + failures = append(failures, record) + } else { + warnings = append(warnings, record) + } + } + messages := promptKitAcquisition{} + if len(warnings) > 0 { + messages.diagnostics = []commandkit.Diagnostic{{ + Severity: commandkit.SeverityWarning, + Write: func(writer io.Writer) error { + return writePromptKitDiagnostics(writer, scope, side, sources, warnings) + }, + }} + } + if len(failures) > 0 { + messages.explanation = commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error { + return writePromptKitDiagnostics(writer, scope, side, sources, failures) + }}} + } + return messages +} + +func writePromptKitDiagnostics(writer io.Writer, scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) error { + heading := "Configuration scope " + strconv.Quote(scope) + if side != "" { + heading += " (" + side + ")" + } + if _, err := fmt.Fprintf(writer, "%s:\n", heading); err != nil { + return err + } + if len(sources) > 0 { + if _, err := io.WriteString(writer, "Source IDs:\n"); err != nil { + return err + } + for _, source := range sources { + if _, err := fmt.Fprintf(writer, " %s: label=%s, path=%s\n", strconv.Quote(source.ID), strconv.Quote(source.Label), strconv.Quote(source.Path)); err != nil { return err } - if len(sources) > 0 { - if _, err := io.WriteString(writer, "Source IDs:\n"); err != nil { - return err - } - for _, source := range sources { - if _, err := fmt.Fprintf(writer, " %s: label=%s, path=%s\n", strconv.Quote(source.ID), strconv.Quote(source.Label), strconv.Quote(source.Path)); err != nil { - return err - } - } - } - return configkit.WriteDiagnostics(writer, records) - }, - }} + } + } + return configkit.WriteDiagnostics(writer, records) } diff --git a/integrations/ckprompt/adapters_test.go b/integrations/ckprompt/adapters_test.go index 988707f..e7b7637 100644 --- a/integrations/ckprompt/adapters_test.go +++ b/integrations/ckprompt/adapters_test.go @@ -239,7 +239,7 @@ func TestWithSmokeTestingRetainsNilLoaderIssueAndChecksCancellationBeforeBuild(t } } -func TestWithSmokeTestingRetainsLoaderDiagnosticsAndErrorIdentity(t *testing.T) { +func TestWithSmokeTestingRetainsLoaderExplanationAndErrorIdentity(t *testing.T) { _, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid smoke configuration", []byte("broken: ["))) if loadErr == nil { t.Fatal("invalid configuration unexpectedly loaded") @@ -249,7 +249,7 @@ func TestWithSmokeTestingRetainsLoaderDiagnosticsAndErrorIdentity(t *testing.T) }, nil) acquired, err := suite.SmokeTest(context.Background(), SmokeRequest{}, 0, 0) var retained *configkit.LoadError - if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || acquired.Value != nil || len(acquired.Diagnostics) == 0 { + if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || acquired.Value != nil || len(acquired.Explanation.Details) == 0 { t.Fatalf("smoke load failure = (%#v, %v)", acquired, err) } } @@ -394,7 +394,7 @@ func TestConfigScopeCompareStopsAfterLeftLoadFailure(t *testing.T) { } } -func TestConfigScopeCompareRetainsRightLoadDiagnosticsAndErrorIdentity(t *testing.T) { +func TestConfigScopeCompareRetainsRightLoadExplanationAndErrorIdentity(t *testing.T) { _, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid right prompt config", []byte("broken: ["))) if loadErr == nil { t.Fatal("invalid right configuration unexpectedly loaded") @@ -415,12 +415,12 @@ func TestConfigScopeCompareRetainsRightLoadDiagnosticsAndErrorIdentity(t *testin acquired, err := scope.Compare(context.Background(), request, struct{}{}) var retained *configkit.LoadError - if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || len(calls) != 2 || len(acquired.Diagnostics) == 0 { + if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || len(calls) != 2 || len(acquired.Explanation.Details) == 0 { t.Fatalf("right failure = (%#v, %v), calls=%#v", acquired, err, calls) } var output bytes.Buffer - if writeErr := acquired.Diagnostics[0].Write(&output); writeErr != nil || !strings.Contains(output.String(), `Configuration scope "promptkit" (right):`) { - t.Fatalf("right diagnostic = %q, %v", output.String(), writeErr) + if writeErr := acquired.Explanation.Details[0](&output); writeErr != nil || !strings.Contains(output.String(), `Configuration scope "promptkit" (right):`) { + t.Fatalf("right explanation = %q, %v", output.String(), writeErr) } } @@ -534,7 +534,7 @@ func TestFromEngineInspectsRequirementsWithNativeRequest(t *testing.T) { } } -func TestFromEngineRequirementsRetainLoadDiagnosticsAndSelectionErrors(t *testing.T) { +func TestFromEngineRequirementsRetainLoadExplanationAndSelectionErrors(t *testing.T) { _, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid requirements configuration", []byte("broken: ["))) if loadErr == nil { t.Fatal("invalid configuration unexpectedly loaded") @@ -544,11 +544,8 @@ func TestFromEngineRequirementsRetainLoadDiagnosticsAndSelectionErrors(t *testin }) acquired, err := loadFailure.InspectRequirements(context.Background(), RequirementsRequest{}, 0, 0) var retained *configkit.LoadError - if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 || acquired.Value != nil { - t.Fatalf("requirements load failure = (%#v, %v), want original ConfigKit error and diagnostics", acquired, err) - } - if acquired.Diagnostics[0].Severity != commandkit.SeverityError { - t.Fatalf("load diagnostics = %#v, want error severity", acquired.Diagnostics) + if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 || acquired.Value != nil { + t.Fatalf("requirements load failure = (%#v, %v), want original ConfigKit error and terminal context", acquired, err) } nilEngine := FromEngine[int, int](func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) { @@ -651,7 +648,7 @@ func TestFromEngineListsNativeCatalogsWithInvocationValues(t *testing.T) { } } -func TestFromEngineCatalogsRetainLoadDiagnosticsAndErrorIdentity(t *testing.T) { +func TestFromEngineCatalogsRetainLoadExplanationAndErrorIdentity(t *testing.T) { _, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid catalog configuration", []byte("broken: ["))) if loadErr == nil { t.Fatal("invalid configuration unexpectedly loaded") @@ -666,42 +663,42 @@ func TestFromEngineCatalogsRetainLoadDiagnosticsAndErrorIdentity(t *testing.T) { {name: "prompts", call: func() (int, error) { acquired, err := suite.ListPrompts(context.Background(), CatalogRequest{}, 0, 0) var retained *configkit.LoadError - if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 { + if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 { t.Errorf("prompt load failure = (%#v, %v)", acquired, err) } - return len(acquired.Diagnostics), err + return len(acquired.Diagnostics) + len(acquired.Explanation.Details), err }}, {name: "profiles", call: func() (int, error) { acquired, err := suite.ListProfiles(context.Background(), CatalogRequest{}, 0, 0) var retained *configkit.LoadError - if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 { + if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 { t.Errorf("profile load failure = (%#v, %v)", acquired, err) } - return len(acquired.Diagnostics), err + return len(acquired.Diagnostics) + len(acquired.Explanation.Details), err }}, {name: "backends", call: func() (int, error) { acquired, err := suite.ListBackends(context.Background(), CatalogRequest{}, 0, 0) var retained *configkit.LoadError - if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 { + if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 { t.Errorf("backend load failure = (%#v, %v)", acquired, err) } - return len(acquired.Diagnostics), err + return len(acquired.Diagnostics) + len(acquired.Explanation.Details), err }}, {name: "namespaces", call: func() (int, error) { acquired, err := suite.ListNamespaces(context.Background(), NamespaceRequest{}, 0, 0) var retained *configkit.LoadError - if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 { + if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 { t.Errorf("namespace load failure = (%#v, %v)", acquired, err) } - return len(acquired.Diagnostics), err + return len(acquired.Diagnostics) + len(acquired.Explanation.Details), err }}, {name: "collections", call: func() (int, error) { acquired, err := suite.ListCollections(context.Background(), ckconfig.Selection{}, 0, 0) var retained *configkit.LoadError - if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 { + if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 { t.Errorf("collection load failure = (%#v, %v)", acquired, err) } - return len(acquired.Diagnostics), err + return len(acquired.Diagnostics) + len(acquired.Explanation.Details), err }}, } { t.Run(test.name, func(t *testing.T) { @@ -782,7 +779,7 @@ func TestAdapterAcquisitionPreservesLoadAndContextErrors(t *testing.T) { } } -func TestFromEngineRetainsConfigKitLoadDiagnostics(t *testing.T) { +func TestFromEngineRetainsConfigKitLoadExplanation(t *testing.T) { _, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid PromptKit config", []byte("broken: ["))) if loadErr == nil { t.Fatal("invalid configuration unexpectedly loaded") @@ -793,11 +790,8 @@ func TestFromEngineRetainsConfigKitLoadDiagnostics(t *testing.T) { acquired, err := suite.InspectProfile(context.Background(), ProfileRequest{ID: "default"}, 0, 0) var retained *configkit.LoadError - if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 { - t.Fatalf("load failure = (%#v, %v), want original ConfigKit error and diagnostics", acquired, err) - } - if acquired.Diagnostics[0].Severity != commandkit.SeverityError { - t.Fatalf("load diagnostics = %#v, want error severity", acquired.Diagnostics) + if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 { + t.Fatalf("load failure = (%#v, %v), want original ConfigKit error and terminal context", acquired, err) } } @@ -823,25 +817,55 @@ func TestPromptKitDiagnosticsRemainDeferredAndPropagateWriterErrors(t *testing.T Severity: configkit.SeverityWarning, Message: "safe warning", }} - diagnostics := promptKitDiagnostics("promptkit", "", sources, records) - if len(diagnostics) != 1 || diagnostics[0].Severity != commandkit.SeverityWarning { - t.Fatalf("diagnostics = %#v", diagnostics) + messages := promptKitDiagnostics("promptkit", "", sources, records) + if len(messages.diagnostics) != 1 || messages.diagnostics[0].Severity != commandkit.SeverityWarning || len(messages.explanation.Details) != 0 { + t.Fatalf("diagnostic messages = %#v", messages) } - if err := diagnostics[0].Write(failingDiagnosticWriter{err: writeErr}); !errors.Is(err, writeErr) { + if err := messages.diagnostics[0].Write(failingDiagnosticWriter{err: writeErr}); !errors.Is(err, writeErr) { t.Fatalf("diagnostic write error = %v", err) } var output bytes.Buffer - if err := diagnostics[0].Write(&output); err != nil || !strings.Contains(output.String(), `Configuration scope "promptkit":`) || !strings.Contains(output.String(), "safe warning") || !strings.Contains(output.String(), `"source"`) { + if err := messages.diagnostics[0].Write(&output); err != nil || !strings.Contains(output.String(), `Configuration scope "promptkit":`) || !strings.Contains(output.String(), "safe warning") || !strings.Contains(output.String(), `"source"`) { t.Fatalf("diagnostic text = %q, %v", output.String(), err) } sideDiagnostics := promptKitDiagnostics("promptkit", "left", sources, records) output.Reset() - if err := sideDiagnostics[0].Write(&output); err != nil || !strings.Contains(output.String(), `Configuration scope "promptkit" (left):`) { + if err := sideDiagnostics.diagnostics[0].Write(&output); err != nil || !strings.Contains(output.String(), `Configuration scope "promptkit" (left):`) { t.Fatalf("comparison-side diagnostic text = %q, %v", output.String(), err) } } +func TestPromptKitDiagnosticsSeparateIndependentWarningsAndTerminalRecords(t *testing.T) { + sources := []configkit.SourceInfo{{ID: "source", Label: "safe label", Path: "config.yml"}} + primary := configkit.Location{SourceID: "source", Line: 3, Column: 2} + related := configkit.Location{SourceID: "base.yml", Line: 7, Column: 4} + messages := promptKitDiagnostics("promptkit", "right", sources, []configkit.Diagnostic{ + {Severity: configkit.SeverityWarning, Message: "independent warning", Primary: &primary, Related: []configkit.Location{related}}, + {Severity: configkit.SeverityError, Message: "terminal configuration error", Primary: &related, Related: []configkit.Location{primary}}, + }) + if len(messages.diagnostics) != 1 || len(messages.explanation.Details) != 1 { + t.Fatalf("mixed acquisition messages = %#v", messages) + } + writeErr := errors.New("terminal explanation writer failed") + if err := messages.explanation.Details[0](failingDiagnosticWriter{err: writeErr}); !errors.Is(err, writeErr) { + t.Fatalf("terminal explanation write error = %v", err) + } + var warning, terminal bytes.Buffer + if err := messages.diagnostics[0].Write(&warning); err != nil { + t.Fatal(err) + } + if err := messages.explanation.Details[0](&terminal); err != nil { + t.Fatal(err) + } + if got := warning.String(); !strings.Contains(got, "independent warning") || !strings.Contains(got, `Configuration scope "promptkit" (right):`) || !strings.Contains(got, `"source": label="safe label", path="config.yml"`) || !strings.Contains(got, "source:3:2") || !strings.Contains(got, "related: base.yml:7:4") || strings.Contains(got, "terminal configuration error") { + t.Fatalf("independent warning report = %q", got) + } + if got := terminal.String(); !strings.Contains(got, "terminal configuration error") || !strings.Contains(got, `Configuration scope "promptkit" (right):`) || !strings.Contains(got, `"source": label="safe label", path="config.yml"`) || !strings.Contains(got, "base.yml:7:4") || !strings.Contains(got, "related: source:3:2") || strings.Contains(got, "independent warning") { + t.Fatalf("terminal explanation report = %q", got) + } +} + type failingDiagnosticWriter struct{ err error } func (writer failingDiagnosticWriter) Write([]byte) (int, error) { return 0, writer.err } diff --git a/integrations/ckprompt/smoke.go b/integrations/ckprompt/smoke.go index f915c53..5569a63 100644 --- a/integrations/ckprompt/smoke.go +++ b/integrations/ckprompt/smoke.go @@ -26,8 +26,8 @@ type SmokeRequest struct { // invocation context. Its complete returned request is passed to PromptKit; // without a builder, the seed is forwarded unchanged. Reports and errors from // PromptKit are both retained. A nil loader is kept as a suite wiring issue and -// does not install a smoke-test callback. The callback also retains engine -// loading diagnostics with the report and error. +// does not install a smoke-test callback. The callback also retains independent +// engine diagnostics and terminal context with the report and error. func WithSmokeTesting[I, S any]( suite Suite[I, S], load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error), @@ -56,16 +56,16 @@ func WithSmokeTesting[I, S any]( return commandkit.Acquired[*promptkit.SmokeTestReport]{}, fmt.Errorf("PromptKit smoke test after building: %w", err) } - engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load) + engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load) if err != nil { - return commandkit.Acquired[*promptkit.SmokeTestReport]{Diagnostics: diagnostics}, err + return commandkit.Acquired[*promptkit.SmokeTestReport]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err } if err := promptKitContextError(ctx); err != nil { - return commandkit.Acquired[*promptkit.SmokeTestReport]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit smoke test before native operation: %w", err) + return commandkit.Acquired[*promptkit.SmokeTestReport]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit smoke test before native operation: %w", err) } report, err := engine.SmokeTest(ctx, concreteRequest) - return commandkit.Acquired[*promptkit.SmokeTestReport]{Value: report, Diagnostics: diagnostics}, err + return commandkit.Acquired[*promptkit.SmokeTestReport]{Value: report, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err } return suite } diff --git a/integrations/ckprompt/validation_adapters_test.go b/integrations/ckprompt/validation_adapters_test.go index 754e397..37452f0 100644 --- a/integrations/ckprompt/validation_adapters_test.go +++ b/integrations/ckprompt/validation_adapters_test.go @@ -213,7 +213,7 @@ func TestWithRequestValidationBuilderOutputReachesNativeValidation(t *testing.T) } } -func TestWithRequestValidationRetainsNilEngineAndLoadDiagnostics(t *testing.T) { +func TestWithRequestValidationRetainsNilEngineAndLoadExplanation(t *testing.T) { request := ValidationRequest{Request: promptkit.RunRequest{PromptID: "empty", ProfileID: "default"}} nilEngineSuite := WithRequestValidation(Suite[struct{}, struct{}]{}, func(context.Context, ckconfig.Selection, struct{}, struct{}) (*promptkit.Engine, error) { return nil, nil @@ -232,8 +232,8 @@ func TestWithRequestValidationRetainsNilEngineAndLoadDiagnostics(t *testing.T) { }, nil) acquired, err = loadFailure.ValidateRequest(context.Background(), request, struct{}{}, struct{}{}) var retained *configkit.LoadError - if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 { - t.Fatalf("load failure = (%#v, %v), want original ConfigKit error and diagnostics", acquired, err) + if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 { + t.Fatalf("load failure = (%#v, %v), want original ConfigKit error and terminal context", acquired, err) } } @@ -289,7 +289,7 @@ func TestWithRequestValidationCancellationBoundaries(t *testing.T) { }, nil) acquired, err := suite.ValidateRequest(ctx, request, 0, 0) var retained *configkit.LoadError - if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 || loaderCalls != 1 { + if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 || loaderCalls != 1 { t.Fatalf("cancelled load = (%#v, %v), loader calls=%d", acquired, err, loaderCalls) } })