---
phase: 06-output-reporting
plan: 02
subsystem: pkg/output
tags: [output, formatter, json, csv, unmask]
requirements: [OUT-02, OUT-04]
dependency-graph:
  requires:
    - "pkg/output.Formatter"
    - "pkg/output.Options"
    - "pkg/output.Register"
    - "pkg/engine.Finding"
  provides:
    - "output.JSONFormatter (registered as \"json\")"
    - "output.CSVFormatter (registered as \"csv\")"
  affects:
    - "cmd/scan.go --output=json|csv (wired in later plan)"
tech-stack:
  added: []
  patterns:
    - "encoding/json Encoder with SetIndent for pretty output"
    - "encoding/csv Writer for stable, auto-quoted flat rows"
    - "omitempty on verify_* JSON fields to keep unverified output compact"
    - "init()-time registration into the shared output.Registry"
key-files:
  created:
    - pkg/output/json.go
    - pkg/output/json_test.go
    - pkg/output/csv.go
    - pkg/output/csv_test.go
  modified: []
decisions:
  - "JSON `key` field carries masked value by default; `key_masked` always carries masked form regardless of Unmask so consumers can tell which mode produced the file."
  - "Verification fields use `omitempty` so unverified scans stay compact; `verified: false` is always emitted as a required discriminator."
  - "CSV `id` column is the zero-based slice index, not the DB rowid — formatter must run on in-memory findings before persistence."
  - "CSV header order is frozen (id,provider,confidence,key,source,line,detected_at,verified,verify_status) — downstream consumers may index by position."
  - "DetectedAt is serialised as RFC3339 in both formats for cross-format consistency."
metrics:
  duration: ~10m
  completed: 2026-04-05
  tasks: 2
  commits: 4
---

# Phase 06 Plan 02: JSON + CSV Formatters Summary

Added `JSONFormatter` and `CSVFormatter` to `pkg/output`, both implementing the `Formatter` interface established in Plan 01 and both honoring the `Unmask` option. These are the machine-readable siblings of the colored table output: JSON for pipelines and tooling, CSV for spreadsheets and ad-hoc grep.

## What Was Built

### 1. JSONFormatter (`pkg/output/json.go`)

- Renders `[]engine.Finding` as a JSON array with 2-space indent via `json.Encoder.SetIndent("", "  ")`.
- Empty input produces `"[]\n"` exactly (important for deterministic CI diffs).
- Private `jsonFinding` struct controls field names and serialization order: `provider, key, key_masked, confidence, source, source_type, line, offset, detected_at, verified, verify_status?, verify_http_code?, verify_metadata?, verify_error?`.
- `key` column: masked by default, full `KeyValue` when `Options.Unmask == true`. `key_masked` is always the masked form.
- `detected_at` serialized as RFC3339.
- Verification fields tagged `omitempty` so unverified scans don't carry dead columns.
- Registers itself as `"json"` in the output registry via `init()`.

### 2. CSVFormatter (`pkg/output/csv.go`)

- Writes a frozen header row followed by one flat row per finding using `encoding/csv.Writer`.
- Header (stable, index-indexable): `id,provider,confidence,key,source,line,detected_at,verified,verify_status`.
- `id` is the zero-based slice index (scan-time id; DB rowid is intentionally not used so this formatter can run on pre-persisted results).
- `key` column honors `Options.Unmask` identically to JSON.
- `verified` rendered as `"true"`/`"false"`, `line` as a plain integer, `detected_at` as RFC3339.
- Commas, quotes, and newlines in source paths are auto-escaped by `encoding/csv`.
- Empty input still writes the header row (one line) — consumers can unconditionally parse a header.
- Registers itself as `"csv"` in the output registry via `init()`.

## Test Coverage

### `pkg/output/json_test.go`
- `TestJSONFormatter_EmptyFindings` — exact `"[]\n"` output.
- `TestJSONFormatter_RegisteredUnderJSON` — registry lookup returns `JSONFormatter`.
- `TestJSONFormatter_MaskedByDefault` — `key == KeyMasked`, verify fields absent (omitempty).
- `TestJSONFormatter_UnmaskRevealsKey` — `key == KeyValue`, `key_masked` still masked.
- `TestJSONFormatter_VerifyFieldsPresent` — verify_status/code/metadata round-trip when `Verified=true`.
- `TestJSONFormatter_Indented` — 2-space array item indent, 4-space nested field indent.

### `pkg/output/csv_test.go`
- `TestCSVFormatter_RegisteredUnderCSV` — registry lookup.
- `TestCSVFormatter_HeaderOnly` — empty findings produce exactly the 9-column header.
- `TestCSVFormatter_RowMasked` — full row round-trips via `csv.NewReader`, masked key, correct types.
- `TestCSVFormatter_Unmask` — Unmask=true reveals `KeyValue`.
- `TestCSVFormatter_QuotesCommaInSource` — `"path, with, commas.txt"` round-trips verbatim.
- `TestCSVFormatter_VerifiedRow` — verified/verify_status columns populated.
- `TestCSVFormatter_MultipleRowsIncrementID` — id column increments 0,1,… with slice position.

## Commits

| Task | Phase | Hash      | Message                                            |
| ---- | ----- | --------- | -------------------------------------------------- |
| 1    | RED   | `c933673` | `test(06-02): add failing tests for JSONFormatter` |
| 1    | GREEN | `1644771` | `feat(06-02): implement JSONFormatter with Unmask support` |
| 2    | RED   | `b35881a` | `test(06-02): add failing tests for CSVFormatter`  |
| 2    | GREEN | `03249fb` | `feat(06-02): implement CSVFormatter with Unmask support` |

## Verification

```
$ go test ./pkg/output/... -count=1
ok  	github.com/salvacybersec/keyhunter/pkg/output	0.005s

$ go build ./...
(no output — clean build)

$ grep -h "Register(" pkg/output/*.go
	Register("csv", CSVFormatter{})
	Register("json", JSONFormatter{})
	Register("sarif", SARIFFormatter{})
	Register("table", TableFormatter{})
```

All four formatters (`table`, `json`, `csv`, `sarif`) now live in the registry — `--output=json|csv` works end to end once the scan command is wired up in a later plan.

## Deviations from Plan

### Fixed During Execution

**1. [Rule 1 - Test bug] Incorrect indent assertion in initial JSON test**
- **Found during:** Task 1 GREEN run
- **Issue:** `TestJSONFormatter_Indented` asserted `"\n  \"provider\""` (2-space indent) but `json.Encoder.SetIndent("", "  ")` produces 4-space indent for nested object fields (array item at 2 spaces + 2 more for nested field).
- **Fix:** Assertion now verifies both indent levels: `"\n  {"` for array items and `"\n    \"provider\""` for nested fields.
- **Files modified:** `pkg/output/json_test.go`
- **Commit:** `1644771` (rolled into GREEN commit since the RED had not yet been runnable)

Plan body (the JSON encoder behavior) was implemented exactly as specified — only the test expectation was off by one indent level.

### Out of Scope / Observed

- During execution, a `pkg/output/sarif_test.go` file from the parallel Plan 03 executor was present in the working tree before `pkg/output/sarif.go` existed, causing the package to fail compilation for unrelated tests. Handled by temporarily moving it aside while running `go test -run TestJSONFormatter|TestCSVFormatter`, then restoring. By the time the final package-wide test run happened, Plan 03's `sarif.go` had landed and the full package compiled and tested cleanly. No files were modified as part of this mitigation; this is a parallel-execution artifact and not a deviation from the plan.

## Known Stubs

None. JSON and CSV formatters emit real data from `engine.Finding` and wire through `Options.Unmask`. No placeholder fields, no TODO markers.

## Self-Check: PASSED

- FOUND: pkg/output/json.go
- FOUND: pkg/output/json_test.go
- FOUND: pkg/output/csv.go
- FOUND: pkg/output/csv_test.go
- FOUND: c933673 (test JSON RED)
- FOUND: 1644771 (feat JSON GREEN)
- FOUND: b35881a (test CSV RED)
- FOUND: 03249fb (feat CSV GREEN)
- FOUND: `Register("json"` in pkg/output/json.go
- FOUND: `Register("csv"` in pkg/output/csv.go
- VERIFIED: `go test ./pkg/output/... -count=1` → ok
- VERIFIED: `go build ./...` → clean
