Skip to content

fix(cli): write JSON to stdout, always as an array - #73

Merged
KARTIKrocks merged 3 commits into
mainfrom
fix/json-to-stdout
Sep 24, 2026
Merged

KARTIKrocks merged 3 commits into
mainfrom
fix/json-to-stdout

Conversation

@KARTIKrocks

Copy link
Copy Markdown
Owner

Summary

sqlguard scan --format json > findings.json produced an empty file. The JSON reporter was built with NewJSONReporter(), which targets stderr, so the one thing a machine-readable format exists for did not work — and piping into jq saw nothing either. explain --format json had the same wiring.

Reproducing it turned up a second defect in the same user story: a clean run emitted nothing at all, so even with the stream fixed a consumer got an empty file rather than a parseable empty array.

# before
$ sqlguard scan --format json ./... > out.json   # stdout 0B, stderr 902B
$ sqlguard scan --format json ./clean > out.json # stdout 0B — not even "[]"

# after
$ sqlguard scan --format json ./... > out.json   # stdout 902B, stderr 0B, exit 1
$ sqlguard scan --format json ./clean > out.json # stdout "[]",  exit 0

Closes #70

Type of change

  • Bug fix
  • New detection rule
  • New integration / parser
  • Feature / enhancement
  • Docs only
  • Refactor / chore

Checklist

  • make ci passes (fmt-check, vet, lint, vuln, test-race, lint-docs) across all modules
  • Added/updated tests (and, where practical, a failure-mode check)
  • Updated docs under website/docs/ with a version marker for anything new (_0.3+_, _Added in 0.3._, // 0.3+) — never website/versioned_docs/
  • Updated AGENTS.md / .sqlguard.example.yml if a convention or config key changed
  • Added an entry under ## [Unreleased] in CHANGELOG.md
  • No new third-party deps in analyzer / middleware / reporter
  • Findings stay redaction-safe (no raw literals leak into a Result)

AGENTS.md unticked deliberately: no convention or config key changed. The library reporter defaults are unchanged (see below), so nothing AGENTS.md documents moved.

The design decision worth reviewing

Only the CLI changed. The library defaults are deliberately left alone.

reporter.NewJSONReporter() and NewConsoleReporter() still target stderr, because a reporter wired into middleware runs inside someone else's process — writing findings to that application's stdout would corrupt its output. So the split is product-vs-diagnostic rather than a blanket rule:

stdout stderr
CLI --format json the JSON array (nothing)
CLI --format console — findings + summary
library reporters — findings

If you'd rather the library default move too, say so — it's a one-line change, but I think it's wrong for the middleware case.

Notes for reviewers

Docs were documenting the bug. website/docs/scan.md said "Findings go to stderr, so 2> redirection captures them" as though it were intended. That paragraph is rewritten to describe the split by audience, with a _Changed in 0.3._ marker and the 0.2 behaviour. I also added a CI recipe that uploads sqlguard.json as a build artifact, since that is the workflow the format exists for.

Three follow-ups from review are in the second commit:

  1. explain accepted any --format value and fell back to console — so --format jsonn > plan.json dialled the database, rendered to stderr and left an empty file, re-creating this very bug for a typo. It now shares scan's newReporter and validates before connecting, so a mistake costs a message rather than a 30-second dial.
  2. Now that stdout carries the product, a failed write matters: an encode error only reached a warning on stderr, so a truncated array on a full disk exited 0 and the CI recipe above would upload a corrupt artifact as green. reporter.Reporter cannot return an error, so the CLI wraps stdout in a writer that records the first failure and turns it into a non-zero exit. I did not change the Reporter interface — that ripples through middleware and all six integrations and deserves its own decision.
  3. The stream-capture test helper read both pipes only after runScan returned, which deadlocks once either stream exceeds the 64 KiB pipe buffer. It now drains concurrently and closes the read ends.

Every test was checked against its own regression — each fails when the corresponding fix is reverted:

test reverting fails with
TestScan_JSONGoesToStdout NewJSONReporterTo(os.Stdout) → NewJSONReporter() findings on stderr, stdout unparseable
TestScan_JSONEmptyArrayWhenClean reporting only when findings exist "" instead of []
TestScan_JSONLargeOutputDoesNotDeadlock sequential pipe reads hangs to the test timeout

The 400-finding fixture asserts it still exceeds 64 KiB, so that guard cannot quietly stop testing anything.

Verified: make ci green across all nine modules (lint-docs fails only on an untracked local scratch file outside this branch); the Docusaurus build passes, so the new scan#usage cross-link resolves under onBrokenLinks: 'throw'.

`sqlguard scan --format json > findings.json` produced an empty file: the JSON
reporter was constructed with NewJSONReporter(), which targets stderr, so the
one thing a machine-readable format is for did not work. Piping into jq saw
nothing either. `explain --format json` had the same wiring.

Both commands now build the reporter with NewJSONReporterTo(os.Stdout). The
console format and the progress/summary lines stay on stderr, so stdout is
pure JSON and `2>` still captures the human-readable report — the usual split
between a program's product and its diagnostics.

A clean run also used to emit nothing at all, handing a consumer an empty file
rather than a parseable empty array. JSON now reports unconditionally and
prints `[]` when there are no findings; the exit code is unchanged.

The library defaults are deliberately left alone. NewJSONReporter() and
NewConsoleReporter() still target stderr, which is right for a reporter wired
into middleware inside someone else's process — writing findings to that
application's stdout would corrupt its output.
Three follow-ups from review of the stdout change.

explain accepted any --format value and fell back to console, so
`explain --format jsonn … > plan.json` dialed the database, rendered to stderr
and left an empty file — the same failure this branch fixes for the correctly
spelled flag. It now shares scan's newReporter, which rejects unknown formats,
and validates before connecting so a typo costs a message rather than a dial.

Now that stdout carries the product, a failed write matters: an encode error
only reached a warning on stderr, so a truncated array on a full disk exited 0
and the documented CI recipe would upload a corrupt artifact as green.
reporter.Reporter cannot return an error, so the CLI wraps stdout in a writer
that records the first failure and turns it into a non-zero exit.

The stream-capture test helper read both pipes only after runScan returned,
which deadlocks once either stream exceeds the 64 KiB pipe buffer. It now
drains concurrently and closes the read ends; a 400-finding fixture covers the
case, and asserts the fixture still exceeds the buffer so the guard cannot
quietly stop testing anything.
@coderabbitai

coderabbitai Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Repository: KARTIKrocks/sqlguard/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: a877bc41-f705-4f11-8f09-c5472478b3c9


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge; the remaining explain success-path coverage gap is non-blocking.

Findings

  1. P2 Explain output lacks coverage ▶
Fix with agent prompt
### Issue 1
cmd/sqlguard/explain_test.go:43-45
Every accepted-format case stops at a connection failure, before explain reports anything. These tests cannot catch a regression in JSON stdout routing, clean `[]` output, or the findings exit status. Add a successful-plan test that reaches the JSON reporting path.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

The PR sends CLI JSON reports to stdout and emits [] for clean runs, while keeping console output on stderr.

  • It validates explain formats before connecting and makes JSON write failures return an error.
  • It adds stream, output-policy, and write-error tests and updates the CLI documentation and changelog.

Reviews (2) · Last reviewed commit: "refactor(cli): scope the write-error to ..."

Comment thread cmd/sqlguard/scan.go Outdated
Comment thread cmd/sqlguard/scan_test.go
Comment on lines +43 to +45
for _, f := range []string{"console", "json", ""} {
explainFormat = f
err := runExplain(&cobra.Command{}, []string{"SELECT 1"})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Explain output lacks coverage Every accepted-format case stops at a connection failure, before explain reports anything. These tests cannot catch a regression in JSON stdout routing, clean [] output, or the findings exit status. Add a successful-plan test that reaches the JSON reporting path.

Prompt To Fix With AI
This is a comment left during a code review.
Path: cmd/sqlguard/explain_test.go
Line: 43-45

Comment:
**Explain output lacks coverage** Every accepted-format case stops at a connection failure, before explain reports anything. These tests cannot catch a regression in JSON stdout routing, clean `[]` output, or the findings exit status. Add a successful-plan test that reaches the JSON reporting path.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

…d policy

Three follow-ups from review.

The checked writer lived in a package-level var, so two invocations in one
process could read each other's write result. newReporter now returns the
write-error check alongside the reporter, and nothing is shared between calls.

scan and explain carried the same reporting branch twice. It moves into one
`report` helper — JSON always reports so a redirect gets an array, console
stays quiet unless there is something to render, findings mean errIssuesFound
either way, and a failed write outranks both. The callers keep only their own
summary wording.

The clean-run assertion could not fail: `null` unmarshals into a slice without
error and leaves it nil with length 0, so a reporter that switched to a nil
slice would emit `null` and still satisfy a len()-only check. It now asserts
the literal `[]` and a non-nil decode — verified by making reporter/json.go
emit `null`, which the old assertion passed and the new one catches.

explain's reporting path cannot be reached without a live database, so the
policy is pinned through the shared helper instead, plus a direct test that
the JSON reporter targets stdout and one that two reporters keep their write
results apart.
@KARTIKrocks
KARTIKrocks merged commit 7957059 into main Sep 24, 2026
29 checks passed
@KARTIKrocks
KARTIKrocks deleted the fix/json-to-stdout branch September 24, 2026 16:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JSON output goes to stderr, so --format json > out.json writes an empty file

1 participant