diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index bcba66e..9b5d864 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -516,6 +516,13 @@ many entries, heavily escaped and invalid strings, valid JSON, and omission semantics. Repeat the roadmap's three formatter workloads and record real allocation results; aggregate allocation is not a peak-heap guarantee. +Completion: repair feedback uses bounded prefixes for both complete and +omission forms. Focused tests pass on Go 1.27.3. The documented 20-iteration, +three-run workloads measured 398 B/op for 64 ASCII bytes (68 output bytes), +767,634 B/op for 1 MiB ASCII (65,536 output bytes), and about 12,125,700 B/op +for 1 MiB escaped content (65,531 output bytes). These aggregate allocations +do not establish a peak-heap guarantee. + ## Stage 20: Select Namespace Ranges Before Copying Inventories **Catalog:** 20. **Files:** configuration/engine inventory interfaces and listing tests. diff --git a/internal/usecase/repairer.go b/internal/usecase/repairer.go index a7eec42..7943af2 100644 --- a/internal/usecase/repairer.go +++ b/internal/usecase/repairer.go @@ -117,27 +117,38 @@ func repairGuidance(mode domain.ValidationMode) (string, error) { } func formatRepairDiagnostics(errors []string) string { - diagnostics := make([]string, len(errors)) - for index, diagnostic := range errors { - diagnostics[index] = strings.ToValidUTF8(diagnostic, "\uFFFD") + complete := []byte{'['} + fits := true + for _, diagnostic := range errors { + separator := 0 + if len(complete) > 1 { + separator = 1 + } + entry, whole := boundedDiagnosticJSON(diagnostic, maxRepairDiagnosticBytes-len(complete)-separator-1) + if !whole { + fits = false + break + } + if separator != 0 { + complete = append(complete, ',') + } + complete = append(complete, entry...) } - - complete, _ := json.Marshal(diagnostics) - if len(complete) <= maxRepairDiagnosticBytes { - return string(complete) + if fits { + return string(append(complete, ']')) } omission, _ := json.Marshal(omittedRepairDiagnostics) encoded := make([]byte, 0, maxRepairDiagnosticBytes) encoded = append(encoded, '[') - for _, diagnostic := range diagnostics { - entry, _ := json.Marshal(diagnostic) + for _, diagnostic := range errors { separator := 0 if len(encoded) > 1 { separator = 1 } available := maxRepairDiagnosticBytes - len(encoded) - separator - 1 - len(omission) - 1 - if len(entry) <= available { + entry, whole := boundedDiagnosticJSON(diagnostic, available) + if whole { if separator != 0 { encoded = append(encoded, ',') } @@ -151,7 +162,7 @@ func formatRepairDiagnostics(errors []string) string { if separator != 0 { encoded = append(encoded, ',') } - encoded = append(encoded, truncateDiagnosticJSONValue(diagnostic, available)...) + encoded = append(encoded, entry...) break } if len(encoded) > 1 { diff --git a/internal/usecase/repairer_test.go b/internal/usecase/repairer_test.go index 05ce261..318241d 100644 --- a/internal/usecase/repairer_test.go +++ b/internal/usecase/repairer_test.go @@ -298,6 +298,23 @@ func TestFormatRepairDiagnosticsBoundsAndPreservesData(t *testing.T) { } } +func TestFormatRepairDiagnosticsEmptyAndEscapedOversize(t *testing.T) { + for _, values := range [][]string{nil, {}} { + if got := formatRepairDiagnostics(values); got != "[]" { + t.Fatalf("empty diagnostics = %q", got) + } + } + value := strings.Repeat("<&>\x00\xff\xfe", maxRepairDiagnosticBytes) + encoded := formatRepairDiagnostics([]string{value}) + var got []string + if err := json.Unmarshal([]byte(encoded), &got); err != nil { + t.Fatal(err) + } + if len(encoded) > maxRepairDiagnosticBytes || len(got) != 2 || got[1] != omittedRepairDiagnostics || !strings.HasPrefix(strings.ToValidUTF8(value, "\uFFFD"), got[0]) { + t.Fatalf("oversize diagnostics length=%d, values=%d", len(encoded), len(got)) + } +} + func manyRepairDiagnostics() []string { diagnostics := make([]string, 1_000) for index := range diagnostics {