[object Object]

← back to Cli Printing Press

fix(cli): preserve unknown-shape records in compactListFields (#1078)

dc6fe8791ec3693a8addac8dab27f0e333c1e326 · 2026-05-11 09:51:32 -0700 · Trevin Chow

`compactListFields` previously reduced any list item that carried no
allowlist key to `{}`, so printed CLIs whose APIs use domain-specific
field names (travel: fare/airline; commerce: vendor; locale: domain)
silently emitted empty objects under `--agent` / `--compact`. The narrow
id/name/title allowlist also dropped useful business scalars (price,
rating, currency) from canonical-schema records that did match `id`/`name`.

Extend the allowlist with monetary, metric, locale, identity-adjacent,
temporal, and versioning scalars, and add a per-item fallback so records
with no matching keys are preserved whole instead of nuked. Pin the
contract via a new template-level test mirroring TestPipedJsonGate.

Closes #1046

Files touched

Diff

commit dc6fe8791ec3693a8addac8dab27f0e333c1e326
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Mon May 11 09:51:32 2026 -0700

    fix(cli): preserve unknown-shape records in compactListFields (#1078)
    
    `compactListFields` previously reduced any list item that carried no
    allowlist key to `{}`, so printed CLIs whose APIs use domain-specific
    field names (travel: fare/airline; commerce: vendor; locale: domain)
    silently emitted empty objects under `--agent` / `--compact`. The narrow
    id/name/title allowlist also dropped useful business scalars (price,
    rating, currency) from canonical-schema records that did match `id`/`name`.
    
    Extend the allowlist with monetary, metric, locale, identity-adjacent,
    temporal, and versioning scalars, and add a per-item fallback so records
    with no matching keys are preserved whole instead of nuked. Pin the
    contract via a new template-level test mirroring TestPipedJsonGate.
    
    Closes #1046
---
 internal/generator/generator_test.go               | 33 ++++++++++++++++++++++
 internal/generator/templates/helpers.go.tmpl       | 26 +++++++++++++++--
 .../internal/cli/helpers.go                        | 26 +++++++++++++++--
 .../printing-press-golden/internal/cli/helpers.go  | 26 +++++++++++++++--
 4 files changed, 105 insertions(+), 6 deletions(-)

diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index d9b15871..a60d65f7 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -3332,6 +3332,39 @@ func TestGeneratedOutput_MutatingCommandsHaveEnvelope(t *testing.T) {
 	assert.Contains(t, content, `envelope["success"] = false`)
 }
 
+// TestCompactListFieldsPreservesUnknownShapes pins the two contracts that
+// keep `--agent` / `--compact` from silently emitting {} for records that
+// don't match the canonical id/name/title allowlist: a per-item fallback
+// to the original item, and a wider scalar allowlist that covers
+// monetary, metric, locale, and identity-adjacent keys.
+//
+// Pinned at the template level because the helper renders into every
+// printed CLI's helpers.go and a per-fixture assertion would miss future
+// copies that drift in.
+func TestCompactListFieldsPreservesUnknownShapes(t *testing.T) {
+	t.Parallel()
+
+	path := filepath.Join("templates", "helpers.go.tmpl")
+	data, err := os.ReadFile(path)
+	require.NoError(t, err, "template must exist: %s", path)
+	body := string(data)
+
+	assert.Contains(t, body, "if len(compact) == 0 {",
+		"compactListFields must preserve the original item when no allowlist keys match — otherwise records with domain-specific field names get reduced to {}")
+	assert.Contains(t, body, "compact = item",
+		"compactListFields must assign the original item as the per-item fallback")
+
+	for _, key := range []string{
+		`"price":`, `"fare":`, `"currency":`,
+		`"rating":`,
+		`"locale":`, `"language":`,
+		`"code":`,
+	} {
+		assert.Contains(t, body, key,
+			"compactListFields allowlist must include %s so canonical-schema records keep high-signal scalars across business/travel/commerce/locale APIs", key)
+	}
+}
+
 // TestPipedJsonGateRespectsExplicitFormatFlags pins the contract: the
 // piped-output auto-JSON gate must defer to explicit --csv / --quiet /
 // --plain flags so piped consumers that asked for a non-JSON format
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index 976511d3..a5c2fcba 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -777,12 +777,31 @@ func compactFields(data json.RawMessage) json.RawMessage {
 }
 
 // compactListFields keeps only high-gravity fields for array responses.
+// When an item carries none of these keys the original is preserved, so
+// `--agent` does not silently emit {} for APIs whose response shapes use
+// domain-specific field names (travel: airline/destination; commerce: vendor).
 func compactListFields(items []map[string]any) json.RawMessage {
 	keepFields := map[string]bool{
+		// Identity
 		"id": true, "name": true, "title": true, "identifier": true,
-		"status": true, "state": true, "type": true, "priority": true,
-		"url": true, "email": true, "key": true,
+		"code": true, "slug": true, "key": true,
+		// Categorization
+		"status": true, "state": true, "type": true, "kind": true, "priority": true,
+		// Communication
+		"url": true, "email": true,
+		// Monetary
+		"price": true, "amount": true, "cost": true, "fare": true,
+		"rate": true, "currency": true,
+		// Metrics
+		"rating": true, "score": true, "count": true,
+		// Locale / geo
+		"language": true, "locale": true, "country": true, "region": true,
+		"city": true, "domain": true,
+		// Temporal
 		"created_at": true, "updated_at": true, "createdAt": true, "updatedAt": true,
+		"date": true,
+		// Versioning
+		"version": true,
 	}
 
 	filtered := make([]map[string]any, 0, len(items))
@@ -793,6 +812,9 @@ func compactListFields(items []map[string]any) json.RawMessage {
 				compact[k] = v
 			}
 		}
+		if len(compact) == 0 {
+			compact = item
+		}
 		filtered = append(filtered, compact)
 	}
 	result, _ := json.Marshal(filtered)
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
index 5ed45def..5d0bf0c8 100644
--- a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
@@ -579,12 +579,31 @@ func compactFields(data json.RawMessage) json.RawMessage {
 }
 
 // compactListFields keeps only high-gravity fields for array responses.
+// When an item carries none of these keys the original is preserved, so
+// `--agent` does not silently emit {} for APIs whose response shapes use
+// domain-specific field names (travel: airline/destination; commerce: vendor).
 func compactListFields(items []map[string]any) json.RawMessage {
 	keepFields := map[string]bool{
+		// Identity
 		"id": true, "name": true, "title": true, "identifier": true,
-		"status": true, "state": true, "type": true, "priority": true,
-		"url": true, "email": true, "key": true,
+		"code": true, "slug": true, "key": true,
+		// Categorization
+		"status": true, "state": true, "type": true, "kind": true, "priority": true,
+		// Communication
+		"url": true, "email": true,
+		// Monetary
+		"price": true, "amount": true, "cost": true, "fare": true,
+		"rate": true, "currency": true,
+		// Metrics
+		"rating": true, "score": true, "count": true,
+		// Locale / geo
+		"language": true, "locale": true, "country": true, "region": true,
+		"city": true, "domain": true,
+		// Temporal
 		"created_at": true, "updated_at": true, "createdAt": true, "updatedAt": true,
+		"date": true,
+		// Versioning
+		"version": true,
 	}
 
 	filtered := make([]map[string]any, 0, len(items))
@@ -595,6 +614,9 @@ func compactListFields(items []map[string]any) json.RawMessage {
 				compact[k] = v
 			}
 		}
+		if len(compact) == 0 {
+			compact = item
+		}
 		filtered = append(filtered, compact)
 	}
 	result, _ := json.Marshal(filtered)
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
index 80b431b3..8a2aa5b3 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
@@ -582,12 +582,31 @@ func compactFields(data json.RawMessage) json.RawMessage {
 }
 
 // compactListFields keeps only high-gravity fields for array responses.
+// When an item carries none of these keys the original is preserved, so
+// `--agent` does not silently emit {} for APIs whose response shapes use
+// domain-specific field names (travel: airline/destination; commerce: vendor).
 func compactListFields(items []map[string]any) json.RawMessage {
 	keepFields := map[string]bool{
+		// Identity
 		"id": true, "name": true, "title": true, "identifier": true,
-		"status": true, "state": true, "type": true, "priority": true,
-		"url": true, "email": true, "key": true,
+		"code": true, "slug": true, "key": true,
+		// Categorization
+		"status": true, "state": true, "type": true, "kind": true, "priority": true,
+		// Communication
+		"url": true, "email": true,
+		// Monetary
+		"price": true, "amount": true, "cost": true, "fare": true,
+		"rate": true, "currency": true,
+		// Metrics
+		"rating": true, "score": true, "count": true,
+		// Locale / geo
+		"language": true, "locale": true, "country": true, "region": true,
+		"city": true, "domain": true,
+		// Temporal
 		"created_at": true, "updated_at": true, "createdAt": true, "updatedAt": true,
+		"date": true,
+		// Versioning
+		"version": true,
 	}
 
 	filtered := make([]map[string]any, 0, len(items))
@@ -598,6 +617,9 @@ func compactListFields(items []map[string]any) json.RawMessage {
 				compact[k] = v
 			}
 		}
+		if len(compact) == 0 {
+			compact = item
+		}
 		filtered = append(filtered, compact)
 	}
 	result, _ := json.Marshal(filtered)

← d7e2d74f feat(cli): promote html_scrape reachability mode when captch  ·  back to Cli Printing Press  ·  feat(cli): sync.walker spec parameter for hierarchical APIs 24962b7c →