Skip to content

Commit 03fb1be

Browse files
authored
Merge pull request #156 from flashcatcloud/fix/json-projection-never-shortened
fix(cli): announce shortened projection values instead of clipping silently
2 parents 28073d3 + 4ed699f commit 03fb1be

6 files changed

Lines changed: 199 additions & 51 deletions

File tree

internal/cli/alert_event.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,11 @@ func newAlertEventListCmd() *cobra.Command {
9797
if err != nil {
9898
return err
9999
}
100-
if err := boundProjectedOutput(proj, compactListOutputLimit); err != nil {
100+
note, err := boundProjectedOutput(proj, compactListOutputLimit)
101+
if err != nil {
101102
return err
102103
}
104+
noteProjectionShortening(cmd.ErrOrStderr(), note)
103105
return ctx.PrintList(proj, nil, len(result.Items), page, int(result.Total))
104106
}
105107

internal/cli/fieldproject.go

Lines changed: 103 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,18 @@ func noteDefaultProjection(w io.Writer, fields []string) {
8080
strings.Join(fields, ","))
8181
}
8282

83+
// noteProjectionShortening tells the caller, on stderr, that some values came
84+
// back clipped. Without it a shortened value is only visible to a reader, not
85+
// to the jq filter or exact match a --json consumer runs over it, so a query
86+
// that silently matches nothing looks like an empty result rather than a
87+
// truncated one.
88+
func noteProjectionShortening(w io.Writer, note string) {
89+
if note == "" {
90+
return
91+
}
92+
_, _ = fmt.Fprintln(w, note)
93+
}
94+
8395
// boundProjectedOutput keeps the new agent-oriented projections below their
8496
// command budget without changing the selected keys. List rows (many small
8597
// records) are shortened fairly when they overflow the budget, with
@@ -88,44 +100,49 @@ func noteDefaultProjection(w io.Writer, fields []string) {
88100
// a genuinely short value, so silently shortening it would hand the caller
89101
// wrong data instead of a compact one. If a detail projection doesn't fit,
90102
// the command fails with an error instead.
91-
func boundProjectedOutput(data any, maxBytes int) error {
103+
//
104+
// It returns a caller-printable note (empty when nothing was shortened) that
105+
// names the clipped fields, so the caller can announce the loss on stderr —
106+
// the "..." marker is only visible to something that reads the value, never
107+
// to the filter a --json consumer runs over it.
108+
func boundProjectedOutput(data any, maxBytes int) (string, error) {
92109
switch value := data.(type) {
93110
case map[string]any:
94-
return boundProjectedDetail(value, maxBytes)
111+
return "", boundProjectedDetail(value, maxBytes)
95112
case []map[string]any:
96113
return boundProjectedList(value, maxBytes)
97114
default:
98-
return fmt.Errorf("internal error: unsupported projected output %T", data)
115+
return "", fmt.Errorf("internal error: unsupported projected output %T", data)
99116
}
100117
}
101118

102-
// boundProjectedDetail rejects an oversized single-object projection instead
103-
// of truncating it, naming the largest fields so the caller can fix the
104-
// request in one pass: drop some of them from --fields, or drop --fields
105-
// entirely for the full, unbounded detail.
106-
func boundProjectedDetail(row map[string]any, maxBytes int) error {
107-
encoded, err := marshalStructured(row)
108-
if err != nil {
109-
return err
110-
}
111-
if len(encoded)+1 < maxBytes {
112-
return nil
119+
// largestProjectedFields names the up to three fields carrying the most bytes
120+
// in a projection, so an over-budget request can be narrowed in one pass
121+
// instead of one re-run per field. Sizes are summed per field across every
122+
// row, which is what makes it meaningful for a list: the field responsible
123+
// for the overflow is the one that is big in aggregate, not in any one row.
124+
// Ties break on name so the same oversized request always names the same
125+
// fields, despite Go's randomized map iteration order.
126+
func largestProjectedFields(rows []map[string]any) (string, error) {
127+
totals := map[string]int{}
128+
for _, row := range rows {
129+
for key, value := range row {
130+
encoded, err := marshalStructured(map[string]any{key: value})
131+
if err != nil {
132+
return "", err
133+
}
134+
totals[key] += len(encoded)
135+
}
113136
}
114137

115138
type fieldSize struct {
116139
name string
117140
size int
118141
}
119-
sizes := make([]fieldSize, 0, len(row))
120-
for key, value := range row {
121-
fieldEncoded, err := marshalStructured(map[string]any{key: value})
122-
if err != nil {
123-
return err
124-
}
125-
sizes = append(sizes, fieldSize{key, len(fieldEncoded)})
142+
sizes := make([]fieldSize, 0, len(totals))
143+
for name, size := range totals {
144+
sizes = append(sizes, fieldSize{name, size})
126145
}
127-
// Ties break on name so the same oversized request always names the same
128-
// fields, despite Go's randomized map iteration order.
129146
sort.Slice(sizes, func(i, j int) bool {
130147
if sizes[i].size != sizes[j].size {
131148
return sizes[i].size > sizes[j].size
@@ -139,8 +156,28 @@ func boundProjectedDetail(row map[string]any, maxBytes int) error {
139156
for i, f := range sizes {
140157
largest[i] = fmt.Sprintf("%s (%d bytes)", f.name, f.size)
141158
}
159+
return strings.Join(largest, ", "), nil
160+
}
161+
162+
// boundProjectedDetail rejects an oversized single-object projection instead
163+
// of truncating it, naming the largest fields so the caller can fix the
164+
// request in one pass: drop some of them from --fields, or drop --fields
165+
// entirely for the full, unbounded detail.
166+
func boundProjectedDetail(row map[string]any, maxBytes int) error {
167+
encoded, err := marshalStructured(row)
168+
if err != nil {
169+
return err
170+
}
171+
if len(encoded)+1 < maxBytes {
172+
return nil
173+
}
174+
175+
largest, err := largestProjectedFields([]map[string]any{row})
176+
if err != nil {
177+
return err
178+
}
142179
return fmt.Errorf("projected detail is %d bytes, exceeds the %d-byte limit; largest fields: %s; request fewer --fields, or omit --fields for the full, unbounded detail",
143-
len(encoded), maxBytes, strings.Join(largest, ", "))
180+
len(encoded), maxBytes, largest)
144181
}
145182

146183
// boundProjectedList shortens a list projection's string values fairly when
@@ -153,14 +190,25 @@ func boundProjectedDetail(row map[string]any, maxBytes int) error {
153190
// marker itself disappear, so a shortened value is always distinguishable
154191
// from a genuinely short one; if no cap at or above that floor fits, the
155192
// command fails with a small error instead of emitting values that look
156-
// real but aren't.
157-
func boundProjectedList(rows []map[string]any, maxBytes int) error {
193+
// real but aren't. Whatever it clips, it reports back in the returned note.
194+
func boundProjectedList(rows []map[string]any, maxBytes int) (string, error) {
158195
encoded, err := marshalStructured(rows)
159196
if err != nil {
160-
return err
197+
return "", err
161198
}
162199
if len(encoded)+1 < maxBytes {
163-
return nil
200+
return "", nil
201+
}
202+
203+
// The overflow error names the fields responsible, exactly as the detail
204+
// path does, so the request can be narrowed in one pass.
205+
tooBig := func() (string, error) {
206+
largest, err := largestProjectedFields(rows)
207+
if err != nil {
208+
return "", err
209+
}
210+
return "", fmt.Errorf("projected list is %d bytes across %d rows, exceeds the %d-byte limit; largest fields: %s; request fewer rows (--limit) or fewer --fields",
211+
len(encoded), len(rows), maxBytes, largest)
164212
}
165213

166214
maxLen := 0
@@ -172,7 +220,7 @@ func boundProjectedList(rows []map[string]any, maxBytes int) error {
172220
}
173221
}
174222
if maxLen == 0 {
175-
return fmt.Errorf("structured projection exceeds %d-byte limit; request fewer rows or fields", maxBytes)
223+
return tooBig()
176224
}
177225

178226
fits := func(limit int) (bool, error) {
@@ -202,12 +250,12 @@ func boundProjectedList(rows []map[string]any, maxBytes int) error {
202250
// reintroduce.
203251
const minMarkedTruncationCap = 4
204252
if maxLen <= minMarkedTruncationCap {
205-
return fmt.Errorf("structured projection exceeds %d-byte limit; request fewer rows or fields", maxBytes)
253+
return tooBig()
206254
}
207255
if ok, err := fits(minMarkedTruncationCap); err != nil {
208-
return err
256+
return "", err
209257
} else if !ok {
210-
return fmt.Errorf("structured projection exceeds %d-byte limit; request fewer rows or fields", maxBytes)
258+
return tooBig()
211259
}
212260

213261
// Binary search for the largest cap that still fits: fits(limit) is true
@@ -219,7 +267,7 @@ func boundProjectedList(rows []map[string]any, maxBytes int) error {
219267
mid := lo + (hi-lo+1)/2
220268
ok, err := fits(mid)
221269
if err != nil {
222-
return err
270+
return "", err
223271
}
224272
if ok {
225273
lo = mid
@@ -228,14 +276,33 @@ func boundProjectedList(rows []map[string]any, maxBytes int) error {
228276
}
229277
}
230278

279+
shortened, total := 0, 0
280+
fields := map[string]bool{}
231281
for _, row := range rows {
232282
for key, value := range row {
233-
if text, ok := value.(string); ok {
234-
row[key] = truncateUTF8Bytes(text, lo)
283+
text, ok := value.(string)
284+
if !ok {
285+
continue
286+
}
287+
total++
288+
clipped := truncateUTF8Bytes(text, lo)
289+
if clipped != text {
290+
shortened++
291+
fields[key] = true
235292
}
293+
row[key] = clipped
236294
}
237295
}
238-
return nil
296+
if shortened == 0 {
297+
return "", nil
298+
}
299+
names := make([]string, 0, len(fields))
300+
for name := range fields {
301+
names = append(names, name)
302+
}
303+
sort.Strings(names)
304+
return fmt.Sprintf("note: %d of %d string values were shortened to fit the %d-byte limit and now end with \"...\" (fields: %s); matching or filtering on those fields will miss — narrow --fields or --limit for untruncated values",
305+
shortened, total, maxBytes, strings.Join(names, ", ")), nil
239306
}
240307

241308
func truncateUTF8Bytes(value string, maxBytes int) string {

internal/cli/fieldproject_test.go

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ func TestBoundProjectedOutputCapsStructuredFormats(t *testing.T) {
4242
"title": strings.Repeat("数据库故障", 2000),
4343
}}
4444

45-
if err := boundProjectedOutput(rows, 512); err != nil {
45+
if _, err := boundProjectedOutput(rows, 512); err != nil {
4646
t.Fatalf("bound projected output: %v", err)
4747
}
4848
encoded, err := marshalStructured(rows)
@@ -68,8 +68,8 @@ func TestBoundProjectedOutputRejectsIrreducibleMetadata(t *testing.T) {
6868
rows[i] = map[string]any{"count": i}
6969
}
7070

71-
err := boundProjectedOutput(rows, 512)
72-
if err == nil || !strings.Contains(err.Error(), "request fewer rows or fields") {
71+
_, err := boundProjectedOutput(rows, 512)
72+
if err == nil || !strings.Contains(err.Error(), "request fewer rows") {
7373
t.Fatalf("irreducible output error = %v, want bounded guidance", err)
7474
}
7575
}
@@ -92,7 +92,7 @@ func TestBoundProjectedOutputDetailWithinBudgetLeavesValuesUnchanged(t *testing.
9292
"progress": "Triggered",
9393
}
9494

95-
if err := boundProjectedOutput(row, compactDetailOutputLimit); err != nil {
95+
if _, err := boundProjectedOutput(row, compactDetailOutputLimit); err != nil {
9696
t.Fatalf("bound projected output: %v", err)
9797
}
9898
if !reflect.DeepEqual(row, want) {
@@ -119,7 +119,7 @@ func TestBoundProjectedOutputDetailOversizedErrorsWithoutMutating(t *testing.T)
119119
"root_cause": strings.Repeat("disk exhaustion details ", 3000),
120120
}
121121

122-
err := boundProjectedOutput(row, 512)
122+
_, err := boundProjectedOutput(row, 512)
123123
if err == nil {
124124
t.Fatal("expected an error for an oversized detail projection, got nil")
125125
}
@@ -151,7 +151,7 @@ func TestBoundProjectedOutputDetailErrorIsDeterministic(t *testing.T) {
151151
"delta": strings.Repeat("d", 400),
152152
"echo": strings.Repeat("e", 400),
153153
}
154-
err := boundProjectedOutput(row, 512)
154+
_, err := boundProjectedOutput(row, 512)
155155
if err == nil {
156156
t.Fatal("expected an error for an oversized detail projection, got nil")
157157
}
@@ -196,7 +196,7 @@ func TestIncidentListStructuredDefaultUsesCompactProjection(t *testing.T) {
196196
row["title"] = strings.Repeat("数据库故障", 5000)
197197
stub.data = map[string]any{"items": []any{row}, "total": 1}
198198

199-
out, _, err := execCommandSplit("incident", "list", "--output-format", format)
199+
out, stderrText, err := execCommandSplit("incident", "list", "--output-format", format)
200200
if err != nil {
201201
t.Fatalf("execCommandSplit: %v", err)
202202
}
@@ -206,6 +206,11 @@ func TestIncidentListStructuredDefaultUsesCompactProjection(t *testing.T) {
206206
if !utf8.ValidString(out) || !strings.Contains(out, "...") {
207207
t.Fatalf("bounded %s incident list must retain valid UTF-8 and show truncation", format)
208208
}
209+
// The clipped value must be announced, not just marked: a --json
210+
// consumer filters on the value and never sees the "..." itself.
211+
if !strings.Contains(stderrText, "were shortened to fit") || !strings.Contains(stderrText, "title") {
212+
t.Errorf("shortened %s incident list should announce the clipped field on stderr, got:\n%s", format, stderrText)
213+
}
209214
})
210215
}
211216

@@ -800,7 +805,7 @@ func TestBoundProjectedListNeverEmitsUnmarkedTruncation(t *testing.T) {
800805
originals[i] = clone
801806
}
802807

803-
if err := boundProjectedOutput(rows, compactListOutputLimit); err != nil {
808+
if _, err := boundProjectedOutput(rows, compactListOutputLimit); err != nil {
804809
t.Fatalf("bound: %v", err)
805810
}
806811

@@ -847,3 +852,70 @@ func TestStructuredFieldsEmptyErrors(t *testing.T) {
847852
})
848853
}
849854
}
855+
856+
// TestBoundProjectedListAnnouncesShortening pins that a list projection which
857+
// had to clip values says so on the caller's side. The "..." marker alone is
858+
// only visible to something that READS the value; a --json consumer runs a jq
859+
// filter or an exact match over it, where a clipped string produces an empty
860+
// result that is indistinguishable from "nothing matched" — the expensive
861+
// failure this note exists to prevent.
862+
func TestBoundProjectedListAnnouncesShortening(t *testing.T) {
863+
for _, format := range []string{"json", "toon"} {
864+
t.Run(format, func(t *testing.T) {
865+
saveAndResetGlobals(t)
866+
flagOutputFormat = format
867+
rows := []map[string]any{{
868+
"incident_id": "inc-1",
869+
"title": strings.Repeat("payment-gateway timeout ", 200),
870+
}}
871+
872+
note, err := boundProjectedOutput(rows, 512)
873+
if err != nil {
874+
t.Fatalf("bound projected output: %v", err)
875+
}
876+
if note == "" {
877+
t.Fatalf("shortened projection returned no note; caller cannot tell values were clipped")
878+
}
879+
if !strings.Contains(note, "title") {
880+
t.Fatalf("note = %q, want it to name the shortened field (title)", note)
881+
}
882+
})
883+
}
884+
}
885+
886+
// TestBoundProjectedListNoNoteWhenNothingShortened keeps the note honest: a
887+
// projection that fits must not claim anything was clipped.
888+
func TestBoundProjectedListNoNoteWhenNothingShortened(t *testing.T) {
889+
saveAndResetGlobals(t)
890+
flagOutputFormat = "json"
891+
rows := []map[string]any{{"incident_id": "inc-1", "title": "disk full"}}
892+
893+
note, err := boundProjectedOutput(rows, 512)
894+
if err != nil {
895+
t.Fatalf("bound projected output: %v", err)
896+
}
897+
if note != "" {
898+
t.Fatalf("fitting projection returned note %q, want none", note)
899+
}
900+
}
901+
902+
// TestBoundProjectedListErrorNamesLargestFields pins that a list projection
903+
// which cannot fit at all says WHICH fields are responsible, exactly as the
904+
// detail path already does. Without it the only way to find the oversized
905+
// field is to re-run the query once per field.
906+
func TestBoundProjectedListErrorNamesLargestFields(t *testing.T) {
907+
saveAndResetGlobals(t)
908+
flagOutputFormat = "json"
909+
rows := make([]map[string]any, 200)
910+
for i := range rows {
911+
rows[i] = map[string]any{"count": i, "score": i * 2}
912+
}
913+
914+
_, err := boundProjectedOutput(rows, 512)
915+
if err == nil {
916+
t.Fatalf("irreducible projection = nil error, want refusal")
917+
}
918+
if !strings.Contains(err.Error(), "largest fields:") {
919+
t.Fatalf("list overflow error = %q, want it to name the largest fields", err)
920+
}
921+
}

0 commit comments

Comments
 (0)