---
phase: 06-output-reporting
plan: 02
type: execute
wave: 1
depends_on: [06-01]
files_modified:
  - pkg/output/json.go
  - pkg/output/csv.go
  - pkg/output/json_test.go
  - pkg/output/csv_test.go
autonomous: true
requirements: [OUT-02, OUT-04]

must_haves:
  truths:
    - "scan results can be rendered as well-formed JSON (one array of finding objects)"
    - "scan results can be rendered as CSV with a stable header row"
    - "Both formatters honor the Unmask option for KeyValue exposure"
    - "Both formatters are registered in output.Registry under 'json' and 'csv'"
  artifacts:
    - path: pkg/output/json.go
      provides: "JSONFormatter implementing Formatter"
      contains: "type JSONFormatter struct"
    - path: pkg/output/csv.go
      provides: "CSVFormatter implementing Formatter"
      contains: "type CSVFormatter struct"
  key_links:
    - from: pkg/output/json.go
      to: pkg/output/formatter.go
      via: "init() Register(\"json\", JSONFormatter{})"
      pattern: "Register\\(\"json\""
    - from: pkg/output/csv.go
      to: pkg/output/formatter.go
      via: "init() Register(\"csv\", CSVFormatter{})"
      pattern: "Register\\(\"csv\""
---

<objective>
Implement JSONFormatter (full Finding serialization) and CSVFormatter (header row + flat rows) so `keyhunter scan --output=json` and `--output=csv` work end to end after Plan 06 wires the scan command.

Purpose: Machine-readable outputs for pipelines and spreadsheets. Addresses OUT-02 and OUT-04.
Output: `pkg/output/json.go`, `pkg/output/csv.go`, tests.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/phases/06-output-reporting/06-CONTEXT.md
@.planning/phases/06-output-reporting/06-01-PLAN.md
@pkg/engine/finding.go

<interfaces>
From Plan 06-01 (pkg/output/formatter.go):
```go
type Formatter interface {
    Format(findings []engine.Finding, w io.Writer, opts Options) error
}
type Options struct {
    Unmask      bool
    ToolName    string
    ToolVersion string
}
func Register(name string, f Formatter)
```
From pkg/engine/finding.go: full Finding struct with Verify* fields.
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: JSONFormatter with full finding fields</name>
  <files>pkg/output/json.go, pkg/output/json_test.go</files>
  <read_first>
    - pkg/output/formatter.go (Formatter interface, Options)
    - pkg/engine/finding.go
  </read_first>
  <behavior>
    - Output is a JSON array: `[{...}, {...}]` with 2-space indent.
    - Each element includes: provider, key (full when Unmask, masked when not), key_masked, confidence, source, source_type, line, offset, detected_at (RFC3339), verified, verify_status, verify_http_code, verify_metadata, verify_error.
    - Empty findings slice -> `[]\n`.
    - Uses encoding/json Encoder with SetIndent("", "  ").
    - Tests: (a) empty slice -> "[]\n"; (b) one finding round-trips through json.Unmarshal with key==KeyMasked when Unmask=false; (c) Unmask=true sets key==KeyValue; (d) verify fields present when Verified=true.
  </behavior>
  <action>
    Create pkg/output/json.go:

    ```go
    package output

    import (
        "encoding/json"
        "io"
        "time"

        "github.com/salvacybersec/keyhunter/pkg/engine"
    )

    func init() {
        Register("json", JSONFormatter{})
    }

    // JSONFormatter renders findings as a JSON array with 2-space indent.
    type JSONFormatter struct{}

    type jsonFinding struct {
        Provider       string            `json:"provider"`
        Key            string            `json:"key"`
        KeyMasked      string            `json:"key_masked"`
        Confidence     string            `json:"confidence"`
        Source         string            `json:"source"`
        SourceType     string            `json:"source_type"`
        Line           int               `json:"line"`
        Offset         int64             `json:"offset"`
        DetectedAt     string            `json:"detected_at"`
        Verified       bool              `json:"verified"`
        VerifyStatus   string            `json:"verify_status,omitempty"`
        VerifyHTTPCode int               `json:"verify_http_code,omitempty"`
        VerifyMetadata map[string]string `json:"verify_metadata,omitempty"`
        VerifyError    string            `json:"verify_error,omitempty"`
    }

    func (JSONFormatter) Format(findings []engine.Finding, w io.Writer, opts Options) error {
        out := make([]jsonFinding, 0, len(findings))
        for _, f := range findings {
            key := f.KeyMasked
            if opts.Unmask {
                key = f.KeyValue
            }
            out = append(out, jsonFinding{
                Provider:       f.ProviderName,
                Key:            key,
                KeyMasked:      f.KeyMasked,
                Confidence:     f.Confidence,
                Source:         f.Source,
                SourceType:     f.SourceType,
                Line:           f.LineNumber,
                Offset:         f.Offset,
                DetectedAt:     f.DetectedAt.Format(time.RFC3339),
                Verified:       f.Verified,
                VerifyStatus:   f.VerifyStatus,
                VerifyHTTPCode: f.VerifyHTTPCode,
                VerifyMetadata: f.VerifyMetadata,
                VerifyError:    f.VerifyError,
            })
        }
        enc := json.NewEncoder(w)
        enc.SetIndent("", "  ")
        return enc.Encode(out)
    }
    ```

    Create pkg/output/json_test.go with tests for empty, masked, unmask, verify fields. Use json.Unmarshal to assert field values.
  </action>
  <verify>
    <automated>cd /home/salva/Documents/apikey && go test ./pkg/output/... -run "TestJSONFormatter" -count=1</automated>
  </verify>
  <done>
    - TestJSONFormatter_* all pass
    - `grep -q "Register(\"json\"" pkg/output/json.go`
    - `go build ./...` succeeds
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: CSVFormatter with stable header row</name>
  <files>pkg/output/csv.go, pkg/output/csv_test.go</files>
  <read_first>
    - pkg/output/formatter.go
    - pkg/engine/finding.go
  </read_first>
  <behavior>
    - Header: id,provider,confidence,key,source,line,detected_at,verified,verify_status
    - id is the zero-based index within the findings slice (scan-time id; DB id not available here).
    - key column renders KeyMasked when Unmask=false, KeyValue when Unmask=true.
    - verified column is "true"/"false".
    - Uses encoding/csv Writer; flushes on return.
    - Empty findings still writes header row only.
    - Tests: header presence; masked vs unmask; CSV quoting of comma in Source; verify_status column populated.
  </behavior>
  <action>
    Create pkg/output/csv.go:

    ```go
    package output

    import (
        "encoding/csv"
        "io"
        "strconv"
        "time"

        "github.com/salvacybersec/keyhunter/pkg/engine"
    )

    func init() {
        Register("csv", CSVFormatter{})
    }

    // CSVFormatter renders findings as comma-separated values with a fixed header row.
    type CSVFormatter struct{}

    var csvHeader = []string{
        "id", "provider", "confidence", "key", "source",
        "line", "detected_at", "verified", "verify_status",
    }

    func (CSVFormatter) Format(findings []engine.Finding, w io.Writer, opts Options) error {
        cw := csv.NewWriter(w)
        if err := cw.Write(csvHeader); err != nil {
            return err
        }
        for i, f := range findings {
            key := f.KeyMasked
            if opts.Unmask {
                key = f.KeyValue
            }
            row := []string{
                strconv.Itoa(i),
                f.ProviderName,
                f.Confidence,
                key,
                f.Source,
                strconv.Itoa(f.LineNumber),
                f.DetectedAt.Format(time.RFC3339),
                strconv.FormatBool(f.Verified),
                f.VerifyStatus,
            }
            if err := cw.Write(row); err != nil {
                return err
            }
        }
        cw.Flush()
        return cw.Error()
    }
    ```

    Create pkg/output/csv_test.go:
    - TestCSVFormatter_HeaderOnly: empty findings -> single header line.
    - TestCSVFormatter_Row: one finding, parse with csv.NewReader, assert fields.
    - TestCSVFormatter_QuotesCommaInSource: Source="a, b.txt" round-trips via csv reader.
    - TestCSVFormatter_Unmask: Unmask=true puts KeyValue into key column.
  </action>
  <verify>
    <automated>cd /home/salva/Documents/apikey && go test ./pkg/output/... -run "TestCSVFormatter" -count=1</automated>
  </verify>
  <done>
    - TestCSVFormatter_* all pass
    - `grep -q "Register(\"csv\"" pkg/output/csv.go`
    - Header exactly matches: `grep -q 'id", "provider", "confidence", "key", "source"' pkg/output/csv.go`
    - `go build ./...` succeeds
  </done>
</task>

</tasks>

<verification>
- `go test ./pkg/output/... -count=1` all green
- Both formats registered: `grep -h "Register(" pkg/output/*.go` shows table, json, csv (sarif added in Plan 03)
</verification>

<success_criteria>
- JSONFormatter and CSVFormatter implement Formatter
- Both are registered on package init
- Unmask option propagates to key column
- All unit tests pass
</success_criteria>

<output>
After completion, create `.planning/phases/06-output-reporting/06-02-SUMMARY.md`.
</output>
