Skip to content

fix(output): valid JSON on stdout and numeric toObjectId parsing#56

Open
piekstra wants to merge 3 commits into
mainfrom
fix/json-output-stdout-and-associations
Open

fix(output): valid JSON on stdout and numeric toObjectId parsing#56
piekstra wants to merge 3 commits into
mainfrom
fix/json-output-stdout-and-associations

Conversation

@piekstra

Copy link
Copy Markdown

Closes #52
Closes #51

Fixes two related JSON-output-correctness bugs. They live in separate, otherwise-unrelated files (the output/view layer and the associations API model), but both are about producing valid JSON, so they are bundled on one branch/PR per the issue grouping. Both fixes are small.

Summary

#52--output json writes status messages to stdout (corrupts JSON)

Root cause: In internal/view/view.go, View.Success, Info, Print, and Println all wrote to v.Out (stdout). View.JSON also writes to stdout, so human-facing banners (✓ Note created...), count lines (Found 3 contact(s)), and pagination hints (More results available...) were interleaved with the JSON payload on the same stream — breaking jq, json.load(), and any machine-readable pipeline.

Fix: Route those four methods to v.Err (stderr), matching Error and Warning, which already write to stderr. Status messages still display on the terminal (stderr is shown by default), but stdout now carries only the structured payload. This is the standard Unix split: structured data on stdout, human text on stderr. Fixed at the helper level, so it applies repo-wide to every command using the View.

#51associations list JSON parse error when results exist

Root cause: The HubSpot CRM v4 associations API returns toObjectId as a JSON number (e.g. 98765), but api.Association.ToObjectID was typed as string. encoding/json cannot unmarshal a number into a Go string, so every non-empty response failed with json: cannot unmarshal number into Go struct field Association.results.toObjectId of type string. Empty result sets parsed fine, which made the bug deceptive.

Fix: Change the field type to json.Number (already-imported package). json.Number accepts both numeric and string JSON values and re-serializes a number as a number, so associations list -o json now produces valid JSON. Updated the one display call site in internal/cmd/associations/associations.go to use .String().

Also added a ### Fixed entry to CHANGELOG.md under [Unreleased].

Testing

All commands run from the repo root.

  • make build → success (binary at bin/hspt)
  • make test (go test -race -v ./...) → PASS, no failures across all packages
  • make lint (golangci-lint run, v2.12.2) → 0 issues
  • go vet ./... → clean

New tests:

  • internal/view/view_test.go
    • TestStatusMessagesGoToStderrSuccess/Info/Print/Println write to stderr, never stdout
    • TestStdoutIsValidJSON — with status messages emitted before/after JSON(), stdout alone is parseable JSON (json.Unmarshal succeeds; no banners/pagination text leak)
    • TestErrorAndWarningGoToStderr, TestJSONOutputUnaffectedByColorFlag
  • api/associations_test.go
    • Numeric toObjectId (98765) parses successfully (the regression case)
    • String toObjectId still parses (back-compat)
    • Empty results parse cleanly
    • Result re-marshals to valid JSON with the id as a number ("toObjectId":98765)
    • Empty from-ID returns an error

@piekstra piekstra marked this pull request as ready for review June 17, 2026 12:55
@piekstra piekstra requested a review from piekstra-dev June 17, 2026 12:55
Comment thread internal/view/view.go Outdated
Comment thread api/associations_test.go Outdated
Comment thread internal/view/view.go Outdated
@piekstra-dev

Copy link
Copy Markdown

Automated PR Review

Reviewed commit: c6c5bf5dcda7
Profile: reviewer - Posting as: piekstra-dev

Summary

Reviewer Findings
documentation:docs 0
go:implementation-tests 1
policies:conventions 1
go:implementation-tests (1 finding)

Major - api/associations_test.go:67

The test "string toObjectId also parses" and its accompanying comment claim that json.Number accepts both JSON number literals and JSON string values (e.g. "toObjectId": "55555" with quotes). This is incorrect. json.Number is treated by the Go JSON decoder as a number target: it only accepts an unquoted JSON number token. A quoted JSON string like "55555" will produce json: cannot unmarshal string into Go struct field Association.results.toObjectId of type json.Number — the same kind of error the change was introduced to fix, just in the opposite direction.

As written this test will fail when run against the real decoder, making the test suite broken, or (if the test somehow passes due to an unrelated detail in the HTTP client stack) it gives completely false confidence about backward compatibility with string-returning API shapes. Either way the invariant documented in the comment — that json.Number is a drop-in for both representations — is wrong and will mislead future maintainers.

Fix: Remove or correct the second subtest. If true backward compatibility with string toObjectId values is required, json.Number is not sufficient; you would need a custom UnmarshalJSON on Association (or a wrapper type) that accepts both token kinds. If no older string-returning shape actually exists in production, just delete the subtest and the comment claiming dual compatibility.

policies:conventions (1 finding)

Major - internal/view/view.go:140

Print and Println are generic names with no inherent "status" semantics. Silently redirecting them to v.Err can break any caller that relies on them to emit primary (non-status) output to stdout — for example, a caller that pipes v.Print output through to JSON consumers. The adjacent Success/Info rename is defensible because those names already imply transient status, but bare Print/Println do not. Fix: either rename these to PrintStatus/PrintlnStatus (or similar) so the stderr contract is visible at the call site, or audit all callers and confirm none use them for primary structured output before landing this change.

0 PR discussion threads considered. 0 summarized; 0 resolved.


Completed in 2m 36s | ~$0.32 (est.) | claude-sonnet-4-6 | cr 0.4.161
Field Value
Model claude-sonnet-4-6
Reviewers documentation:docs, go:implementation-tests, policies:conventions
Engine claude_cli · claude-sonnet-4-6
Reviewed by cr · piekstra-dev
Duration 2m 36s wall · 2m 35s compute
Cost ~$0.32 (est.)
Tokens 19 in / 5.0k out

Per-workstream usage

Workstream Model In Out Cache read Cache create Cost Duration
orchestrator-selection claude-sonnet-4-6 3 124 3.3k 4.8k ~$0.02 (est.) 29s
documentation:docs claude-sonnet-4-6 4 498 7.2k 11.9k ~$0.05 (est.) 12s
go:implementation-tests claude-sonnet-4-6 4 2.2k 7.7k 17.0k ~$0.10 (est.) 47s
policies:conventions claude-sonnet-4-6 4 1.7k 7.2k 12.3k ~$0.07 (est.) 37s
orchestrator-rollup claude-sonnet-4-6 4 463 19.9k 14.5k ~$0.07 (est.) 28s

@piekstra-dev piekstra-dev left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated PR review completed with outcome: comment.

@piekstra piekstra requested a review from piekstra-dev June 17, 2026 13:14
Comment thread internal/view/view.go Outdated
@piekstra-dev

Copy link
Copy Markdown

Automated PR Review

Reviewed commit: e82b49f22cd3
Profile: reviewer - Posting as: piekstra-dev

Summary

Reviewer Findings
documentation:docs 0
go:implementation-tests 0
policies:conventions 0

3 PR discussion threads considered. 3 summarized; 3 resolved.


Completed in 2m 57s | ~$0.38 (est.) | claude-sonnet-4-6 | cr 0.4.161
Field Value
Model claude-sonnet-4-6
Reviewers documentation:docs, go:implementation-tests, policies:conventions
Engine claude_cli · claude-sonnet-4-6
Reviewed by cr · piekstra-dev
Duration 2m 57s wall · 2m 48s compute
Cost ~$0.38 (est.)
Tokens 19 in / 4.4k out

Per-workstream usage

Workstream Model In Out Cache read Cache create Cost Duration
orchestrator-selection claude-sonnet-4-6 4 1.8k 22.9k 22.4k ~$0.12 (est.) 40s
documentation:docs claude-sonnet-4-6 4 257 7.2k 11.9k ~$0.05 (est.) 10s
go:implementation-tests claude-sonnet-4-6 3 124 3.3k 4.8k ~$0.02 (est.) 1m 03s
policies:conventions claude-sonnet-4-6 4 1.8k 7.7k 13.7k ~$0.08 (est.) 41s
orchestrator-rollup claude-sonnet-4-6 4 355 32.1k 25.8k ~$0.11 (est.) 13s

@piekstra-dev piekstra-dev left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated PR review completed with outcome: approved.

@piekstra piekstra requested a review from piekstra-dev June 17, 2026 13:40
@piekstra-dev

Copy link
Copy Markdown

Automated PR Review

Reviewed commit: ace8c03f8b2c
Profile: reviewer - Posting as: piekstra-dev

Summary

Reviewer Findings
documentation:docs 0
go:implementation-tests 0
policies:conventions 0

1 PR discussion threads considered. 1 summarized; 1 resolved.


Completed in 2m 34s | ~$0.42 (est.) | claude-sonnet-4-6 | cr 0.4.161
Field Value
Model claude-sonnet-4-6
Reviewers documentation:docs, go:implementation-tests, policies:conventions
Engine claude_cli · claude-sonnet-4-6
Reviewed by cr · piekstra-dev
Duration 2m 34s wall · 2m 33s compute
Cost ~$0.42 (est.)
Tokens 21 in / 7.4k out

Per-workstream usage

Workstream Model In Out Cache read Cache create Cost Duration
orchestrator-selection claude-sonnet-4-6 5 2.6k 25.7k 21.9k ~$0.13 (est.) 48s
documentation:docs claude-sonnet-4-6 4 259 7.2k 11.9k ~$0.05 (est.) 9s
go:implementation-tests claude-sonnet-4-6 4 2.6k 7.2k 18.7k ~$0.11 (est.) 53s
policies:conventions claude-sonnet-4-6 4 1.6k 7.2k 16.0k ~$0.09 (est.) 33s
orchestrator-rollup claude-sonnet-4-6 4 279 7.7k 10.8k ~$0.05 (est.) 8s

@piekstra-dev piekstra-dev left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated PR review completed with outcome: approved.

Comment thread internal/view/view.go
Comment thread api/associations_test.go
Comment thread internal/view/view.go
Comment thread internal/view/view.go
@piekstra-dev

Copy link
Copy Markdown

Automated PR Review

Reviewed commit: ace8c03f8b2c
Profile: reviewer - Posting as: piekstra-dev

Summary

Reviewer Findings
go:implementation-tests 1
policies:conventions 0
structure:repo-health 2
documentation:docs 0
go:implementation-tests (1 finding)

Minor - api/associations_test.go:103

The "empty from ID returns error" subtest constructs a Client with BaseURL: "https://api.hubapi.com" — a real production URL — rather than an empty string or a no-op httptest.NewServer. The test currently passes because ListAssociations validates the ID before making any HTTP call, but that dependency is implicit and fragile: if the validation is ever reordered or moved into middleware, the test will make a live network call in CI. Every other subtest in this file uses httptest.NewServer for isolation. For the error path, either use BaseURL: "" (no request is ever issued) or a httptest.NewServer that returns a 500 and assert the error is still the validation error — whichever the surrounding test style prefers.

structure:repo-health (2 findings)

Major - internal/view/view.go:126

Print and Println are fully removed and replaced by PrintStatus / PrintlnStatus, but the diff only shows call-site updates in configcmd.go and forms.go. If any other command files call v.Print(...) or v.Println(...), the build is silently broken — the rename is invisible to the type system and grep is the only guard. Before merging, verify with grep -r '\.Print(' internal/cmd that no callers of the old names remain. If other callers exist they should appear in this diff.

Minor - internal/view/view.go:122

Info previously wrote to stdout; it now silently writes to stderr. Unlike PrintPrintStatus and PrintlnPrintlnStatus, the rename gives no compile-time signal to callers that the stream changed. Any script or test that captures stdout and expects Info output (e.g., a shell pipeline around a non-JSON command) will lose that output without error. The new view_test.go covers the new contract, but existing integration or golden-output tests that relied on Info appearing on stdout would pass a wrong assertion. Consider whether any such tests exist and need updating.

0 PR discussion threads considered. 0 summarized; 0 resolved.


Completed in 4m 34s | ~$0.57 (est.) | claude-sonnet-4-6 | cr 0.4.161
Field Value
Model claude-sonnet-4-6
Reviewers go:implementation-tests, policies:conventions, structure:repo-health, documentation:docs
Engine claude_cli · claude-sonnet-4-6
Reviewed by cr · piekstra-dev
Duration 4m 34s wall · 4m 32s compute
Cost ~$0.57 (est.)
Tokens 25 in / 12.6k out

Per-workstream usage

Workstream Model In Out Cache read Cache create Cost Duration
orchestrator-selection claude-sonnet-4-6 4 2.9k 7.2k 19.8k ~$0.12 (est.) 52s
go:implementation-tests claude-sonnet-4-6 4 4.0k 7.2k 18.7k ~$0.13 (est.) 1m 20s
policies:conventions claude-sonnet-4-6 5 2.5k 22.2k 18.4k ~$0.11 (est.) 54s
structure:repo-health claude-sonnet-4-6 4 2.5k 7.2k 16.7k ~$0.10 (est.) 50s
documentation:docs claude-sonnet-4-6 4 257 7.2k 11.9k ~$0.05 (est.) 8s
orchestrator-rollup claude-sonnet-4-6 4 447 15.7k 10.6k ~$0.05 (est.) 26s

@piekstra-dev piekstra-dev left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated PR review completed with outcome: comment.

piekstra added 3 commits June 17, 2026 12:30
…bjectId

Two related JSON-correctness bugs:

- #52: View.Success/Info/Print/Println wrote human-facing status and
  progress messages to stdout (v.Out), interleaving them with the JSON
  payload and breaking jq / json.load() / any machine-readable pipeline.
  Route all four to stderr (v.Err), matching Error and Warning which
  already do. stdout now carries only structured data.

- #51: api.Association.ToObjectID was typed as string, but the HubSpot
  CRM v4 associations API returns toObjectId as a JSON number, so every
  non-empty `associations list` response failed with "cannot unmarshal
  number into Go struct field ... of type string". Change the field to
  json.Number (accepts numbers and strings) and use .String() at the
  display call site.

Add tests: view_test.go asserts status messages stay on stderr and that
stdout alone is valid parseable JSON; associations_test.go asserts numeric
and string toObjectId both parse and re-serialize to valid JSON.

Closes #52
Closes #51
…x associations test

Addresses code-review feedback on the --output json (#52) and associations
toObjectId (#51) fixes.

- view: Print/Println were generic names routed to stderr, hiding the
  stderr contract at call sites. They are used only for status/progress
  text (audit found zero primary-output callers), so rename them to
  PrintStatus/PrintlnStatus and update the only callers (configcmd, forms).
  Condense the duplicated multi-line stderr-rationale comments to one line
  per method and move the prose once to the View struct doc.
- view test: add TestPrimaryOutputGoesToStdout asserting primary rendered
  output (Table/Plain/Render) lands on stdout while status goes to stderr,
  complementing the existing JSON-on-stdout assertion.
- associations: remove the misleading 'string toObjectId also parses'
  subtest and the 'json.Number accepts both' comment. json.Number only
  accepts a quoted string when its contents are a valid number; HubSpot's
  v4 API returns toObjectId as an integer, so keep just the numeric
  regression test (the real #51 fix).
@piekstra piekstra force-pushed the fix/json-output-stdout-and-associations branch from ace8c03 to dbf1c43 Compare June 17, 2026 16:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants