diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 2046ab6..b95b294 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -1,6 +1,6 @@ # Shared reporting and PromptKit adoption remediation -Status: three open findings; R1–R4 complete. R1–R3 are from the architectural review of +Status: two open findings; R1–R5 complete. R1–R3 are from the architectural review of `f83866e44a8a9066db52cbd2aa5cccde0d69a839`. R4–R7 are from the algorithmic audit of `f5b2d1f80c4720b762f3301c29d5050710c210d0`; R4 is a correctness issue and R5–R7 are efficiency opportunities for consideration. @@ -211,6 +211,13 @@ on quoting style. ## R5 — P3: Validation filter membership rescans operator lists +**Complete.** Severity and code membership sets are built once per invocation +and shared by all reached scopes. Membership work is now expected O(C+V+D), +with O(C+V) storage, excluding string and existing projection costs. Expanded +public command checks preserve match-any/match-both semantics, duplicates, unknown +codes, ordering, evidence states, source references, and failure outcomes. The +independent ckconfig suite passes with Go 1.27.0. + **Location:** [validate.go](../../integrations/ckconfig/validate.go), projectValidationDetails lines 128–133. diff --git a/integrations/ckconfig/detailed_validation_test.go b/integrations/ckconfig/detailed_validation_test.go index 462c3db..43fe721 100644 --- a/integrations/ckconfig/detailed_validation_test.go +++ b/integrations/ckconfig/detailed_validation_test.go @@ -60,6 +60,44 @@ func TestDetailedValidationFiltersAndRetainsFailureEvidence(t *testing.T) { if strings.Contains(diag.String(), "native warnings") || !strings.Contains(diag.String(), "application warning") || !strings.Contains(diag.String(), "terminal failure") { t.Fatalf("diagnostics=%q", diag.String()) } + + for _, tc := range []struct { + name string + flags []string + first, second []string + }{ + {"any within filters", []string{"--severity=warning", "--severity=error", "--code=chosen", "--code=other", "--code=failed"}, []string{"selected warning", "other warning"}, []string{"failed finding"}}, + {"duplicates and unknown", []string{"--severity=warning", "--severity=warning", "--code=chosen", "--code=chosen", "--code=future-code"}, []string{"selected warning"}, nil}, + {"both filters", []string{"--severity=error", "--code=chosen", "--code=failed"}, nil, []string{"failed finding"}}, + {"no matching code", []string{"--code=future-code"}, nil, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + var out bytes.Buffer + args := append([]string{"config", "validate", "--scope=all", "--details", "--format=json"}, tc.flags...) + got := app.Execute(context.Background(), args, commandkit.Streams{Out: &out, Err: io.Discard}) + var report validationReport + if err := json.Unmarshal(out.Bytes(), &report); err != nil { + t.Fatal(err) + } + if !errors.Is(got.Cause, cause) || len(report.Scopes) != 2 { + t.Fatalf("outcome=%#v report=%#v", got, report) + } + for index, want := range [][]string{tc.first, tc.second} { + details := report.Scopes[index].Details + if len(details.Findings) != len(want) { + t.Fatalf("findings=%#v want=%v", details.Findings, want) + } + for i, message := range want { + if details.Findings[i].Message != message { + t.Fatalf("finding order=%#v want=%v", details.Findings, want) + } + } + if len(want) == 0 && details.State != "filtered" { + t.Fatalf("state=%q", details.State) + } + } + }) + } before := len(calls) for _, args := range [][]string{{"--severity=warning"}, {"--code=x"}, {"--details", "--severity=unknown"}} { result = app.Execute(context.Background(), append([]string{"config", "validate"}, args...), commandkit.Streams{Out: io.Discard, Err: io.Discard}) diff --git a/integrations/ckconfig/validate.go b/integrations/ckconfig/validate.go index bcea16e..7ef2018 100644 --- a/integrations/ckconfig/validate.go +++ b/integrations/ckconfig/validate.go @@ -3,7 +3,6 @@ package ckconfig import ( "errors" "fmt" - "slices" "text/template" commandkit "go.maximumdirect.net/commandkit" @@ -62,6 +61,14 @@ func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *confi reporter := invocation.Reporter() report := validationReport{Scopes: make([]validationScope, 0, len(plan.scopes))} outcome := commandkit.Success() + severities := make(map[configkit.Severity]bool, len(plan.severities)) + for _, severity := range plan.severities { + severities[severity] = true + } + codes := make(map[string]bool, len(plan.codes)) + for _, code := range plan.codes { + codes[code] = true + } for _, selected := range plan.scopes { if err := invocation.Context.Err(); err != nil { outcome = configFailure("Configuration validation was canceled.", commandkit.Explanation{}, err) @@ -71,7 +78,7 @@ func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *confi if selected.scope.scope.Acquire == nil { entry := validationScope{Scope: name, Outcome: "failed"} if plan.details { - entry.Details = projectValidationDetails(View{}, plan.severities, plan.codes) + entry.Details = projectValidationDetails(View{}, severities, codes) } report.Scopes = append(report.Scopes, entry) outcome = commandkit.Failure(fmt.Sprintf("Configuration scope %q has no acquisition callback.", name)) @@ -84,7 +91,7 @@ func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *confi } entry := validationScope{Scope: name, Outcome: "valid"} if plan.details { - entry.Details = projectValidationDetails(acquired.Value, plan.severities, plan.codes) + entry.Details = projectValidationDetails(acquired.Value, severities, codes) } if canceled := invocation.Context.Err(); canceled != nil { entry.Outcome = "canceled" @@ -114,7 +121,7 @@ func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *confi return outcome } -func projectValidationDetails(view View, severities []configkit.Severity, codes []string) *validationDetails { +func projectValidationDetails(view View, severities map[configkit.Severity]bool, codes map[string]bool) *validationDetails { details := &validationDetails{State: "unavailable", Findings: []validationFinding{}, Sources: []validationSource{}} if view.Findings == nil { return details @@ -126,10 +133,10 @@ func projectValidationDetails(view View, severities []configkit.Severity, codes details.State = "filtered" references := make(map[string]bool) for _, record := range view.Findings.Records { - if len(severities) > 0 && !slices.Contains(severities, record.Severity) { + if len(severities) > 0 && !severities[record.Severity] { continue } - if len(codes) > 0 && !slices.Contains(codes, string(record.Code)) { + if len(codes) > 0 && !codes[string(record.Code)] { continue } finding := validationFinding{Code: string(record.Code), Severity: string(record.Severity), Path: record.Path.String(), Message: record.Message, Related: locationReports(record.Related)}