--- phase: 06-output-reporting plan: 06 type: execute wave: 2 depends_on: [06-01, 06-02, 06-03] files_modified: - cmd/scan.go - cmd/scan_output_test.go autonomous: true requirements: [OUT-05, OUT-06] must_haves: truths: - "scan --output accepts table, json, sarif, csv and dispatches to the registered formatter" - "invalid --output values fail with a clear error listing valid formats" - "scan exit code is 0 on clean scans, 1 on findings, 2 on scan errors" - "key masking is the default; --unmask propagates through opts.Unmask to all formatters" artifacts: - path: cmd/scan.go provides: "Refactored output dispatch via output.Get + exit-code handling" contains: "output.Get(flagOutput" key_links: - from: cmd/scan.go to: pkg/output/formatter.go via: "output.Get(flagOutput).Format(findings, os.Stdout, Options{Unmask, ToolName, ToolVersion})" pattern: "output\\.Get\\(flagOutput" --- Wire the scan command to the formatter registry established in Plans 01-03. Replace the inline json/table switch with `output.Get(name)` and finalize exit-code semantics (0/1/2). Purpose: Surfaces all four output formats through `scan --output=` and enforces OUT-06 exit-code contract for CI/CD consumers. Output: Updated `cmd/scan.go`, a small test file covering format dispatch and exit codes. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/phases/06-output-reporting/06-CONTEXT.md @.planning/phases/06-output-reporting/06-01-PLAN.md @cmd/scan.go @cmd/root.go From Plans 06-01..03: ```go output.Get(name string) (Formatter, error) // returns ErrUnknownFormat wrapped output.Names() []string output.Options{ Unmask, ToolName, ToolVersion } ``` Current scan.go ends with: ```go switch flagOutput { case "json": // inline jsonFinding encoder default: output.PrintFindings(findings, flagUnmask) } if len(findings) > 0 { os.Exit(1) } return nil ``` Task 1: Replace scan output switch with formatter registry and finalize exit codes cmd/scan.go - cmd/scan.go (current dispatch) - pkg/output/formatter.go (Get, Options, Names) - cmd/root.go (for version constant — if none exists, use "dev") 1. Remove the inline `jsonFinding` struct and the `switch flagOutput` block. 2. Replace with: ```go // Output via the formatter registry (OUT-01..04). formatter, err := output.Get(flagOutput) if err != nil { return fmt.Errorf("%w (valid: %s)", err, strings.Join(output.Names(), ", ")) } if err := formatter.Format(findings, os.Stdout, output.Options{ Unmask: flagUnmask, ToolName: "keyhunter", ToolVersion: versionString(), // see step 4 }); err != nil { return fmt.Errorf("rendering %s output: %w", flagOutput, err) } // OUT-06 exit codes: 0=clean, 1=findings, 2=error (errors returned via RunE -> root.Execute). if len(findings) > 0 { os.Exit(1) } return nil ``` Add `"strings"` import if missing. 3. Update the --output flag help text: ```go scanCmd.Flags().StringVar(&flagOutput, "output", "table", "output format: table, json, sarif, csv") ``` 4. Version helper: if cmd/root.go doesn't already export a version constant, add (in cmd/scan.go or a new cmd/version.go): ```go // versionString returns the compiled tool version. Set via -ldflags "-X github.com/salvacybersec/keyhunter/cmd.version=...". var version = "dev" func versionString() string { return version } ``` If a version constant already exists elsewhere, reuse it instead of adding a new one. 5. Confirm the rootCmd.Execute() path in cmd/root.go already handles errors by `os.Exit(1)` on non-nil. For the OUT-06 "exit 2 on error" requirement, update cmd/root.go Execute(): ```go func Execute() { if err := rootCmd.Execute(); err != nil { // cobra already prints the error message. Exit 2 signals scan/tool error // per OUT-06. (Exit 1 is reserved for "findings present".) os.Exit(2) } } ``` This is a one-line change from `os.Exit(1)` to `os.Exit(2)`. Any subcommand returning an error will now exit 2, which matches the CI contract (findings=1, error=2, clean=0). 6. Verify the old inline jsonFinding struct (and its imports — check if "encoding/json" is still used anywhere in scan.go; if not, remove the import). cd /home/salva/Documents/apikey && go build ./... && go vet ./cmd/... - `go build ./...` succeeds - `grep -q "output\\.Get(flagOutput)" cmd/scan.go` - `grep -q "output\\.Options{" cmd/scan.go` - `grep -vq "jsonFinding" cmd/scan.go` (inline struct removed) - `grep -q "os\\.Exit(2)" cmd/root.go` - Help text lists all four formats Task 2: Tests for unknown format error and exit-code contract cmd/scan_output_test.go - cmd/scan.go (updated dispatch) - pkg/output/formatter.go - Test 1: TestScanOutput_UnknownFormat — setting flagOutput="bogus" and invoking a minimal code path that calls output.Get(flagOutput) returns an error whose message contains "unknown format" and lists valid names. - Test 2: TestScanOutput_FormatNamesIncludeAll — output.Names() returns a slice containing "table", "json", "csv", "sarif". - Rather than invoking the whole RunE (which requires a real scan target), isolate the dispatch logic into a small helper `renderScanOutput(findings, name string, unmask bool, w io.Writer) error` inside cmd/scan.go. Update Task 1 if not already done so the helper exists. Test exercises the helper directly. - Test 3: TestRenderScanOutput_JSONSucceeds — passes an empty findings slice with name="json"; asserts output is valid JSON array `[]`. - Test 4: TestRenderScanOutput_UnknownReturnsError — name="bogus"; asserts errors.Is(err, output.ErrUnknownFormat). 1. If not done in Task 1, add `renderScanOutput(findings []engine.Finding, name string, unmask bool, w io.Writer) error` to cmd/scan.go and use it from the RunE. 2. Create cmd/scan_output_test.go: ```go package cmd import ( "bytes" "encoding/json" "errors" "strings" "testing" "github.com/salvacybersec/keyhunter/pkg/engine" "github.com/salvacybersec/keyhunter/pkg/output" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestScanOutput_FormatNamesIncludeAll(t *testing.T) { names := output.Names() for _, want := range []string{"table", "json", "csv", "sarif"} { assert.Contains(t, names, want) } } func TestRenderScanOutput_UnknownReturnsError(t *testing.T) { err := renderScanOutput(nil, "bogus", false, &bytes.Buffer{}) require.Error(t, err) assert.True(t, errors.Is(err, output.ErrUnknownFormat)) assert.Contains(t, err.Error(), "valid:") } func TestRenderScanOutput_JSONSucceeds(t *testing.T) { var buf bytes.Buffer err := renderScanOutput([]engine.Finding{}, "json", false, &buf) require.NoError(t, err) var out []any require.NoError(t, json.Unmarshal(buf.Bytes(), &out)) assert.Len(t, out, 0) } func TestRenderScanOutput_TableEmpty(t *testing.T) { var buf bytes.Buffer err := renderScanOutput(nil, "table", false, &buf) require.NoError(t, err) assert.True(t, strings.Contains(buf.String(), "No API keys found")) } ``` cd /home/salva/Documents/apikey && go test ./cmd/... -run "TestScanOutput|TestRenderScanOutput" -count=1 - All four tests pass - `grep -q "func renderScanOutput" cmd/scan.go` - errors.Is(err, output.ErrUnknownFormat) works from cmd package - `go build ./...` succeeds - `go test ./... -count=1` all green - Manual smoke: `go run . scan --output=bogus /tmp` prints "unknown format" and exits 2 - Manual smoke: `go run . scan --output=sarif testdata/` prints SARIF JSON - scan --output dispatches to the formatter registry for all four formats - Unknown format error lists valid names - Exit codes: clean=0, findings=1, error=2 - OUT-05 masking default respected via flagUnmask -> Options.Unmask After completion, create `.planning/phases/06-output-reporting/06-06-SUMMARY.md`.