From 3436149b57510bc8a645a9d4f919317731d8ca91 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Fri, 4 Sep 2026 04:05:31 -0700 Subject: [PATCH] Bound oversized structured list output from generated list verbs and insight incidents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated list verbs printed their full response as unbounded dense JSON/TOON; a large page could dump tens of kilobytes in one shot. Route their structured output through the existing bounding machinery (boundProjectedList, 16 KiB limit, stderr note) already used by the curated incident/alert/alert-event projections: - printGenericResult measures the encoded payload; anything under the limit takes the pre-existing printer path, byte-identical. Over-cap list payloads — a top-level object array, or an items/docs/list envelope whose siblings are scalar pagination metadata (the shape cligen's listEnvelope recognizes) — are reduced to the leading rows that fit, with envelope keys kept and the reduction announced on stderr. A single oversized row is shortened with "..." markers instead; identifier fields are never shortened. - The typed response reaches the bounding machinery through a JSON round trip that decodes numbers as json.Number before narrowing to int64/float64, so integer IDs above 2^53 (channel_id, team_id, ...) keep full precision in bounded output. Rows inside an envelope are re-fit against the limit minus the observed envelope overhead, so the whole printed payload lands under it. - Detail-shaped single objects are never reduced or rejected. Curated insight incidents gains the incident-list treatment in json/toon mode: a compact default projection (incident_id,title,severity,channel_name,seconds_to_ack,seconds_to_close,notifications) with a stderr note, a --fields flag to choose a different projection, and the same bounding. Table mode is unchanged. Tests: stub-driven end-to-end coverage for the bounded envelope and top-level-array paths, >2^53 ID precision, oversized-detail passthrough, and the single-row shorten path; insight incidents projection, --fields override, bounding, and unchanged table output. --- internal/cli/gen_support.go | 171 +++++++++++++++++++- internal/cli/gen_support_test.go | 216 +++++++++++++++++++++++++- internal/cli/insight.go | 33 +++- internal/cli/insight_export_test.go | 144 +++++++++++++++++ skills/flashduty/reference/insight.md | 1 + 5 files changed, 558 insertions(+), 7 deletions(-) diff --git a/internal/cli/gen_support.go b/internal/cli/gen_support.go index 4aebadc..37fb4f5 100644 --- a/internal/cli/gen_support.go +++ b/internal/cli/gen_support.go @@ -1,6 +1,7 @@ package cli import ( + "bytes" "encoding/json" "fmt" "io" @@ -11,6 +12,7 @@ import ( "github.com/spf13/cobra" + "github.com/flashcatcloud/flashduty-cli/internal/output" "github.com/flashcatcloud/flashduty-cli/internal/timeutil" ) @@ -320,16 +322,177 @@ func bindURLTagged(body map[string]any, rv reflect.Value) { // printGenericResult renders a generated command's typed response. In // machine-readable mode (TOON/JSON) it marshals the whole value — which is what -// the agent reads. In human (table) mode it derives an aligned table by -// reflection (renderGenericTable), since generated commands carry no hand-written -// column set; anything that isn't a list or object falls back to indented JSON. +// the agent reads. A list-shaped response (a top-level array of objects, or an +// items/docs/list page envelope whose other fields are scalar pagination +// metadata) that overflows compactListOutputLimit is first bounded to the +// leading rows that fit via boundProjectedList, with the reduction announced on +// stderr; a payload that fits, and any detail-shaped single object, prints +// untouched. In human (table) mode it derives an aligned table by reflection +// (renderGenericTable), since generated commands carry no hand-written column +// set; anything that isn't a list or object falls back to indented JSON. func printGenericResult(ctx *RunContext, data any) error { if ctx.Structured() { - return ctx.Printer.Print(data, nil) + return printBoundedGenericResult(ctx, data) } return renderGenericTable(ctx, data) } +// printBoundedGenericResult is printGenericResult's structured-mode half. The +// under-cap fast path is the pre-bound behavior verbatim — same printer call, +// byte-identical output. Only an over-cap list payload detours through the +// bounding machinery, and only there does the output change (fewer rows; a +// rebuilt envelope, so key order is no longer the struct's field order). +func printBoundedGenericResult(ctx *RunContext, data any) error { + encoded, err := marshalStructured(data) + if err != nil || len(encoded)+1 < compactListOutputLimit { + // Fits the budget (or cannot be measured, in which case the printer + // surfaces the same marshal error): emit untouched. + return ctx.Printer.Print(data, nil) + } + + generic, err := genericStructured(data) + if err != nil { + return ctx.Printer.Print(data, nil) + } + + switch value := generic.(type) { + case []any: + rows, ok := objectRows(value) + if !ok { + return ctx.Printer.Print(data, nil) + } + bounded, note, err := boundProjectedList(rows, compactListOutputLimit) + if err != nil { + return err + } + noteProjectionBound(ctx.Cmd.ErrOrStderr(), note) + return ctx.Printer.Print(bounded, nil) + case map[string]any: + key, ok := listEnvelopeKey(value) + if !ok { + // Detail-shaped single object: never bounded, never errored — a + // shortened id or status would pass for a real value. + return ctx.Printer.Print(data, nil) + } + rows, ok := objectRows(value[key].([]any)) + if !ok { + return ctx.Printer.Print(data, nil) + } + // boundProjectedList sizes the rows standalone, but printed inside the + // envelope they share the budget with the pagination siblings (and, in + // indented JSON, sit one indent level deeper). Fit against the full + // limit, then re-fit with the observed envelope overhead subtracted + // until the whole payload is under it. + budget := compactListOutputLimit + for { + bounded, note, err := boundProjectedList(rows, budget) + if err != nil { + return err + } + value[key] = bounded + out, err := marshalStructured(value) + if err != nil { + return err + } + if len(out)+1 < compactListOutputLimit { + noteProjectionBound(ctx.Cmd.ErrOrStderr(), note) + return ctx.Printer.Print(value, nil) + } + budget -= len(out) + 2 - compactListOutputLimit + } + default: + return ctx.Printer.Print(data, nil) + } +} + +// genericStructured decodes data through its JSON encoding into plain +// maps/slices/scalars, so the list-bounding machinery can walk rows of any SDK +// response type. Numbers decode as json.Number first (UseNumber) and are then +// narrowed by narrowNumbers: decoding straight to float64 would round integer +// IDs above 2^53 (channel_id, team_id, …) in the bounded output. Unset SDK +// timestamps go in as null (NullUnsetInstants), matching what the printer +// would have emitted for the unbounded payload. +func genericStructured(data any) (any, error) { + raw, err := json.Marshal(output.NullUnsetInstants(data)) + if err != nil { + return nil, err + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var generic any + if err := dec.Decode(&generic); err != nil { + return nil, err + } + return narrowNumbers(generic), nil +} + +// narrowNumbers rewrites every json.Number in a decoded generic value to its +// int64 form when the literal is an integer (exact for IDs beyond 2^53), else +// float64 — both encoders (JSON and TOON) render those natively. +func narrowNumbers(value any) any { + switch v := value.(type) { + case json.Number: + if i, err := v.Int64(); err == nil { + return i + } + if f, err := v.Float64(); err == nil { + return f + } + return v.String() + case map[string]any: + for key, item := range v { + v[key] = narrowNumbers(item) + } + return v + case []any: + for i, item := range v { + v[i] = narrowNumbers(item) + } + return v + default: + return value + } +} + +// listEnvelopeKey reports whether value is a paginated list envelope — exactly +// one array field named items/docs/list with only scalar siblings (total, +// has_next_page, search_after_ctx, …) — and returns the row array's key. It +// mirrors cligen's listEnvelope (internal/cmd/cligen), which classifies the +// same shape when generating these commands. +func listEnvelopeKey(value map[string]any) (string, bool) { + key := "" + for name, field := range value { + _, isArray := field.([]any) + if isArray && (name == "items" || name == "docs" || name == "list") { + if key != "" { + return "", false // two candidate row arrays: not a flat list envelope + } + key = name + continue + } + switch field.(type) { + case map[string]any, []any: + return "", false // non-scalar sibling: a richer response, not a flat list + } + } + return key, key != "" +} + +// objectRows converts a decoded JSON array to rows for boundProjectedList. ok +// is false when any element is not an object: an array of scalars has no row +// fields to bound and prints unbounded instead. +func objectRows(items []any) ([]map[string]any, bool) { + rows := make([]map[string]any, len(items)) + for i, item := range items { + row, ok := item.(map[string]any) + if !ok { + return nil, false + } + rows[i] = row + } + return rows, true +} + // genParseTimeFlag parses a relative-or-absolute time flag into unix seconds, // mirroring the curated incident-list --since/--until handling: a Go duration // ("7d", "24h") is "now minus duration", "+7d" is the future, "now" is now, and diff --git a/internal/cli/gen_support_test.go b/internal/cli/gen_support_test.go index 965eb1c..6ba00bf 100644 --- a/internal/cli/gen_support_test.go +++ b/internal/cli/gen_support_test.go @@ -1,6 +1,15 @@ package cli -import "testing" +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "testing" + "unicode/utf8" + + "github.com/flashcatcloud/flashduty-cli/internal/output" +) func TestGenBindBodyAllowsNullForRequiredNullableField(t *testing.T) { req := new(struct { @@ -14,3 +23,208 @@ func TestGenBindBodyAllowsNullForRequiredNullableField(t *testing.T) { t.Fatalf("Value = %v, want nil", req.Value) } } + +// oversizedInsightRows returns n insight-incident rows whose fat description +// fields push any page of them well past compactListOutputLimit, plus the +// incident IDs in row order so a test can tell emitted rows from dropped ones. +func oversizedInsightRows(n int) ([]any, []string) { + rows := make([]any, n) + ids := make([]string, n) + for i := range rows { + ids[i] = fmt.Sprintf("inc-%024d", i) + rows[i] = map[string]any{ + "incident_id": ids[i], + "title": fmt.Sprintf("Database failover on db-%d", i), + "severity": "Critical", + "channel_id": 12345, + "channel_name": "db-alerts", + "description": strings.Repeat(fmt.Sprintf("row %d root-cause detail ", i), 40), + "seconds_to_ack": 42, + "seconds_to_close": 3600, + "notifications": 3, + } + } + return rows, ids +} + +// TestPrintGenericResultBoundsListEnvelope drives a generated list verb whose +// response is an items[] page envelope (insight incident-list): an oversized +// page must come back under the structured-output limit with the reduction +// announced on stderr and the envelope keys intact, in both structured +// formats. +func TestPrintGenericResultBoundsListEnvelope(t *testing.T) { + for _, format := range []string{"json", "toon"} { + t.Run(format, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + rows, ids := oversizedInsightRows(40) + stub.data = map[string]any{ + "items": rows, + "total": 40, + "has_next_page": true, + "search_after_ctx": "cursor-1", + } + + out, stderrText, err := execCommandSplit("insight", "incident-list", + "--start-time", "7d", "--end-time", "now", "--output-format", format) + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + if len([]byte(out)) >= compactListOutputLimit { + t.Errorf("bounded %s envelope is %d bytes, want <%d", format, len([]byte(out)), compactListOutputLimit) + } + if !strings.Contains(stderrText, "note: emitted") { + t.Errorf("reduced %s page should announce itself on stderr, got:\n%s", format, stderrText) + } + // The first row survives intact; the last was dropped by the + // prefix reduction. + if !strings.Contains(out, ids[0]) { + t.Errorf("bounded %s output lost leading row %q:\n%s", format, ids[0], out) + } + if strings.Contains(out, ids[len(ids)-1]) { + t.Errorf("bounded %s output still contains trailing row %q", format, ids[len(ids)-1]) + } + // The pagination envelope rides along with the bounded rows. + for _, key := range []string{"total", "has_next_page", "search_after_ctx"} { + if !strings.Contains(out, key) { + t.Errorf("bounded %s output lost envelope key %q:\n%s", format, key, out) + } + } + if format == "json" { + var envelope map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &envelope); err != nil { + t.Fatalf("bounded json is not an object: %v", err) + } + if _, ok := envelope["items"].([]any); !ok { + t.Fatalf("bounded json lost the items array: %v", envelope) + } + } + }) + } +} + +// TestPrintGenericResultBoundsTopLevelArray drives a generated verb whose +// response is a bare top-level array (monit rule-list-basic): an oversized +// page must be bounded the same way as an items[] envelope. +func TestPrintGenericResultBoundsTopLevelArray(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + rows := make([]any, 40) + for i := range rows { + rows[i] = map[string]any{ + "id": i + 1, + "name": strings.Repeat(fmt.Sprintf("rule %d ", i), 40), + "folder_id": 100, + "ds_type": "prometheus", + "cron_pattern": "0 * * * * *", + "enabled": true, + } + } + stub.data = rows + + out, stderrText, err := execCommandSplit("monit", "rule-list-basic", "--output-format", "json") + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + if len([]byte(out)) >= compactListOutputLimit { + t.Errorf("bounded top-level array is %d bytes, want <%d", len([]byte(out)), compactListOutputLimit) + } + if !strings.Contains(stderrText, "note: emitted") { + t.Errorf("reduced page should announce itself on stderr, got:\n%s", stderrText) + } + var decoded []map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &decoded); err != nil { + t.Fatalf("bounded top-level array is not a JSON array: %v\n%s", err, out) + } + if len(decoded) == 0 || len(decoded) >= len(rows) { + t.Errorf("bounded array has %d rows, want a reduced page in [1, %d)", len(decoded), len(rows)) + } +} + +// TestPrintGenericResultKeepsLargeIDsExact is the precision guard for the +// typed-slice round trip: an integer ID above 2^53 (channel_id) must reach +// the bounded output with every digit intact, where a float64 decode would +// have rounded it. +func TestPrintGenericResultKeepsLargeIDsExact(t *testing.T) { + const bigChannelID = "9007199254740993" // 2^53 + 1 + for _, format := range []string{"json", "toon"} { + t.Run(format, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + rows, _ := oversizedInsightRows(40) + for _, row := range rows { + row.(map[string]any)["channel_id"] = json.Number(bigChannelID) + } + stub.data = map[string]any{"items": rows, "total": 40} + + out, _, err := execCommandSplit("insight", "incident-list", + "--start-time", "7d", "--end-time", "now", "--output-format", format) + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + if !strings.Contains(out, bigChannelID) { + t.Errorf("bounded %s output lost digits of channel_id %s", format, bigChannelID) + } + if strings.Contains(out, "9007199254740992") { + t.Errorf("bounded %s output rounded channel_id to the nearest float64", format) + } + }) + } +} + +// TestPrintGenericResultDetailNeverBounded: a detail-shaped single object is +// excluded from list bounding no matter its size — never reduced, never +// errored — and prints byte-identical to the direct printer. +func TestPrintGenericResultDetailNeverBounded(t *testing.T) { + detail := &heuristicRow{Name: strings.Repeat("x", 40*1024), Count: 7} + + for _, f := range []output.Format{output.FormatJSON, output.FormatTOON} { + var got, want bytes.Buffer + if err := printGenericResult(structuredCtx(&got, f), detail); err != nil { + t.Fatalf("%v oversized detail errored: %v", f, err) + } + if err := output.NewPrinter(f, false, &want).Print(detail, nil); err != nil { + t.Fatalf("%v reference: %v", f, err) + } + if got.String() != want.String() { + t.Errorf("oversized detail output changed for %v\n got:\n%s\nwant:\n%s", f, got.String(), want.String()) + } + if len(got.Bytes()) < compactListOutputLimit { + t.Errorf("detail payload should pass through unbounded, got %d bytes", len(got.Bytes())) + } + } +} + +// TestPrintGenericResultShortenedRowStaysUTF8 covers the single-row-overflow +// path through a generated verb: one row too big on its own is shortened with +// a "..." marker, valid UTF-8, and a stderr note — never an unmarked clip. +func TestPrintGenericResultShortenedRowStaysUTF8(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{ + "items": []any{map[string]any{ + "incident_id": "inc-1", + "title": strings.Repeat("数据库故障", 5000), + "severity": "Critical", + }}, + "total": 1, + } + + out, stderrText, err := execCommandSplit("insight", "incident-list", + "--start-time", "7d", "--end-time", "now", "--output-format", "json") + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + if len([]byte(out)) >= compactListOutputLimit { + t.Fatalf("shortened single row is %d bytes, want <%d", len([]byte(out)), compactListOutputLimit) + } + if !utf8.ValidString(out) || !strings.Contains(out, "...") { + t.Fatalf("shortened row must retain valid UTF-8 and show the truncation marker") + } + if !strings.Contains(out, "inc-1") { + t.Errorf("identifier field must survive shortening intact, got:\n%s", out) + } + if !strings.Contains(stderrText, "were shortened to fit") { + t.Errorf("shortened row should announce the clipped fields on stderr, got:\n%s", stderrText) + } +} diff --git a/internal/cli/insight.go b/internal/cli/insight.go index 485df4c..d404df8 100644 --- a/internal/cli/insight.go +++ b/internal/cli/insight.go @@ -84,13 +84,14 @@ func newInsightTopAlertsCmd() *cobra.Command { } func newInsightIncidentsCmd() *cobra.Command { - var since, until string + var since, until, fields string var limit, page int + defaultStructuredFields := []string{"incident_id", "title", "severity", "channel_name", "seconds_to_ack", "seconds_to_close", "notifications"} cmd := &cobra.Command{ Use: "incidents", Short: "Query incidents with performance metrics", - Long: curatedLong("List incidents with per-incident performance metrics (MTTA, MTTR, notifications) over a time window.", "Analytics", "IncidentList"), + Long: curatedLong("List incidents with per-incident performance metrics (MTTA, MTTR, notifications) over a time window. In json/toon mode, rows default to the compact fields incident_id,title,severity,channel_name,seconds_to_ack,seconds_to_close,notifications; pass --fields to choose a different projection.", "Analytics", "IncidentList"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) @@ -114,6 +115,33 @@ func newInsightIncidentsCmd() *cobra.Command { return err } + if ctx.Structured() { + selectedFields := defaultStructuredFields + if cmd.Flags().Changed("fields") { + selectedFields = parseStringSlice(fields) + if len(selectedFields) == 0 { + return fmt.Errorf("--fields must name at least one field") + } + } else { + noteDefaultProjection(cmd.ErrOrStderr(), selectedFields) + } + proj, err := projectFields(result.Items, selectedFields) + if err != nil { + return err + } + bounded, note, err := boundProjectedOutput(proj, compactListOutputLimit) + if err != nil { + return err + } + proj = bounded.([]map[string]any) + noteProjectionBound(cmd.ErrOrStderr(), note) + effectiveLimit := limit + if len(proj) < len(result.Items) { + effectiveLimit = len(proj) + } + return ctx.PrintList(proj, nil, len(proj), page, effectiveLimit, int(result.Total)) + } + cols := []output.Column{ {Header: "ID", Field: func(v any) string { return v.(flashduty.IncidentRawItem).IncidentID @@ -147,6 +175,7 @@ func newInsightIncidentsCmd() *cobra.Command { cmd.Flags().StringVar(&until, "until", "now", "End time") cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 100)") cmd.Flags().IntVar(&page, "page", 1, "Page number") + cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. incident_id,title,severity); ignored in table mode. Use to avoid dumping the full nested record.") return cmd } diff --git a/internal/cli/insight_export_test.go b/internal/cli/insight_export_test.go index b565470..839479a 100644 --- a/internal/cli/insight_export_test.go +++ b/internal/cli/insight_export_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "strings" "testing" + "unicode/utf8" "github.com/flashcatcloud/go-flashduty" ) @@ -55,6 +56,149 @@ type insightExportStub struct { listBody map[string]any } +// insightIncidentRow builds one /insight/incident/list row for the stub, +// carrying both the compact-projection fields and the full-record fields a +// default projection must drop. +func insightIncidentRow() map[string]any { + return map[string]any{ + "incident_id": "inc-1", + "title": "Disk full on db-01", + "severity": "Critical", + "progress": "Triggered", + "channel_id": 12345, + "channel_name": "db-alerts", + "seconds_to_ack": 42, + "seconds_to_close": 3600, + "notifications": 3, + "description": "root volume at 98%", + "labels": map[string]any{"service": "db", "env": "prod"}, + "responders": []map[string]any{{"person_id": 101, "person_name": "Alice"}}, + } +} + +// TestInsightIncidentsStructuredDefaultUsesCompactProjection mirrors incident +// list: structured mode must not dump the full nested SDK row when --fields +// is omitted, and the default projection announces itself on stderr. +func TestInsightIncidentsStructuredDefaultUsesCompactProjection(t *testing.T) { + for _, format := range []string{"json", "toon"} { + t.Run(format, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{insightIncidentRow()}, "total": 1} + + out, stderrText, err := execCommandSplit("insight", "incidents", "--output-format", format) + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + for _, key := range []string{"incident_id", "title", "severity", "channel_name", "seconds_to_ack", "seconds_to_close", "notifications"} { + if !strings.Contains(out, key) { + t.Errorf("default %s output missing compact key %q, got:\n%s", format, key, out) + } + } + // Full-record keys must not leak. (stdout only: the stderr note + // embeds the compact field names, never these.) + for _, key := range []string{"description", "labels", "responders"} { + if strings.Contains(out, key) { + t.Errorf("default %s output should not contain full-record key %q, got:\n%s", format, key, out) + } + } + if !strings.Contains(stderrText, "note: rows projected to default compact fields") { + t.Errorf("default projection should announce itself on stderr, got:\n%s", stderrText) + } + }) + } +} + +// TestInsightIncidentsStructuredFieldsFlag: an explicit --fields wins over the +// default projection and stays exactly the named fields. +func TestInsightIncidentsStructuredFieldsFlag(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{insightIncidentRow()}, "total": 1} + + out, stderrText, err := execCommandSplit("insight", "incidents", + "--fields", "incident_id,severity", "--output-format", "json") + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + assertProjectedJSONFields(t, out, []string{"incident_id", "severity"}) + if strings.Contains(stderrText, "note: rows projected to default compact fields") { + t.Errorf("explicit --fields must not print the default-projection note, got:\n%s", stderrText) + } +} + +// TestInsightIncidentsStructuredBounded: an oversized projected page is +// bounded below the structured-output limit — reduced to the leading intact +// rows, or a single oversized row shortened with a marked, announced clip. +func TestInsightIncidentsStructuredBounded(t *testing.T) { + t.Run("reduced page keeps every value intact", func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + rows := make([]any, 10) + for i := range rows { + row := insightIncidentRow() + row["incident_id"] = fmt.Sprintf("inc-%d", i) + row["title"] = strings.Repeat(fmt.Sprintf("db-%d failover ", i), 200) + rows[i] = row + } + stub.data = map[string]any{"items": rows, "total": 10} + + out, stderrText, err := execCommandSplit("insight", "incidents", "--output-format", "json") + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + if len([]byte(out)) >= compactListOutputLimit { + t.Fatalf("bounded page is %d bytes, want <%d", len([]byte(out)), compactListOutputLimit) + } + if !strings.Contains(stderrText, "note: emitted") { + t.Errorf("reduced page should announce itself on stderr, got:\n%s", stderrText) + } + if strings.Contains(out, "...") { + t.Errorf("page reduction must never shorten a value, got:\n%s", out) + } + }) + + t.Run("single oversized row is shortened and announced", func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + row := insightIncidentRow() + row["title"] = strings.Repeat("数据库故障", 5000) + stub.data = map[string]any{"items": []any{row}, "total": 1} + + out, stderrText, err := execCommandSplit("insight", "incidents", "--output-format", "json") + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + if len([]byte(out)) >= compactListOutputLimit { + t.Fatalf("shortened row is %d bytes, want <%d", len([]byte(out)), compactListOutputLimit) + } + if !utf8.ValidString(out) || !strings.Contains(out, "...") { + t.Fatalf("shortened row must retain valid UTF-8 and show the truncation marker") + } + if !strings.Contains(stderrText, "were shortened to fit") || !strings.Contains(stderrText, "title") { + t.Errorf("shortened row should announce the clipped field on stderr, got:\n%s", stderrText) + } + }) +} + +// TestInsightIncidentsTableUnchanged: the human table keeps its full column +// set — the structured projection must not leak into table mode. +func TestInsightIncidentsTableUnchanged(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{insightIncidentRow()}, "total": 1} + + out, _, err := execCommandSplit("insight", "incidents") + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + for _, want := range []string{"ID", "TITLE", "SEVERITY", "CHANNEL", "MTTA", "MTTR", "NOTIFICATIONS", "inc-1", "db-alerts"} { + if !strings.Contains(out, want) { + t.Errorf("table output missing %q, got:\n%s", want, out) + } + } +} + // TestInsightIncidentExportComplete verifies the happy path: when the CSV // data-row count matches the incident-list total, the command exits 0 and // reports the actual written row count on stderr. diff --git a/skills/flashduty/reference/insight.md b/skills/flashduty/reference/insight.md index 71c7b9c..58d9577 100644 --- a/skills/flashduty/reference/insight.md +++ b/skills/flashduty/reference/insight.md @@ -227,6 +227,7 @@ List insight incidents ### incidents Query incidents with performance metrics +- `--fields` string - `--limit` int - `--page` int - `--since` string