[object Object]

← back to Cli Printing Press

feat(cli): add cliutil.ExtractNumber/ExtractInt for JSON-string-encoded numeric fields (#1002)

182395caaa631a5fa7721dc0a6f158c557cd473e · 2026-05-10 18:54:05 -0700 · Trevin Chow

Streaming and market-data APIs (Binance, Coinbase, Kraken, Stripe
*_decimal, vendor WebSocket feeds) commonly encode numeric values as
JSON-encoded strings ("price":"1.91"). json.Unmarshal of a JSON string
into a float64 struct field returns no error and silently leaves the
field at 0 — combined with NULL-on-zero patterns this discards the
entire numeric feed with no error signal at any structural test layer.

Emit a shared cliutil.ExtractNumber + ExtractInt helper into every
generated CLI. Both accept either a JSON number or a JSON-encoded
string, report ok=false on missing/null/empty/unparseable, and operate
on map[string]json.RawMessage so streaming-frame normalizers can use
them directly.

Surface the helper to novel-feature authors in the Phase 3 "Shared
helpers available to novel code" block of the skill, alongside the
existing FanoutRun / CleanText guidance.

Closes #989

Files touched

Diff

commit 182395caaa631a5fa7721dc0a6f158c557cd473e
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sun May 10 18:54:05 2026 -0700

    feat(cli): add cliutil.ExtractNumber/ExtractInt for JSON-string-encoded numeric fields (#1002)
    
    Streaming and market-data APIs (Binance, Coinbase, Kraken, Stripe
    *_decimal, vendor WebSocket feeds) commonly encode numeric values as
    JSON-encoded strings ("price":"1.91"). json.Unmarshal of a JSON string
    into a float64 struct field returns no error and silently leaves the
    field at 0 — combined with NULL-on-zero patterns this discards the
    entire numeric feed with no error signal at any structural test layer.
    
    Emit a shared cliutil.ExtractNumber + ExtractInt helper into every
    generated CLI. Both accept either a JSON number or a JSON-encoded
    string, report ok=false on missing/null/empty/unparseable, and operate
    on map[string]json.RawMessage so streaming-frame normalizers can use
    them directly.
    
    Surface the helper to novel-feature authors in the Phase 3 "Shared
    helpers available to novel code" block of the skill, alongside the
    existing FanoutRun / CleanText guidance.
    
    Closes #989
---
 internal/generator/generator.go                    |  76 ++++++-------
 internal/generator/generator_test.go               |  14 ++-
 .../templates/cliutil_extractnumber.go.tmpl        |  67 ++++++++++++
 .../templates/cliutil_extractnumber_test.go.tmpl   | 117 +++++++++++++++++++++
 skills/printing-press/SKILL.md                     |   2 +
 5 files changed, 235 insertions(+), 41 deletions(-)

diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 3d1efb1e..c5180e6f 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -1387,37 +1387,39 @@ func (g *Generator) prepareOutput() error {
 
 func (g *Generator) renderSingleFiles() error {
 	singleFiles := map[string]string{
-		"main.go.tmpl":               filepath.Join("cmd", naming.CLI(g.Spec.Name), "main.go"),
-		"helpers.go.tmpl":            filepath.Join("internal", "cli", "helpers.go"),
-		"doctor.go.tmpl":             filepath.Join("internal", "cli", "doctor.go"),
-		"agent_context.go.tmpl":      filepath.Join("internal", "cli", "agent_context.go"),
-		"profile.go.tmpl":            filepath.Join("internal", "cli", "profile.go"),
-		"deliver.go.tmpl":            filepath.Join("internal", "cli", "deliver.go"),
-		"feedback.go.tmpl":           filepath.Join("internal", "cli", "feedback.go"),
-		"which.go.tmpl":              filepath.Join("internal", "cli", "which.go"),
-		"which_test.go.tmpl":         filepath.Join("internal", "cli", "which_test.go"),
-		"config.go.tmpl":             filepath.Join("internal", "config", "config.go"),
-		"cache.go.tmpl":              filepath.Join("internal", "cache", "cache.go"),
-		"client.go.tmpl":             filepath.Join("internal", "client", "client.go"),
-		"cliutil_fanout.go.tmpl":     filepath.Join("internal", "cliutil", "fanout.go"),
-		"cliutil_text.go.tmpl":       filepath.Join("internal", "cliutil", "text.go"),
-		"cliutil_probe.go.tmpl":      filepath.Join("internal", "cliutil", "probe.go"),
-		"cliutil_ratelimit.go.tmpl":  filepath.Join("internal", "cliutil", "ratelimit.go"),
-		"cliutil_verifyenv.go.tmpl":  filepath.Join("internal", "cliutil", "verifyenv.go"),
-		"cliutil_test.go.tmpl":       filepath.Join("internal", "cliutil", "cliutil_test.go"),
-		"cobratree/walker.go.tmpl":   filepath.Join("internal", "mcp", "cobratree", "walker.go"),
-		"cobratree/classify.go.tmpl": filepath.Join("internal", "mcp", "cobratree", "classify.go"),
-		"cobratree/typemap.go.tmpl":  filepath.Join("internal", "mcp", "cobratree", "typemap.go"),
-		"cobratree/shellout.go.tmpl": filepath.Join("internal", "mcp", "cobratree", "shellout.go"),
-		"cobratree/cli_path.go.tmpl": filepath.Join("internal", "mcp", "cobratree", "cli_path.go"),
-		"cobratree/names.go.tmpl":    filepath.Join("internal", "mcp", "cobratree", "names.go"),
-		"types.go.tmpl":              filepath.Join("internal", "types", "types.go"),
-		"golangci.yml.tmpl":          ".golangci.yml",
-		"readme.md.tmpl":             "README.md",
-		"agents.md.tmpl":             "AGENTS.md",
-		"skill.md.tmpl":              "SKILL.md",
-		"LICENSE.tmpl":               "LICENSE",
-		"NOTICE.tmpl":                "NOTICE",
+		"main.go.tmpl":                       filepath.Join("cmd", naming.CLI(g.Spec.Name), "main.go"),
+		"helpers.go.tmpl":                    filepath.Join("internal", "cli", "helpers.go"),
+		"doctor.go.tmpl":                     filepath.Join("internal", "cli", "doctor.go"),
+		"agent_context.go.tmpl":              filepath.Join("internal", "cli", "agent_context.go"),
+		"profile.go.tmpl":                    filepath.Join("internal", "cli", "profile.go"),
+		"deliver.go.tmpl":                    filepath.Join("internal", "cli", "deliver.go"),
+		"feedback.go.tmpl":                   filepath.Join("internal", "cli", "feedback.go"),
+		"which.go.tmpl":                      filepath.Join("internal", "cli", "which.go"),
+		"which_test.go.tmpl":                 filepath.Join("internal", "cli", "which_test.go"),
+		"config.go.tmpl":                     filepath.Join("internal", "config", "config.go"),
+		"cache.go.tmpl":                      filepath.Join("internal", "cache", "cache.go"),
+		"client.go.tmpl":                     filepath.Join("internal", "client", "client.go"),
+		"cliutil_fanout.go.tmpl":             filepath.Join("internal", "cliutil", "fanout.go"),
+		"cliutil_text.go.tmpl":               filepath.Join("internal", "cliutil", "text.go"),
+		"cliutil_probe.go.tmpl":              filepath.Join("internal", "cliutil", "probe.go"),
+		"cliutil_ratelimit.go.tmpl":          filepath.Join("internal", "cliutil", "ratelimit.go"),
+		"cliutil_verifyenv.go.tmpl":          filepath.Join("internal", "cliutil", "verifyenv.go"),
+		"cliutil_extractnumber.go.tmpl":      filepath.Join("internal", "cliutil", "extractnumber.go"),
+		"cliutil_extractnumber_test.go.tmpl": filepath.Join("internal", "cliutil", "extractnumber_test.go"),
+		"cliutil_test.go.tmpl":               filepath.Join("internal", "cliutil", "cliutil_test.go"),
+		"cobratree/walker.go.tmpl":           filepath.Join("internal", "mcp", "cobratree", "walker.go"),
+		"cobratree/classify.go.tmpl":         filepath.Join("internal", "mcp", "cobratree", "classify.go"),
+		"cobratree/typemap.go.tmpl":          filepath.Join("internal", "mcp", "cobratree", "typemap.go"),
+		"cobratree/shellout.go.tmpl":         filepath.Join("internal", "mcp", "cobratree", "shellout.go"),
+		"cobratree/cli_path.go.tmpl":         filepath.Join("internal", "mcp", "cobratree", "cli_path.go"),
+		"cobratree/names.go.tmpl":            filepath.Join("internal", "mcp", "cobratree", "names.go"),
+		"types.go.tmpl":                      filepath.Join("internal", "types", "types.go"),
+		"golangci.yml.tmpl":                  ".golangci.yml",
+		"readme.md.tmpl":                     "README.md",
+		"agents.md.tmpl":                     "AGENTS.md",
+		"skill.md.tmpl":                      "SKILL.md",
+		"LICENSE.tmpl":                       "LICENSE",
+		"NOTICE.tmpl":                        "NOTICE",
 	}
 
 	for tmplName, outPath := range singleFiles {
@@ -1670,11 +1672,13 @@ func (g *Generator) GenerateMCPSurface() error {
 		// predates a helper the new MCP template uses (SanitizeErrorBody,
 		// LooksLikeAuthError) fail to build after migration. See the
 		// GenerateMCPSurface doc comment for the full rationale.
-		"cliutil_fanout.go.tmpl":    filepath.Join("internal", "cliutil", "fanout.go"),
-		"cliutil_text.go.tmpl":      filepath.Join("internal", "cliutil", "text.go"),
-		"cliutil_probe.go.tmpl":     filepath.Join("internal", "cliutil", "probe.go"),
-		"cliutil_ratelimit.go.tmpl": filepath.Join("internal", "cliutil", "ratelimit.go"),
-		"cliutil_verifyenv.go.tmpl": filepath.Join("internal", "cliutil", "verifyenv.go"),
+		"cliutil_fanout.go.tmpl":             filepath.Join("internal", "cliutil", "fanout.go"),
+		"cliutil_text.go.tmpl":               filepath.Join("internal", "cliutil", "text.go"),
+		"cliutil_probe.go.tmpl":              filepath.Join("internal", "cliutil", "probe.go"),
+		"cliutil_ratelimit.go.tmpl":          filepath.Join("internal", "cliutil", "ratelimit.go"),
+		"cliutil_verifyenv.go.tmpl":          filepath.Join("internal", "cliutil", "verifyenv.go"),
+		"cliutil_extractnumber.go.tmpl":      filepath.Join("internal", "cliutil", "extractnumber.go"),
+		"cliutil_extractnumber_test.go.tmpl": filepath.Join("internal", "cliutil", "extractnumber_test.go"),
 	}
 	for tmplName, outPath := range mcpFiles {
 		if err := g.renderTemplate(tmplName, outPath, g.Spec); err != nil {
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 438a08eb..bf54a5e9 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -56,6 +56,8 @@ func TestGenerateProjectsCompile(t *testing.T) {
 		"internal/cliutil/probe.go",
 		"internal/cliutil/ratelimit.go",
 		"internal/cliutil/verifyenv.go",
+		"internal/cliutil/extractnumber.go",
+		"internal/cliutil/extractnumber_test.go",
 		"internal/cliutil/cliutil_test.go",
 		"internal/client/client.go",
 		"internal/config/config.go",
@@ -76,9 +78,9 @@ func TestGenerateProjectsCompile(t *testing.T) {
 		// Bump it AND add to mustInclude above when adding always-emitted
 		// templates. Per-spec dynamic files (per-resource command files,
 		// generated tests) account for the difference between fixtures.
-		{name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 55},
-		{name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 60},
-		{name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 57},
+		{name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 57},
+		{name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 62},
+		{name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 59},
 	}
 
 	for _, tt := range tests {
@@ -125,9 +127,9 @@ func TestGenerateCliutilPackage(t *testing.T) {
 	gen := New(apiSpec, outputDir)
 	require.NoError(t, gen.Generate())
 
-	// All three cliutil files must be emitted.
+	// All cliutil files must be emitted.
 	cliutilDir := filepath.Join(outputDir, "internal", "cliutil")
-	for _, name := range []string{"fanout.go", "text.go", "cliutil_test.go"} {
+	for _, name := range []string{"fanout.go", "text.go", "extractnumber.go", "extractnumber_test.go", "cliutil_test.go"} {
 		_, err := os.Stat(filepath.Join(cliutilDir, name))
 		require.NoError(t, err, "expected %s to be emitted", name)
 	}
@@ -146,6 +148,8 @@ func TestGenerateCliutilPackage(t *testing.T) {
 		{"text.go", "func CleanText("},
 		{"text.go", "func LooksLikeAuthError("},
 		{"text.go", "func SanitizeErrorBody("},
+		{"extractnumber.go", "func ExtractNumber("},
+		{"extractnumber.go", "func ExtractInt("},
 	} {
 		data, err := os.ReadFile(filepath.Join(cliutilDir, probe.file))
 		require.NoError(t, err)
diff --git a/internal/generator/templates/cliutil_extractnumber.go.tmpl b/internal/generator/templates/cliutil_extractnumber.go.tmpl
new file mode 100644
index 00000000..d309cc46
--- /dev/null
+++ b/internal/generator/templates/cliutil_extractnumber.go.tmpl
@@ -0,0 +1,67 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil
+
+import (
+	"encoding/json"
+	"strconv"
+)
+
+// ExtractNumber reads a numeric value from a json.RawMessage map, accepting
+// either a JSON number (1.91) or a JSON-encoded string ("1.91"). Returns
+// (value, ok). ok=false when the key is missing, JSON null, an empty raw
+// payload, an empty string, or unparseable as a float64.
+//
+// Why this exists: many streaming and market-data APIs (Binance, Coinbase,
+// Kraken, Stripe *_decimal fields, vendor-specific WebSocket feeds) encode
+// numeric values as JSON-encoded strings. json.Unmarshal of "1.91" into a
+// float64 struct field silently zeroes the field — no error is returned.
+// Combined with a NULL-on-zero pattern (common for SQL aggregation
+// correctness) every such field becomes a SQL NULL and downstream
+// aggregations return empty. The failure is silent at every structural
+// test layer; only frame-level inspection reveals it.
+//
+// Prefer this helper over hand-rolled float64 struct fields when authoring
+// streaming frame normalizers or any decoder whose wire shape is not
+// fully fixed by the spec.
+func ExtractNumber(probe map[string]json.RawMessage, key string) (float64, bool) {
+	raw, present := probe[key]
+	if !present || len(raw) == 0 || string(raw) == "null" {
+		return 0, false
+	}
+	var f float64
+	if err := json.Unmarshal(raw, &f); err == nil {
+		return f, true
+	}
+	var s string
+	if err := json.Unmarshal(raw, &s); err == nil && s != "" {
+		if v, perr := strconv.ParseFloat(s, 64); perr == nil {
+			return v, true
+		}
+	}
+	return 0, false
+}
+
+// ExtractInt is the integer companion of ExtractNumber. It accepts a JSON
+// integer (123) or a JSON-encoded integer string ("123"). Returns
+// (value, ok). ok=false when the key is missing, null, empty, or
+// unparseable as an int64. A non-integer JSON number (1.5) is rejected
+// rather than truncated.
+func ExtractInt(probe map[string]json.RawMessage, key string) (int64, bool) {
+	raw, present := probe[key]
+	if !present || len(raw) == 0 || string(raw) == "null" {
+		return 0, false
+	}
+	var n int64
+	if err := json.Unmarshal(raw, &n); err == nil {
+		return n, true
+	}
+	var s string
+	if err := json.Unmarshal(raw, &s); err == nil && s != "" {
+		if v, perr := strconv.ParseInt(s, 10, 64); perr == nil {
+			return v, true
+		}
+	}
+	return 0, false
+}
diff --git a/internal/generator/templates/cliutil_extractnumber_test.go.tmpl b/internal/generator/templates/cliutil_extractnumber_test.go.tmpl
new file mode 100644
index 00000000..a8b17559
--- /dev/null
+++ b/internal/generator/templates/cliutil_extractnumber_test.go.tmpl
@@ -0,0 +1,117 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil
+
+import (
+	"encoding/json"
+	"math"
+	"testing"
+)
+
+func TestExtractNumber(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name   string
+		input  string
+		key    string
+		wantV  float64
+		wantOk bool
+	}{
+		{"json number", `{"price":1.91}`, "price", 1.91, true},
+		{"json integer", `{"price":42}`, "price", 42, true},
+		{"json zero", `{"price":0}`, "price", 0, true},
+		{"json negative number", `{"price":-3.5}`, "price", -3.5, true},
+		{"string-encoded number", `{"price":"1.91"}`, "price", 1.91, true},
+		{"string-encoded integer", `{"qty":"100"}`, "qty", 100, true},
+		{"string-encoded negative", `{"x":"-3.5"}`, "x", -3.5, true},
+		{"string-encoded exponent", `{"x":"1e2"}`, "x", 100, true},
+		{"null", `{"price":null}`, "price", 0, false},
+		{"missing key", `{}`, "price", 0, false},
+		{"empty string", `{"price":""}`, "price", 0, false},
+		{"unparseable string", `{"price":"abc"}`, "price", 0, false},
+		{"bool true", `{"price":true}`, "price", 0, false},
+		{"object", `{"price":{"a":1}}`, "price", 0, false},
+		{"array", `{"price":[1,2]}`, "price", 0, false},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+			var probe map[string]json.RawMessage
+			if err := json.Unmarshal([]byte(tc.input), &probe); err != nil {
+				t.Fatalf("unmarshal probe: %v", err)
+			}
+			got, ok := ExtractNumber(probe, tc.key)
+			if ok != tc.wantOk {
+				t.Fatalf("ExtractNumber ok = %v, want %v", ok, tc.wantOk)
+			}
+			if ok && math.Abs(got-tc.wantV) > 1e-9 {
+				t.Fatalf("ExtractNumber value = %v, want %v", got, tc.wantV)
+			}
+		})
+	}
+}
+
+func TestExtractNumber_NilProbe(t *testing.T) {
+	t.Parallel()
+
+	got, ok := ExtractNumber(nil, "anything")
+	if ok || got != 0 {
+		t.Fatalf("ExtractNumber(nil, ...) = (%v, %v), want (0, false)", got, ok)
+	}
+}
+
+func TestExtractInt(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name   string
+		input  string
+		key    string
+		wantV  int64
+		wantOk bool
+	}{
+		{"json integer", `{"id":123}`, "id", 123, true},
+		{"json zero", `{"id":0}`, "id", 0, true},
+		{"json negative", `{"id":-5}`, "id", -5, true},
+		{"string-encoded int", `{"id":"123"}`, "id", 123, true},
+		{"string-encoded negative", `{"id":"-5"}`, "id", -5, true},
+		{"null", `{"id":null}`, "id", 0, false},
+		{"missing key", `{}`, "id", 0, false},
+		{"empty string", `{"id":""}`, "id", 0, false},
+		{"unparseable string", `{"id":"abc"}`, "id", 0, false},
+		{"float rejected", `{"id":1.5}`, "id", 0, false},
+		{"float string rejected", `{"id":"1.5"}`, "id", 0, false},
+		{"bool", `{"id":true}`, "id", 0, false},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+			var probe map[string]json.RawMessage
+			if err := json.Unmarshal([]byte(tc.input), &probe); err != nil {
+				t.Fatalf("unmarshal probe: %v", err)
+			}
+			got, ok := ExtractInt(probe, tc.key)
+			if ok != tc.wantOk {
+				t.Fatalf("ExtractInt ok = %v, want %v", ok, tc.wantOk)
+			}
+			if ok && got != tc.wantV {
+				t.Fatalf("ExtractInt value = %v, want %v", got, tc.wantV)
+			}
+		})
+	}
+}
+
+func TestExtractInt_NilProbe(t *testing.T) {
+	t.Parallel()
+
+	got, ok := ExtractInt(nil, "anything")
+	if ok || got != 0 {
+		t.Fatalf("ExtractInt(nil, ...) = (%v, %v), want (0, false)", got, ok)
+	}
+}
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index b87b0af0..2dba62ec 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -2366,6 +2366,8 @@ For features that combine both (cache an API response in the store, or fall thro
 
 **Shared helpers available to novel code:** The generator emits `internal/cliutil/` in every CLI. When authoring novel commands, prefer `cliutil.FanoutRun` for any aggregation command (any `--site`/`--source`/`--region` CSV fan-out) and `cliutil.CleanText` for any text extracted from HTML or schema.org JSON-LD. Re-implementing these inline is how recipe-goat's trending silent-drop and `&#39;` entity bugs shipped.
 
+**Streaming frame normalizers MUST use `cliutil.ExtractNumber` / `cliutil.ExtractInt` rather than raw `float64`/`int64` struct fields.** Real-world WebSocket and streaming JSON feeds (Binance, Coinbase, Kraken, Stripe `*_decimal`, vendor-specific market-data feeds) commonly encode numeric values as JSON-encoded strings (`"price":"1.91"`). `json.Unmarshal` of a JSON string into a `float64` field returns no error and silently leaves the field at 0; combined with NULL-on-zero patterns this discards the entire numeric feed with no error signal anywhere in the pipeline. The helpers accept both shapes (JSON number or JSON-encoded string), report `ok=false` on missing/null/unparseable, and are the canonical extraction path for `map[string]json.RawMessage` decoders. Re-implementing this inline as a `float64` struct field is the silent-aggregation-failure bug class.
+
 **Typed exit-code verification:** If a novel command intentionally returns a non-zero code for a non-error control-flow result, add `cmd.Annotations["pp:typed-exit-codes"] = "0,<code>"` (or the equivalent `Annotations: map[string]string{...}` literal) and document the same command-specific codes in its help. Do not list the global failure palette in command help unless those exits should count as a verify pass for that command; keep general exit-code troubleshooting in README/SKILL prose.
 
 **MCP exposure:** The generator emits `internal/mcp/cobratree/`, and the MCP binary mirrors the Cobra tree at startup. When you add, rename, or remove a user-facing Cobra command, the MCP surface follows automatically. Two annotations control how each command appears as an MCP tool:

← 6e086c00 fix(cli): fold per-spec base URL path prefixes on multi-spec  ·  back to Cli Printing Press  ·  fix(cli): bind typed upsert values in column-declaration ord fef70ba1 →