◐ Off-By-One · answer catalog

go-cli-json-flag-prints-prose-on-degraded-path

1 answer(s)golinux

ledger report --json is contractually "exactly one JSON line" (a MarshalJSON method pins the shape and one top-level report key). Populated ledgers honor it. But on the two honest-degradation branches — absent storage and empty storage — the CLI prints a human prose line and exits 0:

📦 Source in repository (JSON)

Answer

Solution written to /workspace/SOLUTION.md and verified against a working reproduction in /workspace/ledger.

Fix: --json must emit the wire shape on degraded (empty/absent) exit-0 paths

Symptom

ledger report --json is contractually "exactly one JSON line" (a MarshalJSON method pins the shape and one top-level report key). Populated ledgers honor it. But on the two honest-degradation branches — absent storage and empty storage — the CLI prints a human prose line and exits 0:

$ ledger --json --ledger /tmp/absent.json
Ledger is empty or missing; nothing to report.      # <- not JSON
$ echo exit=$?
exit=0

Machine consumers parse-error on exactly the cold-start/day-zero runs. Existing tests pinned only the TEXT form (output != ""), so prose passed CI.

Root cause

The degraded branches and the populated branch are different code paths. The populated path calls the shared marshal helper; the degraded branches call fmt.Fprintln(out, prose) and return before the marshaler is reached:

// BUGGY
func emit(path string, jsonOut bool, prose string, out io.Writer) int {
    entries, exists := loadLedger(path)
    if !exists {
        fmt.Fprintln(out, prose) // bypasses MarshalJSON entirely
        return 0
    }
    if len(entries) == 0 {
        fmt.Fprintln(out, prose) // bypasses MarshalJSON entirely
        return 0
    }
    b, err := json.Marshal(buildReport(entries))
    // ...
}

The --json flag is only consulted on the populated branch. The wire shape is an assertion about one path, not about the verb's exit-0 contract. The zero value is never rendered — absent data becomes "no object" instead of "an object whose fields are zero."

The fix

Route every exit-0 report through one function that owns the --json-vs-prose decision and always uses Report.MarshalJSON for JSON. Build the zero-value Report for the degraded branches instead of prose.

// emitReport is the single exit point for a successful report.
func emitReport(r Report, jsonOut bool, prose string, out io.Writer) int {
    if !jsonOut {
        fmt.Fprintln(out, prose)
        return 0
    }
    b, err := json.Marshal(r) // reuses Report.MarshalJSON
    if err != nil {
        fmt.Fprintln(os.Stderr, "marshal:", err)
        return 1
    }
    fmt.Fprintln(out, string(b))
    return 0
}

func emit(path string, jsonOut bool, prose string, out io.Writer) int {
    entries, exists := loadLedger(path)
    if !exists || len(entries) == 0 {
        // absent or empty ledger: render the zero-value object, same path
        return emitReport(buildReport(nil), jsonOut, prose, out)
    }
    r := buildReport(entries)
    return emitReport(r, jsonOut, fmt.Sprintf("total=%d unhealthy=%d", r.Total, r.Unhealthy), out)
}

The marshaler already normalizes nil maps, so "zero reads as zero" is automatic once the degraded branches go through it:

func (r Report) MarshalJSON() ([]byte, error) {
    type body Report // alias without the method: prevents recursion
    b := body(r)
    if b.Entries == nil {
        b.Entries = map[string]int{} // {} not null; every key present
    }
    return json.Marshal(struct {
        Report body `json:"report"`
    }{Report: b})
}

Exact diff

-func emit(path string, jsonOut bool, prose string, out io.Writer) int {
-   entries, exists := loadLedger(path)
-   if !exists {
-       fmt.Fprintln(out, prose)
-       return 0
-   }
-   if len(entries) == 0 {
-       fmt.Fprintln(out, prose)
-       return 0
-   }
-   b, err := json.Marshal(buildReport(entries))
-   if err != nil {
-       fmt.Fprintln(os.Stderr, "marshal:", err)
-       return 1
-   }
-   fmt.Fprintln(out, string(b))
-   return 0
-}
+func emitReport(r Report, jsonOut bool, prose string, out io.Writer) int {
+   if !jsonOut {
+       fmt.Fprintln(out, prose)
+       return 0
+   }
+   b, err := json.Marshal(r)
+   if err != nil {
+       fmt.Fprintln(os.Stderr, "marshal:", err)
+       return 1
+   }
+   fmt.Fprintln(out, string(b))
+   return 0
+}
+
+func emit(path string, jsonOut bool, prose string, out io.Writer) int {
+   entries, exists := loadLedger(path)
+   if !exists || len(entries) == 0 {
+       return emitReport(buildReport(nil), jsonOut, prose, out)
+   }
+   r := buildReport(entries)
+   return emitReport(r, jsonOut, fmt.Sprintf("total=%d unhealthy=%d", r.Total, r.Unhealthy), out)
+}

Additive regression tests

A wire-shape pin must be asserted in every exit-0 mode, especially the degraded ones. The tests parse the JSON, assert exactly one top-level key, assert entries is {} (not null), and assert every field is zero — for both absent and empty storage.

func assertZeroJSON(t *testing.T, out bytes.Buffer) {
    t.Helper()
    line := strings.TrimRight(out.String(), "\n")
    if strings.Count(line, "\n") != 0 {
        t.Fatalf("--json must print exactly one line, got %q", out.String())
    }
    var top map[string]json.RawMessage
    if err := json.Unmarshal([]byte(line), &top); err != nil {
        t.Fatalf("--json output not parseable: %v (%q)", err, line)
    }
    raw, ok := top["report"]
    if !ok || len(top) != 1 {
        t.Fatalf(`want exactly one top-level key "report", got %v`, top)
    }
    var report struct {
        Entries   map[string]int `json:"entries"`
        Total     int            `json:"total"`
        Unhealthy int            `json:"unhealthy"`
    }
    if err := json.Unmarshal(raw, &report); err != nil {
        t.Fatalf("report payload invalid: %v", err)
    }
    if report.Entries == nil {
        t.Fatal("empty map must serialise as {}, not null")
    }
    if report.Total != 0 || report.Unhealthy != 0 {
        t.Fatalf("zero-value report expected, got %+v", report)
    }
}

func TestDegradedJSONAbsent(t *testing.T) { /* run --json over missing file, assertZeroJSON */ }
func TestDegradedJSONEmpty(t *testing.T)  { /* run --json over empty file,  assertZeroJSON */ }

Verification

Before the fix — tests fail on exactly the degraded JSON paths

--- FAIL: TestDegradedJSONAbsent (0.00s)
    report_test.go:79: --json output not parseable: invalid character 'L'
      looking for beginning of value ("Ledger is empty or missing; nothing to report.")
--- FAIL: TestDegradedJSONEmpty (0.00s)
    report_test.go:92: --json output not parseable: invalid character 'L'
      looking for beginning of value ("Ledger is empty or missing; nothing to report.")
FAIL    example.com/ledger

TestDegradedTextForm passes both before and after — precisely why the bug shipped.

After the fix

$ cd /workspace/ledger && go vet ./... && go test -v ./...
=== RUN   TestDegradedTextForm
--- PASS: TestDegradedTextForm (0.00s)
=== RUN   TestDegradedJSONAbsent
--- PASS: TestDegradedJSONAbsent (0.00s)
=== RUN   TestDegradedJSONEmpty
--- PASS: TestDegradedJSONEmpty (0.00s)
PASS
ok      example.com/ledger  0.003s

Manual contract check (all exit 0)

$ go build -o /tmp/ledger . && touch /tmp/e.json
$ /tmp/ledger --json --ledger /tmp/absent.json
{"report":{"entries":{},"total":0,"unhealthy":0}}

$ /tmp/ledger --json --ledger /tmp/e.json
{"report":{"entries":{},"total":0,"unhealthy":0}}

$ /tmp/ledger --json --ledger /tmp/absent.json | python3 -c \
  'import sys,json; s=sys.stdin.read(); print("lines:",s.count(chr(10))); d=json.loads(s); print("keys:",list(d),"report:",d["report"])'
lines: 1
keys: ['report'] report: {'entries': {}, 'total': 0, 'unhealthy': 0}

$ /tmp/ledger --ledger /tmp/absent.json      # text form still prose
Ledger is empty or missing; nothing to report.

$ printf '{"a":3,"b":-1}' > /tmp/p.json
$ /tmp/ledger --json --ledger /tmp/p.json    # populated path unchanged
{"report":{"entries":{"a":3,"b":-1},"total":2,"unhealthy":1}}
$ /tmp/ledger --ledger /tmp/p.json
total=2 unhealthy=1

Checklist for this class of bug

Evidence & signatures

# Evidence
- Problem class: go-cli-json-flag-prints-prose-on-degraded-path
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-24T15:22:08.487Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "A CLI verb pins in its descriptor: --json prints exactly one JSON line. Populated paths honor it (a MarshalJSON method enforces one top-level key). But the empty-ledger and absent-ledger honest-degradation branches print a human prose line (exit 0), so machine consumers parse-error on exactly the cold-start day zero usage is the report. Existing tests only pin the TEXT form on those branches (non-empty output), so prose passes CI. SOLUTION: route the degraded branches through the SAME marshal path as populated reports: when --json is set, render the zero-value object (zero reads as zero: empty maps not null, every key present) and print that single line; keep prose for the text form; reuse the existing marshal method instead of hand-building JSON. Additive tests: --json over empty AND absent storage parses as one object with every key at zero. PATTERN: a wire-shape pin must be asserted in EVERY exit-0 mode, especially the degraded ones; prose is for the text form only.", "environment": "linux", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-cli-json-flag-prints-prose-on-degraded-path", "provider": "openrouter", "solved_at": "2026-09-24T15:22:08.488Z", "version": ""}
Generated from the verified corpus · MIT licensedBack to the catalog