--- phase: 06-output-reporting plan: 06 subsystem: cli tags: [cobra, cli, output-dispatch, exit-codes, sarif, csv, json, ci-cd] requires: - phase: 06-output-reporting provides: "formatter registry (table/json/csv/sarif), output.Options, ErrUnknownFormat" provides: - "scan --output=table|json|sarif|csv wired through output.Get()" - "renderScanOutput helper for hermetic unit testing" - "OUT-06 exit-code contract: 0 clean, 1 findings, 2 tool error" - "version var in cmd package (ldflags-settable) feeding SARIF tool.driver.version" affects: [07-import-hooks, 15-ci-cd-integration] tech-stack: added: [] patterns: - "Extract RunE output dispatch into a small helper (renderScanOutput) so tests exercise the dispatch path without booting scanner/DB" - "Error wrapping with fmt.Errorf(\"%w (valid: %s)\", ErrUnknownFormat, ...) keeps errors.Is checks intact while surfacing user guidance" - "Root Execute differentiates exit 1 (findings, set inline in scan RunE) from exit 2 (any non-nil RunE error)" key-files: created: - cmd/scan_output_test.go - .planning/phases/06-output-reporting/06-06-SUMMARY.md modified: - cmd/scan.go - cmd/root.go key-decisions: - "Exit 1 is emitted directly inside scan RunE via os.Exit(1) before RunE returns — this preserves the 1-vs-2 split without plumbing a sentinel error" - "version is declared as a package-level var (default \"dev\") so goreleaser can override via -ldflags -X; SARIF tool.driver.version uses it" - "renderScanOutput takes io.Writer (not os.Stdout) so tests can capture output without redirecting file descriptors" - "Unknown-format error wraps output.ErrUnknownFormat AND appends the valid list in the same string — one error, both machine-readable and human-readable" requirements-completed: [OUT-05, OUT-06] duration: 3min completed: 2026-04-05 --- # Phase 06 Plan 06: Scan Output Wiring and Exit Codes Summary **Scan command now dispatches --output=table|json|sarif|csv through the formatter registry and honors the OUT-06 exit-code contract (0 clean, 1 findings, 2 error) for CI/CD consumers.** ## Performance - **Duration:** ~3 min - **Tasks:** 2 - **Files modified:** 2 (cmd/scan.go, cmd/root.go) - **Files created:** 1 (cmd/scan_output_test.go) ## Accomplishments - Replaced inline `jsonFinding` switch in scan RunE with a single call to `output.Get(flagOutput)` → `Formatter.Format(...)`, unlocking SARIF and CSV for scan output. - Extracted the dispatch into `renderScanOutput(findings, name, unmask, w io.Writer)` so the logic can be unit-tested without booting the full scanner/DB/consent pipeline. - Implemented OUT-06 exit-code contract: - `0`: scan returns cleanly, no findings - `1`: `os.Exit(1)` at the tail of scan RunE when `len(findings) > 0` - `2`: root `Execute()` now maps any non-nil `rootCmd.Execute()` error to `os.Exit(2)` (was 1) - Added `version` package var + `versionString()` helper so SARIF `tool.driver.version` is populated and overridable via `-ldflags "-X github.com/salvacybersec/keyhunter/cmd.version=..."`. - Updated `--output` help text to list all four formats. - Four-test coverage in `cmd/scan_output_test.go`: 1. `TestScanOutput_FormatNamesIncludeAll` — registry wiring guard 2. `TestRenderScanOutput_UnknownReturnsError` — `errors.Is(err, output.ErrUnknownFormat)` + "valid:" substring 3. `TestRenderScanOutput_JSONSucceeds` — valid `[]` JSON for empty findings 4. `TestRenderScanOutput_TableEmpty` — "No API keys found" sentinel ## Verification - `go build ./...` — clean - `go vet ./cmd/...` — clean - `go test ./... -count=1` — all packages green (cmd, engine, engine/sources, legal, output, providers, storage, verify) - `go test ./cmd/... -run "TestScanOutput|TestRenderScanOutput" -count=1` — 4/4 pass ## Commits | Task | Type | Hash | Message | | ---- | ---- | ---- | ------- | | 1 | feat | c9114e4 | wire scan --output to formatter registry and exit-code contract | | 2 | test | cdf3c8a | cover scan output dispatch and unknown-format error | ## Deviations from Plan None — plan executed exactly as written. The `renderScanOutput` helper called out as optional in Task 2 was added during Task 1 (the plan explicitly permitted this consolidation in Task 2 step 1). ## Files Touched **Modified:** - `cmd/scan.go` — removed inline `jsonFinding` struct + `encoding/json` import; added `io`/`strings` imports; replaced output switch with `renderScanOutput` helper; introduced `version` var + `versionString()`; updated flag help text. - `cmd/root.go` — `Execute()` now exits with code 2 (was 1) on non-nil `rootCmd.Execute()` error, with a comment documenting the OUT-06 exit-code contract. **Created:** - `cmd/scan_output_test.go` — four tests covering registration, unknown-format error, JSON dispatch, table dispatch. ## Known Stubs None. All four registered formatters (table, json, csv, sarif) are fully wired and exercised by tests. ## Self-Check: PASSED - cmd/scan.go contains `output.Get(name)` — verified (in renderScanOutput) - cmd/scan.go contains `output.Options{` — verified - cmd/scan.go no longer contains `jsonFinding` — verified - cmd/root.go contains `os.Exit(2)` — verified - cmd/scan_output_test.go exists with four tests — verified - Task 1 commit c9114e4 present in git log — verified - Task 2 commit cdf3c8a present in git log — verified