From 2ed1548426fafd09d64f1baf696d08078e912c12 Mon Sep 17 00:00:00 2001 From: Michael <0xMMA@users.noreply.github.com> Date: Sun, 5 Apr 2026 19:23:16 +0000 Subject: [PATCH 01/11] docs: add unified logging design spec Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-05-unified-logging-design.md | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-05-unified-logging-design.md diff --git a/docs/superpowers/specs/2026-04-05-unified-logging-design.md b/docs/superpowers/specs/2026-04-05-unified-logging-design.md new file mode 100644 index 0000000..6015f3a --- /dev/null +++ b/docs/superpowers/specs/2026-04-05-unified-logging-design.md @@ -0,0 +1,106 @@ +# Unified Logging — Design Spec + +**Date:** 2026-04-05 +**Status:** Approved +**Scope:** Logger package, settings model, settings UI, CLI flags, frontend log bridge, sensitive audit + +## Problem + +CLI commands (`-fix`, `-pyramidize`) have no logging. The GUI exposes only a boolean debug toggle — no level granularity. Frontend log messages are not clearly distinguished from backend messages. Some call sites may leak sensitive data through non-sensitive log functions. + +## Design + +### Log Levels + +Six levels, ordered by severity: + +| Level | slog mapping | Description | +|---------|--------------------|------------------------------------| +| off | discard | No logging (default) | +| trace | `slog.Level(-8)` | Verbose internals, hot-path detail | +| debug | `slog.LevelDebug` | Diagnostic info for developers | +| info | `slog.LevelInfo` | Normal operational events | +| warning | `slog.LevelWarn` | Recoverable issues | +| error | `slog.LevelError` | Failures requiring attention | + +### Logger Package (`internal/logger/logger.go`) + +- `Init(level string, sensitive bool)` — replaces `Init(enabled bool, sensitive bool)` +- `"off"` discards all output. Any other valid level opens `debug.log` and sets the slog handler minimum to that level. +- New function: `Trace(msg string, args ...any)` +- All existing functions unchanged: `Debug`, `Info`, `Warn`, `Error`, `Sensitive` +- `Sensitive()` remains gated by the `sensitiveEnabled` bool, independent of log level. When sensitive is enabled, messages log at debug level. +- Define `const LevelTrace = slog.Level(-8)` for the custom trace level. +- Add `source` attribute: backend functions add `"source", "backend"`, frontend bridge adds `"source", "frontend"`. + +### Settings Model (`internal/features/settings/model.go`) + +- Replace `DebugLogging bool` with `LogLevel string` (json tag: `"log_level"`) +- `SensitiveLogging bool` unchanged +- `Default()` returns `LogLevel: "off"` +- **Migration:** Settings load checks for legacy `debug_logging` field. If present and `true` and `LogLevel` is empty → set `LogLevel = "debug"`. The old field is dropped on next save (Go unmarshals only known fields, so the old key is silently ignored after the migration). + +### Settings Service + +- `logger.Init(cfg.LogLevel, cfg.SensitiveLogging)` in both `main.go` (GUI path) and CLI path. + +### Settings UI + +- Replace the "Debug Logging" boolean toggle with a PrimeNG `Select` dropdown. +- Options: Off, Trace, Debug, Info, Warning, Error. +- "Sensitive Logging" toggle remains below, disabled when level is "off". +- Hint text: "Writes to ~/.config/KeyLint/debug.log (Linux) or %AppData%/KeyLint/debug.log (Windows)" + +### CLI (`--log `) + +- Add `--log` flag to both `-fix` and `-pyramidize` via a shared `addLogFlag(fs *flag.FlagSet) *string` helper in `cli.go`. +- Default: `"off"`. Accepts: `off`, `trace`, `debug`, `info`, `warning`, `error`. +- Calls `logger.Init(level, false)` before executing the command. +- Sensitive is always `false` in CLI mode — prevents accidental credential leaks to terminal/pipes. +- Invalid level values produce a clear error message listing valid options. + +### Frontend Log Bridge (`internal/features/logger/service.go`) + +- `Log(level, msg string)` routes through the same logger functions but adds `"source", "frontend"` attribute. +- Supports: `"trace"`, `"debug"`, `"info"`, `"warn"`, `"error"`. +- Frontend errors (Angular `ErrorHandler`, uncaught exceptions) should call this service so they appear in `debug.log`. + +### Backend Log Tagging + +- All backend `logger.*()` calls include `"source", "backend"` as a default attribute set on the handler, so individual call sites don't need to add it manually. +- Frontend bridge overrides with `"source", "frontend"`. + +Implementation: use `slog.Handler` wrapping or `logger.With("source", "backend")` as the default logger instance. The frontend service creates calls with `"source", "frontend"` via a separate logger instance or by passing the attribute explicitly. + +### Sensitive Audit + +Existing call sites reviewed: + +| File | Call | Verdict | +|------|------|---------| +| `enhance/service.go:65` | `Info("enhance: start", "provider", ..., "input_len", ...)` | Safe — no user text, only length | +| `enhance/service.go:107,118,140,152,174,184` | `Sensitive(...)` | Correct — payloads gated | +| `pyramidize/api_*.go` | `Sensitive(...)` | Correct — payloads gated | +| `pyramidize/service.go:97` | `Info("pyramidize: start", "docType", ..., "provider", ...)` | Safe — metadata only | +| `clipboard/service.go:39,54` | `Debug("clipboard: read/write", "len", ...)` | Safe — only length | +| `features/logger/service.go` | `Debug/Info/Warn/Error("frontend: " + msg)` | **Needs review** — raw frontend messages could contain user text. Route through `Sensitive()` or document that frontend messages may contain user content. | + +**Decision:** Not all frontend log messages are sensitive — error stack traces and component lifecycle events are safe, but info/debug/trace messages may contain user text. The bridge should: +- Route `"error"` and `"warn"` level messages through normal `logger.Error/Warn` (with `source=frontend`) — these are operational. +- Route `"info"`, `"debug"`, `"trace"` through `logger.Sensitive` — these may contain user text and should only appear when sensitive logging is explicitly enabled. + +## File Changes Summary + +| File | Change | +|------|--------| +| `internal/logger/logger.go` | New `Init` signature, add `Trace`, add `LevelTrace`, default `source=backend` attribute | +| `internal/features/logger/service.go` | Add `source=frontend` attribute, gate info/debug/trace through `Sensitive` | +| `internal/features/settings/model.go` | `DebugLogging bool` → `LogLevel string`, migration logic | +| `internal/features/settings/service.go` | Migration on load | +| `internal/cli/cli.go` | `addLogFlag` helper, `initLogger` helper | +| `internal/cli/fix.go` | Add `--log` flag | +| `internal/cli/pyramidize.go` | Add `--log` flag | +| `main.go` | `logger.Init(cfg.LogLevel, cfg.SensitiveLogging)` | +| `frontend/.../settings.component.ts` | Replace toggle with Select dropdown | +| `docs/logging.md` | Requirements and conventions reference | +| `CLAUDE.md` | One-line reference to `docs/logging.md` | From ecb0594a88de3c8d77639eab2e982a02a3f4a86d Mon Sep 17 00:00:00 2001 From: Michael <0xMMA@users.noreply.github.com> Date: Sun, 5 Apr 2026 20:27:08 +0000 Subject: [PATCH 02/11] docs: update logging spec with Redact/LogValuer pattern and test strategy Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-05-unified-logging-design.md | 184 ++++++++++++++---- 1 file changed, 148 insertions(+), 36 deletions(-) diff --git a/docs/superpowers/specs/2026-04-05-unified-logging-design.md b/docs/superpowers/specs/2026-04-05-unified-logging-design.md index 6015f3a..2acc569 100644 --- a/docs/superpowers/specs/2026-04-05-unified-logging-design.md +++ b/docs/superpowers/specs/2026-04-05-unified-logging-design.md @@ -2,11 +2,11 @@ **Date:** 2026-04-05 **Status:** Approved -**Scope:** Logger package, settings model, settings UI, CLI flags, frontend log bridge, sensitive audit +**Scope:** Logger package, settings model, settings UI, CLI flags, frontend log bridge, sensitive redaction, testing ## Problem -CLI commands (`-fix`, `-pyramidize`) have no logging. The GUI exposes only a boolean debug toggle — no level granularity. Frontend log messages are not clearly distinguished from backend messages. Some call sites may leak sensitive data through non-sensitive log functions. +CLI commands (`-fix`, `-pyramidize`) have no logging. The GUI exposes only a boolean debug toggle — no level granularity. Frontend log messages are not clearly distinguished from backend messages. Sensitive data (user text, API payloads, credentials) can leak through log calls that don't gate on the sensitive flag. ## Design @@ -23,15 +23,52 @@ Six levels, ordered by severity: | warning | `slog.LevelWarn` | Recoverable issues | | error | `slog.LevelError` | Failures requiring attention | +### Sensitive Redaction via `slog.LogValuer` + +Remove the standalone `Sensitive()` function. Replace with a `Redact(value any) slog.LogValuer` wrapper that uses slog's native `LogValuer` interface: + +```go +// Definition in internal/logger/logger.go +const LevelTrace = slog.Level(-8) + +type redacted struct{ v any } + +func (r redacted) LogValue() slog.Value { + if !sensitiveEnabled { + return slog.StringValue("[redacted]") + } + return slog.AnyValue(r.v) +} + +// Redact wraps a value so it self-redacts when sensitive logging is off. +func Redact(v any) slog.LogValuer { return redacted{v} } +``` + +**Call-site convention:** any value that could contain user text, API payloads, or credentials is wrapped in `Redact()`. The event message and safe metadata (provider name, status code, byte length) are never wrapped. + +```go +// Before (old Sensitive function — entire entry hidden): +logger.Sensitive("enhance: request", "provider", "openai", "payload", string(body)) + +// After (event always visible, payload redacted unless sensitive is on): +logger.Debug("enhance: request", "provider", "openai", "payload", logger.Redact(string(body))) +``` + +This means: +- Every log entry appears at its configured level — you always see *what happened* +- Only the sensitive *values* get redacted — safe metadata stays visible +- No external dependencies — uses stdlib `slog.LogValuer` contract +- The old `Sensitive()` function is removed entirely + ### Logger Package (`internal/logger/logger.go`) - `Init(level string, sensitive bool)` — replaces `Init(enabled bool, sensitive bool)` - `"off"` discards all output. Any other valid level opens `debug.log` and sets the slog handler minimum to that level. -- New function: `Trace(msg string, args ...any)` -- All existing functions unchanged: `Debug`, `Info`, `Warn`, `Error`, `Sensitive` -- `Sensitive()` remains gated by the `sensitiveEnabled` bool, independent of log level. When sensitive is enabled, messages log at debug level. -- Define `const LevelTrace = slog.Level(-8)` for the custom trace level. -- Add `source` attribute: backend functions add `"source", "backend"`, frontend bridge adds `"source", "frontend"`. +- New function: `Trace(msg string, args ...any)` using `LevelTrace = slog.Level(-8)` +- Existing functions unchanged: `Debug`, `Info`, `Warn`, `Error` +- `Sensitive()` removed — replaced by `Redact()` (see above) +- Default logger instance carries `"source", "backend"` attribute via `slog.Logger.With()` +- Expose `Frontend() *slog.Logger` or a `FrontendInfo/Debug/Warn/Error` set that carries `"source", "frontend"` ### Settings Model (`internal/features/settings/model.go`) @@ -61,46 +98,121 @@ Six levels, ordered by severity: ### Frontend Log Bridge (`internal/features/logger/service.go`) -- `Log(level, msg string)` routes through the same logger functions but adds `"source", "frontend"` attribute. +- `Log(level, msg string)` routes through logger functions with `"source", "frontend"` attribute. - Supports: `"trace"`, `"debug"`, `"info"`, `"warn"`, `"error"`. +- The `msg` argument is always wrapped in `Redact()` — frontend messages may contain user text. - Frontend errors (Angular `ErrorHandler`, uncaught exceptions) should call this service so they appear in `debug.log`. ### Backend Log Tagging -- All backend `logger.*()` calls include `"source", "backend"` as a default attribute set on the handler, so individual call sites don't need to add it manually. -- Frontend bridge overrides with `"source", "frontend"`. - -Implementation: use `slog.Handler` wrapping or `logger.With("source", "backend")` as the default logger instance. The frontend service creates calls with `"source", "frontend"` via a separate logger instance or by passing the attribute explicitly. - -### Sensitive Audit - -Existing call sites reviewed: - -| File | Call | Verdict | -|------|------|---------| -| `enhance/service.go:65` | `Info("enhance: start", "provider", ..., "input_len", ...)` | Safe — no user text, only length | -| `enhance/service.go:107,118,140,152,174,184` | `Sensitive(...)` | Correct — payloads gated | -| `pyramidize/api_*.go` | `Sensitive(...)` | Correct — payloads gated | -| `pyramidize/service.go:97` | `Info("pyramidize: start", "docType", ..., "provider", ...)` | Safe — metadata only | -| `clipboard/service.go:39,54` | `Debug("clipboard: read/write", "len", ...)` | Safe — only length | -| `features/logger/service.go` | `Debug/Info/Warn/Error("frontend: " + msg)` | **Needs review** — raw frontend messages could contain user text. Route through `Sensitive()` or document that frontend messages may contain user content. | - -**Decision:** Not all frontend log messages are sensitive — error stack traces and component lifecycle events are safe, but info/debug/trace messages may contain user text. The bridge should: -- Route `"error"` and `"warn"` level messages through normal `logger.Error/Warn` (with `source=frontend`) — these are operational. -- Route `"info"`, `"debug"`, `"trace"` through `logger.Sensitive` — these may contain user text and should only appear when sensitive logging is explicitly enabled. +- Default backend logger instance carries `"source", "backend"` via `slog.Logger.With("source", "backend")`. +- Frontend bridge uses a separate logger instance with `"source", "frontend"`. +- Individual call sites don't need to add the source attribute — it's baked into the logger instance. + +### Sensitive Audit — All Call Sites + +Every existing call site must be reviewed. The rule: **if the value could contain user text, API payloads, or credentials, wrap it in `Redact()`.** + +| File | Call | Action | +|------|------|--------| +| `enhance/service.go:65` | `Info("enhance: start", "provider", ..., "input_len", ...)` | No change — metadata only | +| `enhance/service.go:107,118` | `Sensitive("enhance: request/response", "payload", ...)` | → `Debug(...)` with `Redact(payload)` | +| `enhance/service.go:140,152` | `Sensitive("enhance: request/response", "payload", ...)` | → `Debug(...)` with `Redact(payload)` | +| `enhance/service.go:174,184` | `Sensitive("enhance: request/response", "payload", ...)` | → `Debug(...)` with `Redact(payload)` | +| `pyramidize/api_claude.go:31,48` | `Sensitive(... "len", len(payload))` | → `Debug(...)` with `Redact(payload)` (log the body, not just length) | +| `pyramidize/api_openai.go:36,52` | `Sensitive(...)` | → `Debug(...)` with `Redact(payload)` | +| `pyramidize/api_ollama.go:37,52` | `Sensitive(...)` | → `Debug(...)` with `Redact(payload)` | +| `pyramidize/service.go:97` | `Info("pyramidize: start", "docType", ..., "provider", ...)` | No change — metadata only | +| `clipboard/service.go:39,54` | `Debug("clipboard: read/write", "len", ...)` | No change — only length | +| `features/logger/service.go` | `Debug/Info/Warn/Error("frontend: " + msg)` | → Wrap `msg` in `Redact()` | +| `main.go:120` | `Info("shortcut: registered", "key", cfg.ShortcutKey)` | No change — config value, not user text | +| All other `Info/Debug/Warn/Error` calls | Metadata, status codes, error messages | No change — no user content | + +## Testing Strategy + +The logger and redaction system must be thoroughly tested. No mocks for the core logger — test real output. + +### Logger Package Tests (`internal/logger/logger_test.go`) + +**Init & level filtering:** +- `Init("off", false)` → no output produced for any level +- `Init("error", false)` → only error-level messages appear +- `Init("warning", false)` → warning + error appear, info/debug/trace do not +- `Init("info", false)` → info + warning + error appear +- `Init("debug", false)` → debug + info + warning + error appear +- `Init("trace", false)` → all levels appear including trace +- Invalid level string → falls back to off (or returns error) + +**Redact behavior:** +- `Redact(value)` with `sensitive=false` → output contains `[redacted]`, NOT the value +- `Redact(value)` with `sensitive=true` → output contains the actual value +- `Redact(value)` with various types: string, []byte, struct, nil +- Verify redaction works through the full slog pipeline (write to buffer, parse output, assert) +- **Negative test:** log a "secret" value via `Redact()`, read back the log file, assert the secret string is NOT present when sensitive is off + +**Source tagging:** +- Backend logger output contains `source=backend` +- Frontend logger output contains `source=frontend` + +**Trace level:** +- `Trace()` emits at `LevelTrace` (-8) +- Trace messages appear when level is "trace", not when level is "debug" + +**File output:** +- Init creates `debug.log` in the expected directory +- Multiple Init calls don't leak file handles (close previous file) + +### Settings Migration Tests (`internal/features/settings/`) + +- Legacy JSON with `"debug_logging": true` and no `"log_level"` → loads as `LogLevel: "debug"` +- Legacy JSON with `"debug_logging": false` → loads as `LogLevel: "off"` +- New JSON with `"log_level": "warning"` → loads correctly +- JSON with both fields → `log_level` takes precedence +- Round-trip: load legacy → save → reload → `log_level` present, `debug_logging` absent + +### CLI Flag Tests (`internal/cli/`) + +- `--log debug` → logger initialised at debug level +- `--log off` (or omitted) → no log output +- `--log invalid` → error message listing valid levels +- `--log` flag works for both `-fix` and `-pyramidize` +- Sensitive is always off in CLI mode regardless of flag + +### Frontend Bridge Tests (`internal/features/logger/`) + +- `Log("error", "something broke")` → appears in log with `source=frontend` +- `Log("info", "user typed X")` → msg content redacted when sensitive is off +- `Log("info", "user typed X")` → msg content visible when sensitive is on +- Unknown level defaults to info + +### Settings UI Tests (Vitest) + +- Select dropdown renders with all 6 options +- Changing selection calls the settings save method with correct `log_level` value +- Sensitive toggle is disabled when level is "off" +- Sensitive toggle is enabled when level is anything else ## File Changes Summary | File | Change | |------|--------| -| `internal/logger/logger.go` | New `Init` signature, add `Trace`, add `LevelTrace`, default `source=backend` attribute | -| `internal/features/logger/service.go` | Add `source=frontend` attribute, gate info/debug/trace through `Sensitive` | -| `internal/features/settings/model.go` | `DebugLogging bool` → `LogLevel string`, migration logic | -| `internal/features/settings/service.go` | Migration on load | -| `internal/cli/cli.go` | `addLogFlag` helper, `initLogger` helper | -| `internal/cli/fix.go` | Add `--log` flag | -| `internal/cli/pyramidize.go` | Add `--log` flag | +| `internal/logger/logger.go` | New `Init(level, sensitive)`, add `Trace`, `Redact`, `LevelTrace`, remove `Sensitive`, `source=backend` default | +| `internal/logger/logger_test.go` | **New** — comprehensive tests for levels, redaction, source tagging, file output | +| `internal/features/logger/service.go` | `source=frontend`, wrap msg in `Redact()` | +| `internal/features/logger/service_test.go` | **New** — bridge tests for level routing, redaction, source tagging | +| `internal/features/settings/model.go` | `DebugLogging bool` → `LogLevel string`, update `Default()` | +| `internal/features/settings/service.go` | Migration logic on load | +| `internal/features/settings/service_test.go` | Migration tests (legacy → new, round-trip) | +| `internal/cli/cli.go` | `addLogFlag` helper | +| `internal/cli/fix.go` | Add `--log` flag, call `logger.Init` | +| `internal/cli/pyramidize.go` | Add `--log` flag, call `logger.Init` | +| `internal/cli/cli_test.go` | CLI flag tests for `--log` | | `main.go` | `logger.Init(cfg.LogLevel, cfg.SensitiveLogging)` | +| `internal/features/enhance/service.go` | `Sensitive()` → `Debug()` + `Redact()` | +| `internal/features/pyramidize/api_claude.go` | `Sensitive()` → `Debug()` + `Redact()` | +| `internal/features/pyramidize/api_openai.go` | `Sensitive()` → `Debug()` + `Redact()` | +| `internal/features/pyramidize/api_ollama.go` | `Sensitive()` → `Debug()` + `Redact()` | | `frontend/.../settings.component.ts` | Replace toggle with Select dropdown | +| `frontend/.../settings.component.spec.ts` | UI tests for dropdown + sensitive toggle state | | `docs/logging.md` | Requirements and conventions reference | | `CLAUDE.md` | One-line reference to `docs/logging.md` | From 77c34c71f9747ca23eb257d6d6d5a877f1303788 Mon Sep 17 00:00:00 2001 From: Michael <0xMMA@users.noreply.github.com> Date: Sun, 5 Apr 2026 20:37:06 +0000 Subject: [PATCH 03/11] docs: add unified logging implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-04-05-unified-logging.md | 1431 +++++++++++++++++ 1 file changed, 1431 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-05-unified-logging.md diff --git a/docs/superpowers/plans/2026-04-05-unified-logging.md b/docs/superpowers/plans/2026-04-05-unified-logging.md new file mode 100644 index 0000000..c4a93c2 --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-unified-logging.md @@ -0,0 +1,1431 @@ +# Unified Logging Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the boolean debug logging toggle with a six-level log system, add `Redact()` for sensitive value protection via slog's `LogValuer`, add `--log` CLI flag, tag frontend vs backend log sources, and update the settings UI to a dropdown. + +**Architecture:** The `internal/logger` package becomes the single source of truth for log level, sensitivity, and redaction. `Redact()` wraps values with a `slog.LogValuer` that self-redacts when sensitive logging is off. Settings model migrates `debug_logging: bool` → `log_level: string`. CLI commands get a shared `--log ` flag. Frontend log bridge tags messages with `source=frontend` and wraps message content in `Redact()`. + +**Tech Stack:** Go 1.26 `log/slog`, Angular v21, PrimeNG v21 `Select`, Vitest + +--- + +### Task 1: Logger Package — Levels, Trace, Redact, Source Tagging + +**Files:** +- Modify: `internal/logger/logger.go` (full rewrite) +- Create: `internal/logger/logger_test.go` + +- [ ] **Step 1: Write the failing tests for level filtering** + +Create `internal/logger/logger_test.go`: + +```go +package logger + +import ( + "bytes" + "io" + "strings" + "testing" +) + +// initWithBuffer sets up the logger to write to a buffer at the given level. +// Returns the buffer so tests can inspect output. +func initWithBuffer(t *testing.T, level string, sensitive bool) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + initWithWriter(&buf, level, sensitive) + t.Cleanup(func() { + l = slog.New(slog.NewTextHandler(io.Discard, nil)) + sensitiveEnabled = false + }) + return &buf +} + +func TestInit_Off_ProducesNoOutput(t *testing.T) { + buf := initWithBuffer(t, "off", false) + Trace("trace msg") + Debug("debug msg") + Info("info msg") + Warn("warn msg") + Error("error msg") + if buf.Len() != 0 { + t.Errorf("expected no output with level=off, got:\n%s", buf.String()) + } +} + +func TestInit_Error_OnlyErrorAppears(t *testing.T) { + buf := initWithBuffer(t, "error", false) + Info("info msg") + Warn("warn msg") + Error("error msg") + output := buf.String() + if strings.Contains(output, "info msg") { + t.Error("info should not appear at error level") + } + if strings.Contains(output, "warn msg") { + t.Error("warn should not appear at error level") + } + if !strings.Contains(output, "error msg") { + t.Error("error should appear at error level") + } +} + +func TestInit_Warning_WarnAndErrorAppear(t *testing.T) { + buf := initWithBuffer(t, "warning", false) + Debug("debug msg") + Info("info msg") + Warn("warn msg") + Error("error msg") + output := buf.String() + if strings.Contains(output, "debug msg") { + t.Error("debug should not appear at warning level") + } + if strings.Contains(output, "info msg") { + t.Error("info should not appear at warning level") + } + if !strings.Contains(output, "warn msg") { + t.Error("warn should appear at warning level") + } + if !strings.Contains(output, "error msg") { + t.Error("error should appear at warning level") + } +} + +func TestInit_Info_InfoWarnErrorAppear(t *testing.T) { + buf := initWithBuffer(t, "info", false) + Debug("debug msg") + Info("info msg") + Warn("warn msg") + Error("error msg") + output := buf.String() + if strings.Contains(output, "debug msg") { + t.Error("debug should not appear at info level") + } + if !strings.Contains(output, "info msg") { + t.Error("info should appear") + } + if !strings.Contains(output, "warn msg") { + t.Error("warn should appear") + } + if !strings.Contains(output, "error msg") { + t.Error("error should appear") + } +} + +func TestInit_Debug_AllExceptTraceAppear(t *testing.T) { + buf := initWithBuffer(t, "debug", false) + Trace("trace msg") + Debug("debug msg") + Info("info msg") + output := buf.String() + if strings.Contains(output, "trace msg") { + t.Error("trace should not appear at debug level") + } + if !strings.Contains(output, "debug msg") { + t.Error("debug should appear") + } + if !strings.Contains(output, "info msg") { + t.Error("info should appear") + } +} + +func TestInit_Trace_AllLevelsAppear(t *testing.T) { + buf := initWithBuffer(t, "trace", false) + Trace("trace msg") + Debug("debug msg") + Info("info msg") + Warn("warn msg") + Error("error msg") + output := buf.String() + for _, want := range []string{"trace msg", "debug msg", "info msg", "warn msg", "error msg"} { + if !strings.Contains(output, want) { + t.Errorf("expected %q in output", want) + } + } +} + +func TestInit_InvalidLevel_FallsBackToOff(t *testing.T) { + buf := initWithBuffer(t, "banana", false) + Info("should not appear") + if buf.Len() != 0 { + t.Errorf("invalid level should fall back to off, got:\n%s", buf.String()) + } +} +``` + +- [ ] **Step 2: Write the failing tests for Redact** + +Append to `internal/logger/logger_test.go`: + +```go +func TestRedact_SensitiveOff_RedactsValue(t *testing.T) { + buf := initWithBuffer(t, "debug", false) // sensitive=false + Debug("test", "secret", Redact("my-api-key-123")) + output := buf.String() + if strings.Contains(output, "my-api-key-123") { + t.Error("secret value should be redacted when sensitive=false") + } + if !strings.Contains(output, "[redacted]") { + t.Error("expected [redacted] placeholder") + } +} + +func TestRedact_SensitiveOn_ShowsValue(t *testing.T) { + buf := initWithBuffer(t, "debug", true) // sensitive=true + Debug("test", "secret", Redact("my-api-key-123")) + output := buf.String() + if !strings.Contains(output, "my-api-key-123") { + t.Error("secret value should be visible when sensitive=true") + } + if strings.Contains(output, "[redacted]") { + t.Error("should not contain [redacted] when sensitive=true") + } +} + +func TestRedact_NilValue(t *testing.T) { + buf := initWithBuffer(t, "debug", false) + Debug("test", "val", Redact(nil)) + output := buf.String() + if !strings.Contains(output, "[redacted]") { + t.Error("nil value should still show [redacted]") + } +} + +func TestRedact_ByteSlice(t *testing.T) { + buf := initWithBuffer(t, "debug", true) + Debug("test", "body", Redact([]byte("secret bytes"))) + output := buf.String() + if !strings.Contains(output, "secret bytes") { + t.Error("byte slice should be visible when sensitive=true") + } +} + +func TestRedact_NegativeTest_SecretNotInLogFile(t *testing.T) { + // The most critical test: with sensitive OFF, the secret must NOT + // appear anywhere in the log output — not in any form. + secret := "SUPER_SECRET_KEY_abc123xyz" + buf := initWithBuffer(t, "trace", false) // lowest level, sensitive OFF + Trace("api call", "key", Redact(secret)) + Debug("request", "payload", Redact(`{"auth":"` + secret + `"}`)) + Info("response", "body", Redact(secret)) + output := buf.String() + if strings.Contains(output, secret) { + t.Fatalf("SECRET LEAKED in log output:\n%s", output) + } +} +``` + +- [ ] **Step 3: Write the failing tests for source tagging** + +Append to `internal/logger/logger_test.go`: + +```go +func TestBackendLogger_HasSourceBackend(t *testing.T) { + buf := initWithBuffer(t, "info", false) + Info("hello") + output := buf.String() + if !strings.Contains(output, "source=backend") { + t.Errorf("expected source=backend in output, got:\n%s", output) + } +} + +func TestFrontendLogger_HasSourceFrontend(t *testing.T) { + buf := initWithBuffer(t, "info", false) + fl := FrontendLogger() + fl.Info("hello from frontend") + output := buf.String() + if !strings.Contains(output, "source=frontend") { + t.Errorf("expected source=frontend in output, got:\n%s", output) + } +} +``` + +- [ ] **Step 4: Run tests to verify they fail** + +Run: `cd /home/dev/projects/keylint && go test ./internal/logger/ -v -count=1` +Expected: compilation errors (initWithWriter, Redact, FrontendLogger, Trace not defined yet) + +- [ ] **Step 5: Implement the logger package** + +Rewrite `internal/logger/logger.go`: + +```go +// Package logger provides a thin wrapper around log/slog with level-based +// filtering, sensitive value redaction via slog.LogValuer, and source tagging. +package logger + +import ( + "io" + "log/slog" + "os" + "path/filepath" +) + +// LevelTrace is a custom slog level below Debug for verbose tracing. +const LevelTrace = slog.Level(-8) + +var ( + l = slog.New(slog.NewTextHandler(io.Discard, nil)) + logFile *os.File + sensitiveEnabled bool +) + +// levelNames maps config strings to slog levels. +var levelNames = map[string]slog.Level{ + "trace": LevelTrace, + "debug": slog.LevelDebug, + "info": slog.LevelInfo, + "warning": slog.LevelWarn, + "error": slog.LevelError, +} + +// Init configures the logger. level is one of: "off", "trace", "debug", +// "info", "warning", "error". Invalid values are treated as "off". +// sensitive controls whether Redact() shows or hides wrapped values. +func Init(level string, sensitive bool) { + sensitiveEnabled = sensitive + if logFile != nil { + _ = logFile.Close() + logFile = nil + } + slogLevel, ok := levelNames[level] + if !ok { + // "off" or invalid → discard + l = slog.New(slog.NewTextHandler(io.Discard, nil)) + return + } + dir, err := os.UserConfigDir() + if err != nil { + l = slog.New(slog.NewTextHandler(io.Discard, nil)) + return + } + dir = filepath.Join(dir, "KeyLint") + _ = os.MkdirAll(dir, 0700) + logPath := filepath.Join(dir, "debug.log") + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + l = slog.New(slog.NewTextHandler(io.Discard, nil)) + return + } + logFile = f + l = slog.New(slog.NewTextHandler(f, &slog.HandlerOptions{Level: slogLevel})).With("source", "backend") + l.Info("logger initialized", "path", logPath, "level", level, "sensitive", sensitive) +} + +// initWithWriter is used by tests to redirect output to a buffer. +func initWithWriter(w io.Writer, level string, sensitive bool) { + sensitiveEnabled = sensitive + slogLevel, ok := levelNames[level] + if !ok { + l = slog.New(slog.NewTextHandler(io.Discard, nil)) + return + } + l = slog.New(slog.NewTextHandler(w, &slog.HandlerOptions{Level: slogLevel})).With("source", "backend") +} + +// FrontendLogger returns a logger tagged with source=frontend. +// Used by the frontend log bridge service. +func FrontendLogger() *slog.Logger { + // Build from the same handler as l, but swap the source attribute. + // We create a child logger from the underlying handler to inherit the level. + return l.With("source", "frontend") +} + +func Trace(msg string, args ...any) { l.Log(nil, LevelTrace, msg, args...) } +func Debug(msg string, args ...any) { l.Debug(msg, args...) } +func Info(msg string, args ...any) { l.Info(msg, args...) } +func Warn(msg string, args ...any) { l.Warn(msg, args...) } +func Error(msg string, args ...any) { l.Error(msg, args...) } + +// redacted wraps a value and implements slog.LogValuer to self-redact +// when sensitive logging is disabled. +type redacted struct{ v any } + +func (r redacted) LogValue() slog.Value { + if !sensitiveEnabled { + return slog.StringValue("[redacted]") + } + return slog.AnyValue(r.v) +} + +// Redact wraps a value so it self-redacts when sensitive logging is off. +// Use this for any value that could contain user text, API payloads, or credentials. +func Redact(v any) slog.LogValuer { return redacted{v} } +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cd /home/dev/projects/keylint && go test ./internal/logger/ -v -count=1` +Expected: all tests PASS + +- [ ] **Step 7: Fix FrontendLogger source tag issue** + +The `FrontendLogger()` implementation above will produce `source=backend source=frontend` because `.With()` appends rather than replaces. We need to build the frontend logger from the raw handler instead. Update `FrontendLogger()`: + +```go +// FrontendLogger returns a logger tagged with source=frontend. +func FrontendLogger() *slog.Logger { + // Get the underlying handler and create a fresh logger with source=frontend. + // This avoids the backend logger's source=backend attribute. + h := l.Handler() + return slog.New(h).With("source", "frontend") +} +``` + +And update `initWithWriter` to store the handler separately so FrontendLogger can use it without inheriting `source=backend`: + +Actually, the cleaner approach is to store the base handler before adding the source attribute. Revise the implementation: + +```go +var ( + l = slog.New(slog.NewTextHandler(io.Discard, nil)) + baseH slog.Handler = slog.NewTextHandler(io.Discard, nil) + logFile *os.File + sensitiveEnabled bool +) + +// In Init and initWithWriter, store baseH before adding source: +// baseH = slog.NewTextHandler(w, &slog.HandlerOptions{Level: slogLevel}) +// l = slog.New(baseH).With("source", "backend") + +func FrontendLogger() *slog.Logger { + return slog.New(baseH).With("source", "frontend") +} +``` + +Update both `Init` and `initWithWriter` accordingly. Then re-run the test for `TestFrontendLogger_HasSourceFrontend` — it should show `source=frontend` without `source=backend`. + +- [ ] **Step 8: Run tests again to confirm fix** + +Run: `cd /home/dev/projects/keylint && go test ./internal/logger/ -v -count=1` +Expected: all tests PASS, `TestFrontendLogger_HasSourceFrontend` shows only `source=frontend` + +- [ ] **Step 9: Commit** + +```bash +git add internal/logger/logger.go internal/logger/logger_test.go +git commit -m "feat(logger): add level-based logging, Redact via LogValuer, source tagging" +``` + +--- + +### Task 2: Settings Model — LogLevel Migration + +**Files:** +- Modify: `internal/features/settings/model.go` +- Modify: `internal/features/settings/service.go` +- Modify: `internal/features/settings/service_test.go` + +- [ ] **Step 1: Write the failing migration tests** + +Append to `internal/features/settings/service_test.go`: + +```go +func TestMigration_LegacyDebugLoggingTrue_BecomesDebug(t *testing.T) { + tmp := t.TempDir() + dir := filepath.Join(tmp, "KeyLint") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + // Write legacy JSON with debug_logging: true and no log_level + legacy := `{"active_provider":"openai","debug_logging":true,"sensitive_logging":true}` + if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(legacy), 0600); err != nil { + t.Fatal(err) + } + svc := newServiceAt(t, tmp) + got := svc.Get() + if got.LogLevel != "debug" { + t.Errorf("expected LogLevel=debug after migration, got %q", got.LogLevel) + } + if !got.SensitiveLogging { + t.Error("expected SensitiveLogging=true to be preserved") + } +} + +func TestMigration_LegacyDebugLoggingFalse_BecomesOff(t *testing.T) { + tmp := t.TempDir() + dir := filepath.Join(tmp, "KeyLint") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + legacy := `{"active_provider":"openai","debug_logging":false}` + if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(legacy), 0600); err != nil { + t.Fatal(err) + } + svc := newServiceAt(t, tmp) + got := svc.Get() + if got.LogLevel != "off" { + t.Errorf("expected LogLevel=off after migration, got %q", got.LogLevel) + } +} + +func TestMigration_NewLogLevel_LoadsCorrectly(t *testing.T) { + tmp := t.TempDir() + dir := filepath.Join(tmp, "KeyLint") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + newJSON := `{"active_provider":"openai","log_level":"warning"}` + if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(newJSON), 0600); err != nil { + t.Fatal(err) + } + svc := newServiceAt(t, tmp) + got := svc.Get() + if got.LogLevel != "warning" { + t.Errorf("expected LogLevel=warning, got %q", got.LogLevel) + } +} + +func TestMigration_BothFields_LogLevelTakesPrecedence(t *testing.T) { + tmp := t.TempDir() + dir := filepath.Join(tmp, "KeyLint") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + // Both fields present — log_level wins + both := `{"active_provider":"openai","debug_logging":true,"log_level":"error"}` + if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(both), 0600); err != nil { + t.Fatal(err) + } + svc := newServiceAt(t, tmp) + got := svc.Get() + if got.LogLevel != "error" { + t.Errorf("expected LogLevel=error (explicit wins), got %q", got.LogLevel) + } +} + +func TestMigration_RoundTrip_LegacyToNew(t *testing.T) { + tmp := t.TempDir() + dir := filepath.Join(tmp, "KeyLint") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + legacy := `{"active_provider":"openai","debug_logging":true}` + if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(legacy), 0600); err != nil { + t.Fatal(err) + } + + // Load (migrates), then save, then reload + svc := newServiceAt(t, tmp) + if err := svc.Save(svc.Get()); err != nil { + t.Fatalf("Save: %v", err) + } + + // Read raw file — should have log_level, not debug_logging + data, _ := os.ReadFile(filepath.Join(dir, "settings.json")) + raw := string(data) + if !strings.Contains(raw, `"log_level"`) { + t.Error("saved file should contain log_level") + } + if strings.Contains(raw, `"debug_logging"`) { + t.Error("saved file should not contain legacy debug_logging") + } + + // Reload and verify + svc2 := newServiceAt(t, tmp) + got := svc2.Get() + if got.LogLevel != "debug" { + t.Errorf("round-trip: expected LogLevel=debug, got %q", got.LogLevel) + } +} + +func TestDefault_LogLevel_IsOff(t *testing.T) { + d := settings.Default() + if d.LogLevel != "off" { + t.Errorf("expected default LogLevel=off, got %q", d.LogLevel) + } +} +``` + +Note: you'll need to add `"strings"` to the import block. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /home/dev/projects/keylint && go test ./internal/features/settings/ -v -count=1 -run "Migration|Default_LogLevel"` +Expected: compilation errors (LogLevel field doesn't exist yet) + +- [ ] **Step 3: Update the settings model** + +In `internal/features/settings/model.go`, replace `DebugLogging bool` with `LogLevel string`: + +Replace: +```go + DebugLogging bool `json:"debug_logging"` // writes debug.log to the app config dir +``` +With: +```go + LogLevel string `json:"log_level"` // "off"|"trace"|"debug"|"info"|"warning"|"error" +``` + +Update `Default()` to set `LogLevel: "off"`: +```go +func Default() Settings { + return Settings{ + ActiveProvider: "openai", + ShortcutKey: "ctrl+g", + ThemePreference: "dark", + LogLevel: "off", + PyramidizeQualityThreshold: DefaultQualityThreshold, + } +} +``` + +- [ ] **Step 4: Add migration logic to settings service** + +In `internal/features/settings/service.go`, update the `load()` method. After the `json.Unmarshal` call, add migration logic: + +```go +func (s *Service) load() error { + data, err := os.ReadFile(s.filePath) + if os.IsNotExist(err) { + logger.Info("settings: file not found, using defaults", "path", s.filePath) + return err + } + if err != nil { + logger.Error("settings: read failed", "path", s.filePath, "err", err) + return err + } + if err := json.Unmarshal(data, &s.current); err != nil { + logger.Error("settings: unmarshal failed", "err", err) + return err + } + + // Migrate legacy debug_logging → log_level + if s.current.LogLevel == "" { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err == nil { + if dl, ok := raw["debug_logging"]; ok { + var enabled bool + if json.Unmarshal(dl, &enabled) == nil && enabled { + s.current.LogLevel = "debug" + } else { + s.current.LogLevel = "off" + } + } else { + s.current.LogLevel = "off" + } + } + } + + logger.Info("settings: loaded", "path", s.filePath) + return nil +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd /home/dev/projects/keylint && go test ./internal/features/settings/ -v -count=1` +Expected: all tests PASS (including new migration tests and existing tests) + +- [ ] **Step 6: Commit** + +```bash +git add internal/features/settings/model.go internal/features/settings/service.go internal/features/settings/service_test.go +git commit -m "feat(settings): migrate debug_logging bool to log_level string" +``` + +--- + +### Task 3: Update main.go and CLI — Wire Up New Logger Init + +**Files:** +- Modify: `main.go:72` +- Modify: `internal/cli/cli.go` +- Modify: `internal/cli/fix.go` +- Modify: `internal/cli/pyramidize.go` +- Modify: `internal/cli/cli_test.go` + +- [ ] **Step 1: Write the failing CLI tests for --log flag** + +Append to `internal/cli/cli_test.go`: + +```go +func TestLogFlagValidation_InvalidLevel(t *testing.T) { + var stdout, stderr bytes.Buffer + enhancer := &mockEnhancer{result: "fixed"} + // Pass an invalid log level + err := runFixWith([]string{"--log", "banana", "hello"}, &stdout, &stderr, enhancer) + if err == nil { + t.Fatal("expected error for invalid log level") + } + if !strings.Contains(err.Error(), "invalid log level") { + t.Errorf("error should mention 'invalid log level', got: %v", err) + } +} + +func TestLogFlagValidation_ValidLevels(t *testing.T) { + for _, level := range []string{"off", "trace", "debug", "info", "warning", "error"} { + t.Run(level, func(t *testing.T) { + var stdout, stderr bytes.Buffer + enhancer := &mockEnhancer{result: "fixed"} + err := runFixWith([]string{"--log", level, "hello"}, &stdout, &stderr, enhancer) + if err != nil { + t.Fatalf("unexpected error for level %q: %v", level, err) + } + }) + } +} + +func TestLogFlagDefault_IsOff(t *testing.T) { + // When --log is not provided, no error and command works + var stdout, stderr bytes.Buffer + enhancer := &mockEnhancer{result: "fixed"} + err := runFixWith([]string{"hello"}, &stdout, &stderr, enhancer) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPyramidizeLogFlag(t *testing.T) { + var stdout, stderr bytes.Buffer + mock := &mockPyramidizer{ + result: pyramidize.PyramidizeResult{ + FullDocument: "output", + QualityFlags: []string{}, + }, + } + err := runPyramidizeWith([]string{"--log", "debug", "some text"}, &stdout, &stderr, mock) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} +``` + +Note: add `"strings"` to the import block. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /home/dev/projects/keylint && go test ./internal/cli/ -v -count=1 -run "LogFlag"` +Expected: FAIL — `--log` flag not defined yet + +- [ ] **Step 3: Add shared log flag helper to cli.go** + +Add to `internal/cli/cli.go`: + +```go +import ( + "fmt" + "io" + "os" + "strings" + + "keylint/internal/features/enhance" + "keylint/internal/features/settings" + "keylint/internal/logger" +) + +// validLogLevels lists accepted values for the --log flag. +var validLogLevels = map[string]bool{ + "off": true, "trace": true, "debug": true, + "info": true, "warning": true, "error": true, +} + +// addLogFlag registers a --log flag on the given FlagSet and returns the pointer. +func addLogFlag(fs *flag.FlagSet) *string { + return fs.String("log", "off", "Log level: off|trace|debug|info|warning|error") +} + +// initLogger validates the log level and initializes the logger. +// Sensitive is always false in CLI mode to prevent credential leaks to terminal. +func initLogger(level string) error { + if !validLogLevels[level] { + return fmt.Errorf("invalid log level %q — valid levels: off, trace, debug, info, warning, error", level) + } + logger.Init(level, false) + return nil +} +``` + +Also add `"flag"` to the import block. + +- [ ] **Step 4: Wire --log flag into runFixWith** + +In `internal/cli/fix.go`, update `runFixWith` to accept and validate the flag: + +```go +func runFixWith(args []string, stdout io.Writer, stderr io.Writer, svc enhancer) error { + fs := flag.NewFlagSet("fix", flag.ContinueOnError) + fs.SetOutput(stderr) + filePath := fs.String("f", "", "Input file path") + logLevel := addLogFlag(fs) + if err := fs.Parse(args); err != nil { + return err + } + + if err := initLogger(*logLevel); err != nil { + return err + } + + inlineText := strings.Join(fs.Args(), " ") + text, err := readInput(*filePath, inlineText, stdinIfPiped()) + if err != nil { + return err + } + + result, err := svc.Enhance(text) + if err != nil { + return fmt.Errorf("enhance failed: %w", err) + } + + fmt.Fprintln(stdout, result) + return nil +} +``` + +- [ ] **Step 5: Wire --log flag into runPyramidizeWith** + +In `internal/cli/pyramidize.go`, add the flag after the existing flags: + +```go +func runPyramidizeWith(args []string, stdout io.Writer, stderr io.Writer, svc pyramidizer) error { + fs := flag.NewFlagSet("pyramidize", flag.ContinueOnError) + fs.SetOutput(stderr) + + filePath := fs.String("f", "", "Input file path") + docType := fs.String("type", "auto", "Document type: auto|email|wiki|memo|powerpoint") + jsonOut := fs.Bool("json", false, "Output full result as JSON") + provider := fs.String("provider", "", "AI provider override: claude|openai|ollama") + model := fs.String("model", "", "Model override (e.g. claude-sonnet-4-6)") + style := fs.String("style", "professional", "Communication style") + relationship := fs.String("relationship", "professional", "Relationship level") + variant := fs.Int("variant", 0, "Prompt variant (0=latest, 1=v1, 2=v2)") + logLevel := addLogFlag(fs) + + if err := fs.Parse(args); err != nil { + return err + } + + if err := initLogger(*logLevel); err != nil { + return err + } + + inlineText := strings.Join(fs.Args(), " ") + text, err := readInput(*filePath, inlineText, stdinIfPiped()) + if err != nil { + return err + } + + req := pyramidize.PyramidizeRequest{ + Text: text, + DocumentType: *docType, + CommunicationStyle: *style, + RelationshipLevel: *relationship, + Provider: *provider, + Model: *model, + PromptVariant: *variant, + } + + result, err := svc.Pyramidize(req) + if err != nil { + return fmt.Errorf("pyramidize failed: %w", err) + } + + if *jsonOut { + enc := json.NewEncoder(stdout) + enc.SetIndent("", " ") + return enc.Encode(result) + } + + fmt.Fprintln(stdout, result.FullDocument) + return nil +} +``` + +- [ ] **Step 6: Update main.go** + +In `main.go:72`, change: +```go + logger.Init(cfg.DebugLogging, cfg.SensitiveLogging) +``` +To: +```go + logger.Init(cfg.LogLevel, cfg.SensitiveLogging) +``` + +- [ ] **Step 7: Run all tests** + +Run: `cd /home/dev/projects/keylint && go test ./internal/cli/ -v -count=1 && go test ./internal/logger/ -v -count=1 && go test ./internal/features/settings/ -v -count=1` +Expected: all PASS + +- [ ] **Step 8: Verify build compiles** + +Run: `cd /home/dev/projects/keylint && go build -o bin/KeyLint .` +Expected: compiles cleanly + +- [ ] **Step 9: Commit** + +```bash +git add main.go internal/cli/cli.go internal/cli/fix.go internal/cli/pyramidize.go internal/cli/cli_test.go +git commit -m "feat(cli): add --log flag for CLI commands, wire new logger.Init" +``` + +--- + +### Task 4: Sensitive Audit — Migrate All Sensitive() Calls to Redact() + +**Files:** +- Modify: `internal/features/enhance/service.go` +- Modify: `internal/features/pyramidize/api_claude.go` +- Modify: `internal/features/pyramidize/api_openai.go` +- Modify: `internal/features/pyramidize/api_ollama.go` + +- [ ] **Step 1: Migrate enhance/service.go** + +Replace all `logger.Sensitive(...)` calls with `logger.Debug(...)` + `logger.Redact()`: + +Line 107: +```go +// Before: +logger.Sensitive("enhance: request", "provider", "openai", "payload", string(payload)) +// After: +logger.Debug("enhance: request", "provider", "openai", "payload", logger.Redact(string(payload))) +``` + +Line 118: +```go +// Before: +logger.Sensitive("enhance: response", "provider", "openai", "status", resp.StatusCode, "body", string(body)) +// After: +logger.Debug("enhance: response", "provider", "openai", "status", resp.StatusCode, "body", logger.Redact(string(body))) +``` + +Line 140: +```go +// Before: +logger.Sensitive("enhance: request", "provider", "claude", "payload", string(payload)) +// After: +logger.Debug("enhance: request", "provider", "claude", "payload", logger.Redact(string(payload))) +``` + +Line 152: +```go +// Before: +logger.Sensitive("enhance: response", "provider", "claude", "status", resp.StatusCode, "body", string(body)) +// After: +logger.Debug("enhance: response", "provider", "claude", "status", resp.StatusCode, "body", logger.Redact(string(body))) +``` + +Line 174: +```go +// Before: +logger.Sensitive("enhance: request", "provider", "ollama", "payload", string(payload)) +// After: +logger.Debug("enhance: request", "provider", "ollama", "payload", logger.Redact(string(payload))) +``` + +Line 184: +```go +// Before: +logger.Sensitive("enhance: response", "provider", "ollama", "status", resp.StatusCode, "body", string(body)) +// After: +logger.Debug("enhance: response", "provider", "ollama", "status", resp.StatusCode, "body", logger.Redact(string(body))) +``` + +- [ ] **Step 2: Migrate pyramidize/api_claude.go** + +Line 31: +```go +// Before: +logger.Sensitive("pyramidize: claude request", "len", len(payload)) +// After: +logger.Debug("pyramidize: claude request", "payload", logger.Redact(string(payload))) +``` + +Line 48: +```go +// Before: +logger.Sensitive("pyramidize: claude response", "status", resp.StatusCode, "len", len(body)) +// After: +logger.Debug("pyramidize: claude response", "status", resp.StatusCode, "body", logger.Redact(string(body))) +``` + +- [ ] **Step 3: Migrate pyramidize/api_openai.go** + +Line 36: +```go +// Before: +logger.Sensitive("pyramidize: openai request", "len", len(payload)) +// After: +logger.Debug("pyramidize: openai request", "payload", logger.Redact(string(payload))) +``` + +Line 52: +```go +// Before: +logger.Sensitive("pyramidize: openai response", "status", resp.StatusCode, "len", len(body)) +// After: +logger.Debug("pyramidize: openai response", "status", resp.StatusCode, "body", logger.Redact(string(body))) +``` + +- [ ] **Step 4: Migrate pyramidize/api_ollama.go** + +Line 37: +```go +// Before: +logger.Sensitive("pyramidize: ollama request", "len", len(payload)) +// After: +logger.Debug("pyramidize: ollama request", "payload", logger.Redact(string(payload))) +``` + +Line 52: +```go +// Before: +logger.Sensitive("pyramidize: ollama response", "status", resp.StatusCode, "len", len(body)) +// After: +logger.Debug("pyramidize: ollama response", "status", resp.StatusCode, "body", logger.Redact(string(body))) +``` + +- [ ] **Step 5: Remove the old Sensitive function from logger.go** + +Delete these lines from `internal/logger/logger.go` (they should already be gone from the Task 1 rewrite, but verify): +```go +// Sensitive logs only when sensitive logging is enabled. +func Sensitive(msg string, args ...any) { + if sensitiveEnabled { + l.Debug(msg, args...) + } +} +``` + +- [ ] **Step 6: Verify build compiles and all Go tests pass** + +Run: `cd /home/dev/projects/keylint && go build -o bin/KeyLint . && go test ./internal/... -count=1` +Expected: compiles, all tests pass + +- [ ] **Step 7: Commit** + +```bash +git add internal/features/enhance/service.go internal/features/pyramidize/api_claude.go internal/features/pyramidize/api_openai.go internal/features/pyramidize/api_ollama.go internal/logger/logger.go +git commit -m "refactor(logging): migrate Sensitive() calls to Debug() + Redact()" +``` + +--- + +### Task 5: Frontend Log Bridge — Source Tagging and Redaction + +**Files:** +- Modify: `internal/features/logger/service.go` +- Create: `internal/features/logger/service_test.go` + +- [ ] **Step 1: Write the failing bridge tests** + +Create `internal/features/logger/service_test.go`: + +```go +package logger + +import ( + "bytes" + "strings" + "testing" + + "keylint/internal/logger" +) + +func TestLog_Error_AppearsWithSourceFrontend(t *testing.T) { + var buf bytes.Buffer + logger.InitWithWriter(&buf, "debug", false) + svc := NewService() + svc.Log("error", "something broke") + output := buf.String() + if !strings.Contains(output, "source=frontend") { + t.Errorf("expected source=frontend, got:\n%s", output) + } + if !strings.Contains(output, "something broke") { + t.Error("error message should always appear") + } +} + +func TestLog_Info_MsgRedactedWhenSensitiveOff(t *testing.T) { + var buf bytes.Buffer + logger.InitWithWriter(&buf, "debug", false) // sensitive OFF + svc := NewService() + svc.Log("info", "user typed secret text") + output := buf.String() + if strings.Contains(output, "user typed secret text") { + t.Error("info msg should be redacted when sensitive is off") + } + if !strings.Contains(output, "[redacted]") { + t.Error("expected [redacted] placeholder for msg") + } +} + +func TestLog_Info_MsgVisibleWhenSensitiveOn(t *testing.T) { + var buf bytes.Buffer + logger.InitWithWriter(&buf, "debug", true) // sensitive ON + svc := NewService() + svc.Log("info", "user typed secret text") + output := buf.String() + if !strings.Contains(output, "user typed secret text") { + t.Error("info msg should be visible when sensitive is on") + } +} + +func TestLog_UnknownLevel_DefaultsToInfo(t *testing.T) { + var buf bytes.Buffer + logger.InitWithWriter(&buf, "debug", true) + svc := NewService() + svc.Log("banana", "unknown level msg") + output := buf.String() + if !strings.Contains(output, "unknown level msg") { + t.Error("unknown level should default to info and appear at debug level") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /home/dev/projects/keylint && go test ./internal/features/logger/ -v -count=1` +Expected: FAIL — `InitWithWriter` not exported, service doesn't use FrontendLogger yet + +- [ ] **Step 3: Export InitWithWriter from logger package** + +In `internal/logger/logger.go`, rename `initWithWriter` to `InitWithWriter` (capitalize): + +```go +// InitWithWriter is used by tests to redirect output to a buffer. +func InitWithWriter(w io.Writer, level string, sensitive bool) { +``` + +Also update `logger_test.go` — the `initWithBuffer` helper calls `initWithWriter`, change it to `InitWithWriter`. + +- [ ] **Step 4: Rewrite the frontend bridge service** + +Rewrite `internal/features/logger/service.go`: + +```go +// Package logger exposes a Wails-registered service so the Angular frontend +// can forward log messages into the Go debug.log file. +package logger + +import "keylint/internal/logger" + +// Service forwards frontend log messages into the Go structured logger. +type Service struct{} + +// NewService creates a new LogService. +func NewService() *Service { return &Service{} } + +// Log writes a frontend message at the given level into debug.log. +// The msg is wrapped in Redact() because frontend messages may contain user text. +// Error and warn levels log the msg directly (operational), all others redact. +func (s *Service) Log(level, msg string) { + fl := logger.FrontendLogger() + switch level { + case "trace": + fl.Log(nil, logger.LevelTrace, "frontend", "msg", logger.Redact(msg)) + case "debug": + fl.Debug("frontend", "msg", logger.Redact(msg)) + case "warn": + fl.Warn("frontend", "msg", msg) + case "error": + fl.Error("frontend", "msg", msg) + default: + fl.Info("frontend", "msg", logger.Redact(msg)) + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd /home/dev/projects/keylint && go test ./internal/features/logger/ -v -count=1 && go test ./internal/logger/ -v -count=1` +Expected: all PASS + +- [ ] **Step 6: Verify full Go test suite** + +Run: `cd /home/dev/projects/keylint && go test ./internal/... -count=1` +Expected: all PASS + +- [ ] **Step 7: Commit** + +```bash +git add internal/logger/logger.go internal/logger/logger_test.go internal/features/logger/service.go internal/features/logger/service_test.go +git commit -m "feat(logger): frontend bridge with source tagging and Redact" +``` + +--- + +### Task 6: Settings UI — Replace Toggle with Select Dropdown + +**Files:** +- Modify: `frontend/src/app/features/settings/settings.component.ts` +- Modify: `frontend/src/app/features/settings/settings.component.spec.ts` +- Modify: `frontend/src/testing/wails-mock.ts` +- Modify: `frontend/src/app/core/wails.service.ts` + +- [ ] **Step 1: Write the failing UI tests** + +Add these tests to `frontend/src/app/features/settings/settings.component.spec.ts`: + +```typescript + it('log level select renders with all 6 options', () => { + const section = el.querySelector('[data-testid="log-level-section"]'); + expect(section).toBeTruthy(); + expect(section!.querySelector('p-select')).toBeTruthy(); + }); + + it('sensitive toggle is disabled when log level is off', () => { + component.settings!.log_level = 'off'; + fixture.detectChanges(); + const section = el.querySelector('[data-testid="sensitive-logging-section"]'); + const toggle = section!.querySelector('p-toggle-switch'); + expect(toggle).toBeTruthy(); + }); + + it('save sends log_level to backend', async () => { + component.settings!.log_level = 'warning'; + await component.save(); + expect(wailsMock.saveSettings).toHaveBeenCalledWith( + expect.objectContaining({ log_level: 'warning' }), + ); + }); +``` + +- [ ] **Step 2: Update wails-mock.ts** + +In `frontend/src/testing/wails-mock.ts`, update `defaultSettings`: + +Replace `debug_logging: false` with `log_level: 'off'`: + +```typescript +export const defaultSettings: Settings = { + active_provider: 'openai', + providers: { + ollama_url: '', + aws_region: '', + }, + shortcut_key: 'ctrl+g', + start_on_boot: false, + theme_preference: 'dark', + completed_setup: false, + log_level: 'off', + sensitive_logging: false, + update_channel: '', + app_presets: [], + pyramidize_quality_threshold: 0.65, +}; +``` + +- [ ] **Step 3: Update BROWSER_MODE_DEFAULTS in wails.service.ts** + +In `frontend/src/app/core/wails.service.ts`, update the `BROWSER_MODE_DEFAULTS`: + +Replace `debug_logging: false` with `log_level: 'off'`: + +```typescript +const BROWSER_MODE_DEFAULTS: Settings = { + active_provider: 'claude', + providers: { ollama_url: '', aws_region: '' }, + shortcut_key: 'ctrl+g', + start_on_boot: false, + theme_preference: 'dark', + completed_setup: false, + log_level: 'off', + sensitive_logging: false, + update_channel: '', + app_presets: [], + pyramidize_quality_threshold: 0.65, +}; +``` + +Note: The `Settings` type is auto-generated from Go bindings, so after the Go model change and `wails3 generate bindings`, the TS type will have `log_level: string` instead of `debug_logging: boolean`. If bindings can't be regenerated in the current environment, the type may need a manual interface update. + +- [ ] **Step 4: Update the settings component template** + +In `frontend/src/app/features/settings/settings.component.ts`, replace the debug-logging toggle section (lines 77-85) with a select dropdown: + +Replace: +```html +
+
+
+ + When enabled, writes a debug.log to the app config folder. Takes effect on next launch. +
+ +
+
+``` + +With: +```html +
+ + + Writes to ~/.config/KeyLint/debug.log (Linux) or %AppData%/KeyLint/debug.log (Windows). Takes effect on next launch. +
+``` + +Update the sensitive logging toggle's disabled binding (line 92): + +Replace: +```html + +``` + +With: +```html + +``` + +- [ ] **Step 5: Add logLevels options to the component class** + +In the component class, add the `logLevels` array after `updateChannels`: + +```typescript + readonly logLevels = [ + { label: 'Off', value: 'off' }, + { label: 'Trace', value: 'trace' }, + { label: 'Debug', value: 'debug' }, + { label: 'Info', value: 'info' }, + { label: 'Warning', value: 'warning' }, + { label: 'Error', value: 'error' }, + ]; +``` + +- [ ] **Step 6: Run frontend tests** + +Run: `cd /home/dev/projects/keylint/frontend && npm test` +Expected: all tests PASS + +- [ ] **Step 7: Update existing test assertions** + +The test `debug-logging section contains both toggle and hint text` references `[data-testid="debug-logging-section"]` which no longer exists. Replace it: + +```typescript + it('log-level section contains select and hint text', () => { + const section = el.querySelector('[data-testid="log-level-section"]'); + expect(section).toBeTruthy(); + expect(section!.querySelector('p-select')).toBeTruthy(); + expect(section!.querySelector('small')).toBeTruthy(); + }); +``` + +- [ ] **Step 8: Run frontend tests again** + +Run: `cd /home/dev/projects/keylint/frontend && npm test` +Expected: all tests PASS + +- [ ] **Step 9: Commit** + +```bash +git add frontend/src/app/features/settings/settings.component.ts frontend/src/app/features/settings/settings.component.spec.ts frontend/src/testing/wails-mock.ts frontend/src/app/core/wails.service.ts +git commit -m "feat(settings): replace debug toggle with log level dropdown" +``` + +--- + +### Task 7: Documentation — docs/logging.md and CLAUDE.md Reference + +**Files:** +- Create: `docs/logging.md` +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Create docs/logging.md** + +```markdown +# Logging Conventions + +## Log Levels + +| Level | When to use | +|---------|------------------------------------------------| +| off | Default. No logging. | +| trace | Verbose internals, hot-path detail | +| debug | Diagnostic info for developers | +| info | Normal operational events | +| warning | Recoverable issues | +| error | Failures requiring attention | + +## Sensitive Redaction + +**Rule:** If a log value could contain user text, API payloads, or credentials, wrap it in `logger.Redact()`. + +```go +// Safe metadata — no wrapping needed +logger.Info("enhance: start", "provider", cfg.ActiveProvider, "input_len", len(text)) + +// Sensitive data — wrap in Redact() +logger.Debug("enhance: request", "provider", "openai", "payload", logger.Redact(string(body))) +``` + +When `SensitiveLogging` is off, `Redact()` outputs `[redacted]`. When on, the real value is shown. + +Never wrap: provider names, status codes, byte lengths, error messages, config keys. +Always wrap: API request/response bodies, user text, clipboard content, API keys. + +## Source Tagging + +All log entries include a `source` attribute: +- `source=backend` — Go backend (automatic via default logger) +- `source=frontend` — Angular frontend (via the log bridge service) + +## CLI Usage + +```bash +./bin/KeyLint -fix --log debug "text to fix" +./bin/KeyLint -pyramidize --log info -f input.md +``` + +Sensitive logging is always off in CLI mode. + +## Settings + +UI: Settings → General → Log Level dropdown (Off/Trace/Debug/Info/Warning/Error) +JSON field: `"log_level": "off"` (replaces legacy `"debug_logging": true/false`) +``` + +- [ ] **Step 2: Add reference to CLAUDE.md** + +Add to the `CLAUDE.md` file, in the `## Rules & Reference` section, after the existing reference docs line: + +``` +**Logging conventions:** `docs/logging.md` (levels, Redact() usage, source tagging, CLI flags) +``` + +- [ ] **Step 3: Commit** + +```bash +git add docs/logging.md CLAUDE.md +git commit -m "docs: add logging conventions reference" +``` + +--- + +### Task 8: Final Verification + +- [ ] **Step 1: Run full Go test suite** + +Run: `cd /home/dev/projects/keylint && go test ./internal/... -v -count=1` +Expected: all PASS + +- [ ] **Step 2: Run frontend test suite** + +Run: `cd /home/dev/projects/keylint/frontend && npm test` +Expected: all PASS + +- [ ] **Step 3: Verify build compiles** + +Run: `cd /home/dev/projects/keylint && go build -o bin/KeyLint .` +Expected: compiles cleanly + +- [ ] **Step 4: Smoke test CLI logging** + +Run: `cd /home/dev/projects/keylint && echo "test" | ./bin/KeyLint -fix --log debug 2>&1 || true` +Expected: no crash (API call may fail without key, but the logger should initialize without error) + +- [ ] **Step 5: Verify no Sensitive() references remain** + +Run: `grep -r "logger\.Sensitive\|\.Sensitive(" internal/ --include="*.go"` +Expected: no matches (all migrated to Redact) + +- [ ] **Step 6: Final commit if any fixups needed** + +Only if previous steps required changes. From 44cae394dbd2aa44297621bd4805fa5fa7f2441a Mon Sep 17 00:00:00 2001 From: Michael <0xMMA@users.noreply.github.com> Date: Sun, 5 Apr 2026 20:47:05 +0000 Subject: [PATCH 04/11] feat(logger): add level-based logging, Redact via LogValuer, source tagging Replace bool-based Init(enabled, sensitive) with Init(level, sensitive) accepting "trace", "debug", "info", "warning", "error", or "off". Add custom TRACE level, Redact() wrapping values as slog.LogValuer for safe redaction, FrontendLogger() with source=frontend tagging, and 14 tests covering level filtering, redaction, and source tagging. Backward-compatible Sensitive() retained for Task 5 migration. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/logger/logger.go | 145 ++++++++++++++++++--- internal/logger/logger_test.go | 227 +++++++++++++++++++++++++++++++++ main.go | 6 +- 3 files changed, 362 insertions(+), 16 deletions(-) create mode 100644 internal/logger/logger_test.go diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 091ff93..80e818a 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -1,37 +1,62 @@ -// Package logger provides a thin wrapper around log/slog. +// Package logger provides a thin wrapper around log/slog with level-based +// filtering, sensitive-value redaction, and source tagging (backend/frontend). // It is disabled by default (all output discarded) and must be explicitly -// enabled via Init — typically driven by the DebugLogging settings field. +// enabled via Init — typically driven by the LogLevel settings field. package logger import ( + "context" "io" "log/slog" "os" "path/filepath" ) +// LevelTrace is a custom slog level below Debug. +const LevelTrace = slog.Level(-8) + +// Package-level state. var ( - l = slog.New(slog.NewTextHandler(io.Discard, nil)) - logFile *os.File + l = slog.New(slog.NewTextHandler(io.Discard, nil)) + baseH slog.Handler = slog.NewTextHandler(io.Discard, nil) + logFile *os.File sensitiveEnabled bool ) -// Init enables or disables file logging. When enabled, output goes to -// %AppData%/KeyLint/debug.log (Windows) or $HOME/.config/KeyLint/debug.log (Linux). -// sensitive controls whether Sensitive() calls are written to the log. -// Safe to call only once at startup; not goroutine-safe with concurrent log calls. -func Init(enabled, sensitive bool) { +// levelNames maps level strings to slog.Level values. +var levelNames = map[string]slog.Level{ + "trace": LevelTrace, + "debug": slog.LevelDebug, + "info": slog.LevelInfo, + "warning": slog.LevelWarn, + "error": slog.LevelError, +} + +// Init enables or disables structured logging. The level parameter is one of +// "trace", "debug", "info", "warning", "error", or "off". An unrecognised +// level is treated as "off". When a valid level is provided, output goes to +// ~/.config/KeyLint/debug.log (or the platform equivalent). +// sensitive controls whether Redact() reveals the underlying value. +func Init(level string, sensitive bool) { sensitiveEnabled = sensitive + if logFile != nil { _ = logFile.Close() logFile = nil } - if !enabled { - l = slog.New(slog.NewTextHandler(io.Discard, nil)) + + lvl, ok := levelNames[level] + if !ok { + // "off" or invalid → discard + baseH = slog.NewTextHandler(io.Discard, nil) + l = slog.New(baseH) return } + dir, err := os.UserConfigDir() if err != nil { + baseH = slog.NewTextHandler(io.Discard, nil) + l = slog.New(baseH) return } dir = filepath.Join(dir, "KeyLint") @@ -39,19 +64,109 @@ func Init(enabled, sensitive bool) { logPath := filepath.Join(dir, "debug.log") f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) if err != nil { + baseH = slog.NewTextHandler(io.Discard, nil) + l = slog.New(baseH) return } logFile = f - l = slog.New(slog.NewTextHandler(f, &slog.HandlerOptions{Level: slog.LevelDebug})) + + replaceFunc := func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.LevelKey { + if a.Value.Any().(slog.Level) == LevelTrace { + a.Value = slog.StringValue("TRACE") + } + } + return a + } + baseH = slog.NewTextHandler(f, &slog.HandlerOptions{ + Level: lvl, + ReplaceAttr: replaceFunc, + }) + l = slog.New(baseH).With("source", "backend") l.Info("logger initialized", "path", logPath, "sensitive", sensitive) } -func Info(msg string, args ...any) { l.Info(msg, args...) } +// initWithWriter is the same as Init but writes to w instead of a log file. +// Used by tests. +func initWithWriter(w io.Writer, level string, sensitive bool) { + sensitiveEnabled = sensitive + + if logFile != nil { + _ = logFile.Close() + logFile = nil + } + + lvl, ok := levelNames[level] + if !ok { + baseH = slog.NewTextHandler(io.Discard, nil) + l = slog.New(baseH) + return + } + + replaceFunc := func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.LevelKey { + if a.Value.Any().(slog.Level) == LevelTrace { + a.Value = slog.StringValue("TRACE") + } + } + return a + } + baseH = slog.NewTextHandler(w, &slog.HandlerOptions{ + Level: lvl, + ReplaceAttr: replaceFunc, + }) + l = slog.New(baseH).With("source", "backend") +} + +// FrontendLogger returns a logger tagged with source=frontend. +// It uses baseH (no source attr baked in) to avoid double source attributes. +func FrontendLogger() *slog.Logger { + return slog.New(baseH).With("source", "frontend") +} + +// --- Standard log functions --- + +// Trace logs at the custom TRACE level (below DEBUG). +func Trace(msg string, args ...any) { + l.Log(context.Background(), LevelTrace, msg, args...) +} + +// Debug logs at DEBUG level. func Debug(msg string, args ...any) { l.Debug(msg, args...) } -func Warn(msg string, args ...any) { l.Warn(msg, args...) } + +// Info logs at INFO level. +func Info(msg string, args ...any) { l.Info(msg, args...) } + +// Warn logs at WARN level. +func Warn(msg string, args ...any) { l.Warn(msg, args...) } + +// Error logs at ERROR level. func Error(msg string, args ...any) { l.Error(msg, args...) } -// Sensitive logs only when sensitive logging is enabled. Use for request/response bodies. +// --- Redaction --- + +// redacted wraps a value and implements slog.LogValuer. When sensitive logging +// is disabled, it resolves to "[redacted]" instead of the underlying value. +type redacted struct{ v any } + +// LogValue implements slog.LogValuer. +func (r redacted) LogValue() slog.Value { + if sensitiveEnabled { + return slog.AnyValue(r.v) + } + return slog.StringValue("[redacted]") +} + +// Redact wraps v so that it is shown as "[redacted]" in log output unless +// sensitive logging is enabled. Use for API keys, request bodies, etc. +func Redact(v any) slog.LogValuer { + return redacted{v: v} +} + +// --- Backward compatibility (will be removed in Task 5) --- + +// Sensitive logs only when sensitive logging is enabled. +// Deprecated: use Debug(msg, "key", Redact(value)) instead. func Sensitive(msg string, args ...any) { if sensitiveEnabled { l.Debug(msg, args...) diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go new file mode 100644 index 0000000..0072e0f --- /dev/null +++ b/internal/logger/logger_test.go @@ -0,0 +1,227 @@ +package logger + +import ( + "bytes" + "io" + "log/slog" + "testing" +) + +// initWithBuffer is a test helper that calls initWithWriter with a bytes.Buffer +// and returns the buffer. It registers cleanup to reset package-level state. +func initWithBuffer(t *testing.T, level string, sensitive bool) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + initWithWriter(&buf, level, sensitive) + t.Cleanup(func() { + l = slog.New(slog.NewTextHandler(io.Discard, nil)) + sensitiveEnabled = false + }) + return &buf +} + +// --- Level filtering tests --- + +func TestLevelOff_NoOutput(t *testing.T) { + buf := initWithBuffer(t, "off", false) + Trace("trace msg") + Debug("debug msg") + Info("info msg") + Warn("warn msg") + Error("error msg") + if buf.Len() != 0 { + t.Errorf("expected no output for level=off, got: %s", buf.String()) + } +} + +func TestLevelError_OnlyError(t *testing.T) { + buf := initWithBuffer(t, "error", false) + Debug("debug msg") + Info("info msg") + Warn("warn msg") + Error("error msg") + out := buf.String() + if !containsSubstring(out, "error msg") { + t.Error("expected error msg in output") + } + if containsSubstring(out, "debug msg") || containsSubstring(out, "info msg") || containsSubstring(out, "warn msg") { + t.Errorf("expected only error output, got: %s", out) + } +} + +func TestLevelWarning_WarnAndError(t *testing.T) { + buf := initWithBuffer(t, "warning", false) + Trace("trace msg") + Debug("debug msg") + Info("info msg") + Warn("warn msg") + Error("error msg") + out := buf.String() + if !containsSubstring(out, "warn msg") { + t.Error("expected warn msg in output") + } + if !containsSubstring(out, "error msg") { + t.Error("expected error msg in output") + } + if containsSubstring(out, "trace msg") || containsSubstring(out, "debug msg") || containsSubstring(out, "info msg") { + t.Errorf("expected only warn+error, got: %s", out) + } +} + +func TestLevelInfo_InfoWarnError(t *testing.T) { + buf := initWithBuffer(t, "info", false) + Trace("trace msg") + Debug("debug msg") + Info("info msg") + Warn("warn msg") + Error("error msg") + out := buf.String() + if !containsSubstring(out, "info msg") { + t.Error("expected info msg in output") + } + if !containsSubstring(out, "warn msg") { + t.Error("expected warn msg in output") + } + if !containsSubstring(out, "error msg") { + t.Error("expected error msg in output") + } + if containsSubstring(out, "trace msg") || containsSubstring(out, "debug msg") { + t.Errorf("expected no trace/debug, got: %s", out) + } +} + +func TestLevelDebug_DebugInfoWarnError(t *testing.T) { + buf := initWithBuffer(t, "debug", false) + Trace("trace msg") + Debug("debug msg") + Info("info msg") + Warn("warn msg") + Error("error msg") + out := buf.String() + if !containsSubstring(out, "debug msg") { + t.Error("expected debug msg in output") + } + if !containsSubstring(out, "info msg") { + t.Error("expected info msg in output") + } + if !containsSubstring(out, "warn msg") { + t.Error("expected warn msg in output") + } + if !containsSubstring(out, "error msg") { + t.Error("expected error msg in output") + } + if containsSubstring(out, "trace msg") { + t.Errorf("expected no trace, got: %s", out) + } +} + +func TestLevelTrace_AllLevels(t *testing.T) { + buf := initWithBuffer(t, "trace", false) + Trace("trace msg") + Debug("debug msg") + Info("info msg") + Warn("warn msg") + Error("error msg") + out := buf.String() + for _, msg := range []string{"trace msg", "debug msg", "info msg", "warn msg", "error msg"} { + if !containsSubstring(out, msg) { + t.Errorf("expected %q in output, got: %s", msg, out) + } + } +} + +func TestLevelInvalid_FallsBackToOff(t *testing.T) { + buf := initWithBuffer(t, "bogus", false) + Trace("trace msg") + Debug("debug msg") + Info("info msg") + Warn("warn msg") + Error("error msg") + if buf.Len() != 0 { + t.Errorf("expected no output for invalid level, got: %s", buf.String()) + } +} + +// --- Redact tests --- + +func TestRedact_SensitiveFalse_ShowsRedacted(t *testing.T) { + buf := initWithBuffer(t, "info", false) + Info("secret", "key", Redact("my-api-key-12345")) + out := buf.String() + if !containsSubstring(out, "[redacted]") { + t.Errorf("expected [redacted] in output, got: %s", out) + } + if containsSubstring(out, "my-api-key-12345") { + t.Errorf("expected secret NOT in output, got: %s", out) + } +} + +func TestRedact_SensitiveTrue_ShowsValue(t *testing.T) { + buf := initWithBuffer(t, "info", true) + Info("secret", "key", Redact("my-api-key-12345")) + out := buf.String() + if !containsSubstring(out, "my-api-key-12345") { + t.Errorf("expected actual value in output, got: %s", out) + } +} + +func TestRedact_Nil_SensitiveFalse_ShowsRedacted(t *testing.T) { + buf := initWithBuffer(t, "info", false) + Info("secret", "key", Redact(nil)) + out := buf.String() + if !containsSubstring(out, "[redacted]") { + t.Errorf("expected [redacted] in output, got: %s", out) + } +} + +func TestRedact_ByteSlice_SensitiveTrue_ShowsValue(t *testing.T) { + buf := initWithBuffer(t, "info", true) + Info("payload", "body", Redact([]byte("request-body-content"))) + out := buf.String() + if !containsSubstring(out, "request-body-content") { + t.Errorf("expected byte slice value in output, got: %s", out) + } +} + +func TestRedact_Negative_SecretNeverInOutput(t *testing.T) { + buf := initWithBuffer(t, "trace", false) + secret := "super-secret-token-xyz789" + Trace("auth", "token", Redact(secret)) + Debug("auth", "token", Redact(secret)) + Info("auth", "token", Redact(secret)) + Warn("auth", "token", Redact(secret)) + Error("auth", "token", Redact(secret)) + out := buf.String() + if containsSubstring(out, secret) { + t.Errorf("secret %q must NOT appear in output, got: %s", secret, out) + } +} + +// --- Source tagging tests --- + +func TestBackendLogger_SourceTagBackend(t *testing.T) { + buf := initWithBuffer(t, "info", false) + Info("hello from backend") + out := buf.String() + if !containsSubstring(out, "source=backend") { + t.Errorf("expected source=backend in output, got: %s", out) + } +} + +func TestFrontendLogger_SourceTagFrontend(t *testing.T) { + buf := initWithBuffer(t, "info", false) + fl := FrontendLogger() + fl.Info("hello from frontend") + out := buf.String() + if !containsSubstring(out, "source=frontend") { + t.Errorf("expected source=frontend in output, got: %s", out) + } + if containsSubstring(out, "source=backend") { + t.Errorf("expected NO source=backend in frontend logger output, got: %s", out) + } +} + +// containsSubstring is a small helper for readable assertions. +func containsSubstring(s, substr string) bool { + return len(s) >= len(substr) && bytes.Contains([]byte(s), []byte(substr)) +} diff --git a/main.go b/main.go index 098cc5f..6ca82a3 100644 --- a/main.go +++ b/main.go @@ -69,7 +69,11 @@ func main() { // Initialize structured logger based on the saved settings. cfg := services.Settings.Get() - logger.Init(cfg.DebugLogging, cfg.SensitiveLogging) + logLevel := "off" + if cfg.DebugLogging { + logLevel = "debug" + } + logger.Init(logLevel, cfg.SensitiveLogging) logger.Info("app initializing", "version", AppVersion) // Register backend services so the frontend can call their methods. From 6c5a1db088b93c1f686a7c47653c5d322b53d73f Mon Sep 17 00:00:00 2001 From: Michael <0xMMA@users.noreply.github.com> Date: Sun, 5 Apr 2026 20:50:04 +0000 Subject: [PATCH 05/11] feat(settings): migrate debug_logging bool to log_level string Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/features/settings/model.go | 3 +- internal/features/settings/service.go | 21 +++++ internal/features/settings/service_test.go | 105 +++++++++++++++++++++ main.go | 6 +- 4 files changed, 129 insertions(+), 6 deletions(-) diff --git a/internal/features/settings/model.go b/internal/features/settings/model.go index abdccf6..70bf7b2 100644 --- a/internal/features/settings/model.go +++ b/internal/features/settings/model.go @@ -31,7 +31,7 @@ type Settings struct { StartOnBoot bool `json:"start_on_boot"` ThemePreference string `json:"theme_preference"` // "light" | "dark" | "system" CompletedSetup bool `json:"completed_setup"` - DebugLogging bool `json:"debug_logging"` // writes debug.log to the app config dir + LogLevel string `json:"log_level"` // "off"|"trace"|"debug"|"info"|"warning"|"error" SensitiveLogging bool `json:"sensitive_logging"` // logs full API payloads; never share the log file while enabled UpdateChannel string `json:"update_channel"` // "" (auto-detect), "stable", or "pre-release" @@ -46,6 +46,7 @@ func Default() Settings { ActiveProvider: "openai", ShortcutKey: "ctrl+g", ThemePreference: "dark", + LogLevel: "off", PyramidizeQualityThreshold: DefaultQualityThreshold, } } diff --git a/internal/features/settings/service.go b/internal/features/settings/service.go index 0d25258..1224210 100644 --- a/internal/features/settings/service.go +++ b/internal/features/settings/service.go @@ -60,6 +60,27 @@ func (s *Service) load() error { logger.Error("settings: unmarshal failed", "err", err) return err } + + // Migrate legacy debug_logging → log_level. + // Check whether the raw JSON contains "log_level"; if not, this is a legacy + // file and we derive the level from the old "debug_logging" boolean. + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err == nil { + if _, hasLogLevel := raw["log_level"]; !hasLogLevel { + if val, ok := raw["debug_logging"]; ok { + var debugOn bool + if json.Unmarshal(val, &debugOn) == nil && debugOn { + s.current.LogLevel = "debug" + } else { + s.current.LogLevel = "off" + } + } else { + s.current.LogLevel = "off" + } + logger.Info("settings: migrated debug_logging to log_level", "log_level", s.current.LogLevel) + } + } + logger.Info("settings: loaded", "path", s.filePath) return nil } diff --git a/internal/features/settings/service_test.go b/internal/features/settings/service_test.go index 86b1f1f..9be8ecb 100644 --- a/internal/features/settings/service_test.go +++ b/internal/features/settings/service_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "keylint/internal/features/settings" @@ -142,3 +143,107 @@ func TestNewService_LoadsExistingFile(t *testing.T) { t.Error("expected completed_setup=true from loaded file") } } + +// --- LogLevel migration tests --- + +// writeLegacySettings writes raw JSON to the settings file for a temp-dir-based service. +func writeLegacySettings(t *testing.T, dir string, rawJSON string) { + t.Helper() + settingsDir := filepath.Join(dir, "KeyLint") + if err := os.MkdirAll(settingsDir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(settingsDir, "settings.json"), []byte(rawJSON), 0600); err != nil { + t.Fatal(err) + } +} + +func TestMigration_LegacyDebugLoggingTrue_SetsLogLevelDebug(t *testing.T) { + tmp := t.TempDir() + writeLegacySettings(t, tmp, `{"debug_logging": true}`) + + svc := newServiceAt(t, tmp) + got := svc.Get() + + if got.LogLevel != "debug" { + t.Errorf("expected LogLevel=debug for legacy debug_logging=true, got %q", got.LogLevel) + } +} + +func TestMigration_LegacyDebugLoggingFalse_SetsLogLevelOff(t *testing.T) { + tmp := t.TempDir() + writeLegacySettings(t, tmp, `{"debug_logging": false}`) + + svc := newServiceAt(t, tmp) + got := svc.Get() + + if got.LogLevel != "off" { + t.Errorf("expected LogLevel=off for legacy debug_logging=false, got %q", got.LogLevel) + } +} + +func TestMigration_NewLogLevel_LoadsCorrectly(t *testing.T) { + tmp := t.TempDir() + writeLegacySettings(t, tmp, `{"log_level": "warning"}`) + + svc := newServiceAt(t, tmp) + got := svc.Get() + + if got.LogLevel != "warning" { + t.Errorf("expected LogLevel=warning, got %q", got.LogLevel) + } +} + +func TestMigration_BothFields_LogLevelTakesPrecedence(t *testing.T) { + tmp := t.TempDir() + writeLegacySettings(t, tmp, `{"debug_logging": true, "log_level": "warning"}`) + + svc := newServiceAt(t, tmp) + got := svc.Get() + + if got.LogLevel != "warning" { + t.Errorf("expected LogLevel=warning (log_level takes precedence), got %q", got.LogLevel) + } +} + +func TestMigration_RoundTrip_LegacyToNewFormat(t *testing.T) { + tmp := t.TempDir() + writeLegacySettings(t, tmp, `{"debug_logging": true, "active_provider": "openai"}`) + + svc := newServiceAt(t, tmp) + got := svc.Get() + + // Save the migrated settings. + if err := svc.Save(got); err != nil { + t.Fatalf("Save: %v", err) + } + + // Read the raw file and check the JSON. + filePath := filepath.Join(tmp, "KeyLint", "settings.json") + data, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + raw := string(data) + + if !strings.Contains(raw, `"log_level"`) { + t.Error("saved file should contain log_level field") + } + if strings.Contains(raw, `"debug_logging"`) { + t.Error("saved file should NOT contain legacy debug_logging field") + } + + // Reload and verify. + svc2 := newServiceAt(t, tmp) + got2 := svc2.Get() + if got2.LogLevel != "debug" { + t.Errorf("after round-trip, expected LogLevel=debug, got %q", got2.LogLevel) + } +} + +func TestDefault_LogLevel_IsOff(t *testing.T) { + d := settings.Default() + if d.LogLevel != "off" { + t.Errorf("expected Default().LogLevel=off, got %q", d.LogLevel) + } +} diff --git a/main.go b/main.go index 6ca82a3..f0945bf 100644 --- a/main.go +++ b/main.go @@ -69,11 +69,7 @@ func main() { // Initialize structured logger based on the saved settings. cfg := services.Settings.Get() - logLevel := "off" - if cfg.DebugLogging { - logLevel = "debug" - } - logger.Init(logLevel, cfg.SensitiveLogging) + logger.Init(cfg.LogLevel, cfg.SensitiveLogging) logger.Info("app initializing", "version", AppVersion) // Register backend services so the frontend can call their methods. From 830d6d00bddc458eb66176583be48fdacc120cdc Mon Sep 17 00:00:00 2001 From: Michael <0xMMA@users.noreply.github.com> Date: Sun, 5 Apr 2026 20:53:01 +0000 Subject: [PATCH 06/11] feat(cli): add --log flag for CLI commands Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/cli/cli.go | 27 ++++++++++++++++++++ internal/cli/cli_test.go | 51 ++++++++++++++++++++++++++++++++++++++ internal/cli/fix.go | 4 +++ internal/cli/pyramidize.go | 4 +++ 4 files changed, 86 insertions(+) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 4397e5b..1750e0f 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -1,6 +1,7 @@ package cli import ( + "flag" "fmt" "io" "os" @@ -8,8 +9,34 @@ import ( "keylint/internal/features/enhance" "keylint/internal/features/settings" + "keylint/internal/logger" ) +// validLogLevels enumerates accepted --log values. +var validLogLevels = map[string]bool{ + "off": true, + "trace": true, + "debug": true, + "info": true, + "warning": true, + "error": true, +} + +// addLogFlag registers the --log flag on the given FlagSet. +func addLogFlag(fs *flag.FlagSet) *string { + return fs.String("log", "off", "Log level: off|trace|debug|info|warning|error") +} + +// initLogger validates the level and initialises the logger. +// Sensitive is always false in CLI mode. +func initLogger(level string) error { + if !validLogLevels[level] { + return fmt.Errorf("invalid log level %q — must be one of: off, trace, debug, info, warning, error", level) + } + logger.Init(level, false) + return nil +} + // Run dispatches a CLI command. args[0] is the command name ("-fix" or "-pyramidize"). // stdout receives the command output; stderr receives error messages. func Run(args []string, stdout io.Writer, stderr io.Writer) error { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 6427667..0fef784 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -5,6 +5,8 @@ import ( "os" "strings" "testing" + + "keylint/internal/features/pyramidize" ) func TestRunUnknownCommand(t *testing.T) { @@ -83,3 +85,52 @@ func TestReadInputFilePriority(t *testing.T) { t.Fatalf("got %q, want %q", got, "from file") } } + +func TestLogFlagValidation_InvalidLevel(t *testing.T) { + var stdout, stderr bytes.Buffer + mock := &mockEnhancer{result: "fixed"} + err := runFixWith([]string{"--log", "banana", "hello"}, &stdout, &stderr, mock) + if err == nil { + t.Fatal("expected error for invalid log level") + } + if !strings.Contains(err.Error(), "invalid log level") { + t.Fatalf("error %q should contain %q", err.Error(), "invalid log level") + } +} + +func TestLogFlagValidation_ValidLevels(t *testing.T) { + levels := []string{"off", "trace", "debug", "info", "warning", "error"} + for _, level := range levels { + t.Run(level, func(t *testing.T) { + var stdout, stderr bytes.Buffer + mock := &mockEnhancer{result: "fixed"} + err := runFixWith([]string{"--log", level, "hello"}, &stdout, &stderr, mock) + if err != nil { + t.Fatalf("unexpected error for level %q: %v", level, err) + } + }) + } +} + +func TestLogFlagDefault_IsOff(t *testing.T) { + var stdout, stderr bytes.Buffer + mock := &mockEnhancer{result: "fixed"} + err := runFixWith([]string{"hello"}, &stdout, &stderr, mock) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPyramidizeLogFlag(t *testing.T) { + var stdout, stderr bytes.Buffer + mock := &mockPyramidizer{ + result: pyramidize.PyramidizeResult{ + FullDocument: "output", + QualityFlags: []string{}, + }, + } + err := runPyramidizeWith([]string{"--log", "debug", "hello"}, &stdout, &stderr, mock) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/cli/fix.go b/internal/cli/fix.go index fa31723..b2d043c 100644 --- a/internal/cli/fix.go +++ b/internal/cli/fix.go @@ -26,9 +26,13 @@ func runFixWith(args []string, stdout io.Writer, stderr io.Writer, svc enhancer) fs := flag.NewFlagSet("fix", flag.ContinueOnError) fs.SetOutput(stderr) filePath := fs.String("f", "", "Input file path") + logLevel := addLogFlag(fs) if err := fs.Parse(args); err != nil { return err } + if err := initLogger(*logLevel); err != nil { + return err + } inlineText := strings.Join(fs.Args(), " ") text, err := readInput(*filePath, inlineText, stdinIfPiped()) diff --git a/internal/cli/pyramidize.go b/internal/cli/pyramidize.go index 7de176b..3128fa8 100644 --- a/internal/cli/pyramidize.go +++ b/internal/cli/pyramidize.go @@ -37,10 +37,14 @@ func runPyramidizeWith(args []string, stdout io.Writer, stderr io.Writer, svc py style := fs.String("style", "professional", "Communication style") relationship := fs.String("relationship", "professional", "Relationship level") variant := fs.Int("variant", 0, "Prompt variant (0=latest, 1=v1, 2=v2)") + logLevel := addLogFlag(fs) if err := fs.Parse(args); err != nil { return err } + if err := initLogger(*logLevel); err != nil { + return err + } inlineText := strings.Join(fs.Args(), " ") text, err := readInput(*filePath, inlineText, stdinIfPiped()) From b9f7e4328f88422af6bcdf2be202f79957d4d86f Mon Sep 17 00:00:00 2001 From: Michael <0xMMA@users.noreply.github.com> Date: Sun, 5 Apr 2026 20:54:45 +0000 Subject: [PATCH 07/11] refactor(logging): migrate Sensitive() calls to Debug() + Redact() Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/features/enhance/service.go | 12 ++++++------ internal/features/pyramidize/api_claude.go | 4 ++-- internal/features/pyramidize/api_ollama.go | 4 ++-- internal/features/pyramidize/api_openai.go | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/internal/features/enhance/service.go b/internal/features/enhance/service.go index ba21f32..d5e9e78 100644 --- a/internal/features/enhance/service.go +++ b/internal/features/enhance/service.go @@ -104,7 +104,7 @@ func callOpenAI(client *http.Client, text, apiKey string) (string, error) { {Role: "user", Content: text}, }, }) - logger.Sensitive("enhance: request", "provider", "openai", "payload", string(payload)) + logger.Debug("enhance: request", "provider", "openai", "payload", logger.Redact(string(payload))) req, _ := http.NewRequest(http.MethodPost, "https://api.openai.com/v1/chat/completions", bytes.NewReader(payload)) req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Content-Type", "application/json") @@ -115,7 +115,7 @@ func callOpenAI(client *http.Client, text, apiKey string) (string, error) { } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) - logger.Sensitive("enhance: response", "provider", "openai", "status", resp.StatusCode, "body", string(body)) + logger.Debug("enhance: response", "provider", "openai", "status", resp.StatusCode, "body", logger.Redact(string(body))) if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("OpenAI error %d: %s", resp.StatusCode, body) } @@ -137,7 +137,7 @@ func callClaude(client *http.Client, text, apiKey string) (string, error) { "system": systemPrompt, "messages": []map[string]string{{"role": "user", "content": text}}, }) - logger.Sensitive("enhance: request", "provider", "claude", "payload", string(payload)) + logger.Debug("enhance: request", "provider", "claude", "payload", logger.Redact(string(payload))) req, _ := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages", bytes.NewReader(payload)) req.Header.Set("x-api-key", apiKey) req.Header.Set("anthropic-version", "2023-06-01") @@ -149,7 +149,7 @@ func callClaude(client *http.Client, text, apiKey string) (string, error) { } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) - logger.Sensitive("enhance: response", "provider", "claude", "status", resp.StatusCode, "body", string(body)) + logger.Debug("enhance: response", "provider", "claude", "status", resp.StatusCode, "body", logger.Redact(string(body))) if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("Claude error %d: %s", resp.StatusCode, body) } @@ -171,7 +171,7 @@ func callOllama(client *http.Client, text, baseURL string) (string, error) { "prompt": systemPrompt + "\n\nText: " + text, "stream": false, }) - logger.Sensitive("enhance: request", "provider", "ollama", "payload", string(payload)) + logger.Debug("enhance: request", "provider", "ollama", "payload", logger.Redact(string(payload))) req, _ := http.NewRequest(http.MethodPost, baseURL+"/api/generate", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") @@ -181,7 +181,7 @@ func callOllama(client *http.Client, text, baseURL string) (string, error) { } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) - logger.Sensitive("enhance: response", "provider", "ollama", "status", resp.StatusCode, "body", string(body)) + logger.Debug("enhance: response", "provider", "ollama", "status", resp.StatusCode, "body", logger.Redact(string(body))) if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("Ollama error %d: %s", resp.StatusCode, body) } diff --git a/internal/features/pyramidize/api_claude.go b/internal/features/pyramidize/api_claude.go index 92b08dc..853004c 100644 --- a/internal/features/pyramidize/api_claude.go +++ b/internal/features/pyramidize/api_claude.go @@ -28,7 +28,7 @@ func callClaude(client *http.Client, systemPrompt, userMessage, apiKey, model st return "", fmt.Errorf("pyramidize/claude: marshal error: %w", err) } - logger.Sensitive("pyramidize: claude request", "len", len(payload)) + logger.Debug("pyramidize: claude request", "payload", logger.Redact(string(payload))) req, err := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages", bytes.NewReader(payload)) if err != nil { @@ -45,7 +45,7 @@ func callClaude(client *http.Client, systemPrompt, userMessage, apiKey, model st defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) - logger.Sensitive("pyramidize: claude response", "status", resp.StatusCode, "len", len(body)) + logger.Debug("pyramidize: claude response", "status", resp.StatusCode, "body", logger.Redact(string(body))) if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("pyramidize/claude: error %d: %s", resp.StatusCode, body) diff --git a/internal/features/pyramidize/api_ollama.go b/internal/features/pyramidize/api_ollama.go index a150c8b..0bc6892 100644 --- a/internal/features/pyramidize/api_ollama.go +++ b/internal/features/pyramidize/api_ollama.go @@ -34,7 +34,7 @@ func callOllama(client *http.Client, systemPrompt, userMessage, baseURL, model s return "", fmt.Errorf("pyramidize/ollama: marshal error: %w", err) } - logger.Sensitive("pyramidize: ollama request", "len", len(payload)) + logger.Debug("pyramidize: ollama request", "payload", logger.Redact(string(payload))) req, err := http.NewRequest(http.MethodPost, baseURL+"/api/generate", bytes.NewReader(payload)) if err != nil { @@ -49,7 +49,7 @@ func callOllama(client *http.Client, systemPrompt, userMessage, baseURL, model s defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) - logger.Sensitive("pyramidize: ollama response", "status", resp.StatusCode, "len", len(body)) + logger.Debug("pyramidize: ollama response", "status", resp.StatusCode, "body", logger.Redact(string(body))) if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("pyramidize/ollama: error %d: %s", resp.StatusCode, body) diff --git a/internal/features/pyramidize/api_openai.go b/internal/features/pyramidize/api_openai.go index b1d8de3..1a90aca 100644 --- a/internal/features/pyramidize/api_openai.go +++ b/internal/features/pyramidize/api_openai.go @@ -33,7 +33,7 @@ func callOpenAI(client *http.Client, systemPrompt, userMessage, apiKey, model st return "", fmt.Errorf("pyramidize/openai: marshal error: %w", err) } - logger.Sensitive("pyramidize: openai request", "len", len(payload)) + logger.Debug("pyramidize: openai request", "payload", logger.Redact(string(payload))) req, err := http.NewRequest(http.MethodPost, "https://api.openai.com/v1/chat/completions", bytes.NewReader(payload)) if err != nil { @@ -49,7 +49,7 @@ func callOpenAI(client *http.Client, systemPrompt, userMessage, apiKey, model st defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) - logger.Sensitive("pyramidize: openai response", "status", resp.StatusCode, "len", len(body)) + logger.Debug("pyramidize: openai response", "status", resp.StatusCode, "body", logger.Redact(string(body))) if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("pyramidize/openai: error %d: %s", resp.StatusCode, body) From 8027aab9113d2ab4a641945a6a99b305c47d2542 Mon Sep 17 00:00:00 2001 From: Michael <0xMMA@users.noreply.github.com> Date: Sun, 5 Apr 2026 20:56:37 +0000 Subject: [PATCH 08/11] feat(logger): frontend bridge with source tagging and Redact Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/features/logger/service.go | 19 ++++-- internal/features/logger/service_test.go | 83 ++++++++++++++++++++++++ internal/logger/logger.go | 13 +--- internal/logger/logger_test.go | 2 +- 4 files changed, 100 insertions(+), 17 deletions(-) create mode 100644 internal/features/logger/service_test.go diff --git a/internal/features/logger/service.go b/internal/features/logger/service.go index 4471750..6ec5e11 100644 --- a/internal/features/logger/service.go +++ b/internal/features/logger/service.go @@ -2,7 +2,11 @@ // can forward log messages into the Go debug.log file. package logger -import "keylint/internal/logger" +import ( + "context" + + "keylint/internal/logger" +) // Service forwards frontend log messages into the Go structured logger. type Service struct{} @@ -11,15 +15,20 @@ type Service struct{} func NewService() *Service { return &Service{} } // Log writes a frontend message at the given level into debug.log. +// The msg is wrapped in Redact() because frontend messages may contain user text. +// Error and warn levels log the msg directly (operational). func (s *Service) Log(level, msg string) { + fl := logger.FrontendLogger() switch level { + case "trace": + fl.Log(context.Background(), logger.LevelTrace, "frontend", "msg", logger.Redact(msg)) case "debug": - logger.Debug("frontend: " + msg) + fl.Debug("frontend", "msg", logger.Redact(msg)) case "warn": - logger.Warn("frontend: " + msg) + fl.Warn("frontend", "msg", msg) case "error": - logger.Error("frontend: " + msg) + fl.Error("frontend", "msg", msg) default: - logger.Info("frontend: " + msg) + fl.Info("frontend", "msg", logger.Redact(msg)) } } diff --git a/internal/features/logger/service_test.go b/internal/features/logger/service_test.go new file mode 100644 index 0000000..b86be9d --- /dev/null +++ b/internal/features/logger/service_test.go @@ -0,0 +1,83 @@ +package logger + +import ( + "bytes" + "strings" + "testing" + + "keylint/internal/logger" +) + +func TestLog_Error_AppearsWithSourceFrontend(t *testing.T) { + var buf bytes.Buffer + logger.InitWithWriter(&buf, "debug", false) + t.Cleanup(func() { logger.InitWithWriter(&bytes.Buffer{}, "off", false) }) + + svc := NewService() + svc.Log("error", "something broke") + output := buf.String() + if !strings.Contains(output, "source=frontend") { + t.Errorf("expected source=frontend, got:\n%s", output) + } + if !strings.Contains(output, "something broke") { + t.Error("error message should always appear") + } +} + +func TestLog_Info_MsgRedactedWhenSensitiveOff(t *testing.T) { + var buf bytes.Buffer + logger.InitWithWriter(&buf, "debug", false) + t.Cleanup(func() { logger.InitWithWriter(&bytes.Buffer{}, "off", false) }) + + svc := NewService() + svc.Log("info", "user typed secret text") + output := buf.String() + if strings.Contains(output, "user typed secret text") { + t.Error("info msg should be redacted when sensitive is off") + } + if !strings.Contains(output, "[redacted]") { + t.Error("expected [redacted] placeholder for msg") + } +} + +func TestLog_Info_MsgVisibleWhenSensitiveOn(t *testing.T) { + var buf bytes.Buffer + logger.InitWithWriter(&buf, "debug", true) + t.Cleanup(func() { logger.InitWithWriter(&bytes.Buffer{}, "off", false) }) + + svc := NewService() + svc.Log("info", "user typed secret text") + output := buf.String() + if !strings.Contains(output, "user typed secret text") { + t.Error("info msg should be visible when sensitive is on") + } +} + +func TestLog_UnknownLevel_DefaultsToInfo(t *testing.T) { + var buf bytes.Buffer + logger.InitWithWriter(&buf, "debug", true) + t.Cleanup(func() { logger.InitWithWriter(&bytes.Buffer{}, "off", false) }) + + svc := NewService() + svc.Log("banana", "unknown level msg") + output := buf.String() + if !strings.Contains(output, "unknown level msg") { + t.Error("unknown level should default to info and appear") + } +} + +func TestLog_Warn_MsgNotRedacted(t *testing.T) { + var buf bytes.Buffer + logger.InitWithWriter(&buf, "debug", false) // sensitive OFF + t.Cleanup(func() { logger.InitWithWriter(&bytes.Buffer{}, "off", false) }) + + svc := NewService() + svc.Log("warn", "low disk space") + output := buf.String() + if !strings.Contains(output, "low disk space") { + t.Error("warn msg should appear unredacted") + } + if !strings.Contains(output, "source=frontend") { + t.Error("expected source=frontend") + } +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 80e818a..750a43c 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -86,9 +86,9 @@ func Init(level string, sensitive bool) { l.Info("logger initialized", "path", logPath, "sensitive", sensitive) } -// initWithWriter is the same as Init but writes to w instead of a log file. +// InitWithWriter is the same as Init but writes to w instead of a log file. // Used by tests. -func initWithWriter(w io.Writer, level string, sensitive bool) { +func InitWithWriter(w io.Writer, level string, sensitive bool) { sensitiveEnabled = sensitive if logFile != nil { @@ -163,12 +163,3 @@ func Redact(v any) slog.LogValuer { return redacted{v: v} } -// --- Backward compatibility (will be removed in Task 5) --- - -// Sensitive logs only when sensitive logging is enabled. -// Deprecated: use Debug(msg, "key", Redact(value)) instead. -func Sensitive(msg string, args ...any) { - if sensitiveEnabled { - l.Debug(msg, args...) - } -} diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go index 0072e0f..b8b66eb 100644 --- a/internal/logger/logger_test.go +++ b/internal/logger/logger_test.go @@ -12,7 +12,7 @@ import ( func initWithBuffer(t *testing.T, level string, sensitive bool) *bytes.Buffer { t.Helper() var buf bytes.Buffer - initWithWriter(&buf, level, sensitive) + InitWithWriter(&buf, level, sensitive) t.Cleanup(func() { l = slog.New(slog.NewTextHandler(io.Discard, nil)) sensitiveEnabled = false From e91d5ae6965feaaecfedbe56d4e70810b62c7e25 Mon Sep 17 00:00:00 2001 From: Michael <0xMMA@users.noreply.github.com> Date: Sun, 5 Apr 2026 20:59:03 +0000 Subject: [PATCH 09/11] feat(settings): replace debug toggle with log level dropdown Co-Authored-By: Claude Opus 4.6 (1M context) --- .../internal/features/settings/models.js | 8 +++--- frontend/src/app/core/wails.service.ts | 2 +- .../settings/settings.component.spec.ts | 14 ++++++++-- .../features/settings/settings.component.ts | 28 +++++++++++++------ frontend/src/testing/wails-mock.ts | 2 +- 5 files changed, 36 insertions(+), 18 deletions(-) diff --git a/frontend/bindings/keylint/internal/features/settings/models.js b/frontend/bindings/keylint/internal/features/settings/models.js index 98a2771..9ab2949 100644 --- a/frontend/bindings/keylint/internal/features/settings/models.js +++ b/frontend/bindings/keylint/internal/features/settings/models.js @@ -177,13 +177,13 @@ export class Settings { */ this["completed_setup"] = false; } - if (!("debug_logging" in $$source)) { + if (!("log_level" in $$source)) { /** - * writes debug.log to the app config dir + * "off" | "trace" | "debug" | "info" | "warning" | "error" * @member - * @type {boolean} + * @type {string} */ - this["debug_logging"] = false; + this["log_level"] = ""; } if (!("sensitive_logging" in $$source)) { /** diff --git a/frontend/src/app/core/wails.service.ts b/frontend/src/app/core/wails.service.ts index a9900bb..f9ba151 100644 --- a/frontend/src/app/core/wails.service.ts +++ b/frontend/src/app/core/wails.service.ts @@ -27,7 +27,7 @@ const BROWSER_MODE_DEFAULTS: Settings = { start_on_boot: false, theme_preference: 'dark', completed_setup: false, - debug_logging: false, + log_level: 'off', sensitive_logging: false, update_channel: '', app_presets: [], diff --git a/frontend/src/app/features/settings/settings.component.spec.ts b/frontend/src/app/features/settings/settings.component.spec.ts index c56f44b..ac8e2ed 100644 --- a/frontend/src/app/features/settings/settings.component.spec.ts +++ b/frontend/src/app/features/settings/settings.component.spec.ts @@ -63,10 +63,10 @@ describe('SettingsComponent', () => { expect(el.querySelector('p-tablist')).toBeTruthy(); }); - it('debug-logging section contains both toggle and hint text', () => { - const section = el.querySelector('[data-testid="debug-logging-section"]'); + it('log-level section contains select and hint text', () => { + const section = el.querySelector('[data-testid="log-level-section"]'); expect(section).toBeTruthy(); - expect(section!.querySelector('p-toggle-switch')).toBeTruthy(); + expect(section!.querySelector('p-select')).toBeTruthy(); expect(section!.querySelector('small')).toBeTruthy(); }); @@ -140,6 +140,14 @@ describe('SettingsComponent', () => { expect(component.saved).toBe(false); }); + it('save sends log_level to backend', async () => { + component.settings!.log_level = 'warning'; + await component.save(); + expect(wailsMock.saveSettings).toHaveBeenCalledWith( + expect.objectContaining({ log_level: 'warning' }), + ); + }); + it('save() does nothing when settings is null', async () => { component.settings = null; await component.save(); diff --git a/frontend/src/app/features/settings/settings.component.ts b/frontend/src/app/features/settings/settings.component.ts index 8995f32..2f523a9 100644 --- a/frontend/src/app/features/settings/settings.component.ts +++ b/frontend/src/app/features/settings/settings.component.ts @@ -74,14 +74,15 @@ interface ProviderKey { optionValue="value" /> -
-
-
- - When enabled, writes a debug.log to the app config folder. Takes effect on next launch. -
- -
+
+ + + Writes to ~/.config/KeyLint/debug.log (Linux) or %AppData%/KeyLint/debug.log (Windows). Takes effect on next launch.
@@ -89,7 +90,7 @@ interface ProviderKey { Logs full API request payloads and responses. Do not share the log file while this is enabled. Takes effect on next launch.
- +
@@ -410,6 +411,15 @@ export class SettingsComponent implements OnInit { { label: 'Pre-release', value: 'pre-release' }, ]; + readonly logLevels = [ + { label: 'Off', value: 'off' }, + { label: 'Trace', value: 'trace' }, + { label: 'Debug', value: 'debug' }, + { label: 'Info', value: 'info' }, + { label: 'Warning', value: 'warning' }, + { label: 'Error', value: 'error' }, + ]; + readonly docTypeOptions = DOCUMENT_TYPE_OPTIONS; providerKeys: ProviderKey[] = [ diff --git a/frontend/src/testing/wails-mock.ts b/frontend/src/testing/wails-mock.ts index b89d480..719f5db 100644 --- a/frontend/src/testing/wails-mock.ts +++ b/frontend/src/testing/wails-mock.ts @@ -12,7 +12,7 @@ export const defaultSettings: Settings = { start_on_boot: false, theme_preference: 'dark', completed_setup: false, - debug_logging: false, + log_level: 'off', sensitive_logging: false, update_channel: '', app_presets: [], From 3b5a5f4146d0877bf874d3c85129ddb5f8afda17 Mon Sep 17 00:00:00 2001 From: Michael <0xMMA@users.noreply.github.com> Date: Sun, 5 Apr 2026 21:00:19 +0000 Subject: [PATCH 10/11] docs: add logging conventions reference Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 2 ++ docs/logging.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 docs/logging.md diff --git a/CLAUDE.md b/CLAUDE.md index 737016d..ee60905 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,4 +85,6 @@ KeyLint is a desktop app that fixes/enhances clipboard text via AI (OpenAI, Anth **Reference docs:** `.claude/docs/architecture.md` (service wiring, RPC bridge, platform differences), `.claude/docs/testing.md` (detailed patterns), `.claude/docs/versioning.md` (release pipeline, CI) +**Logging conventions:** `docs/logging.md` (levels, Redact() usage, source tagging, CLI flags) + **Pyramidize docs:** `docs/pyramidize/` (requirements, ADR, quality status, NLP/LangChain research, UX roadmap) diff --git a/docs/logging.md b/docs/logging.md new file mode 100644 index 0000000..85e0f26 --- /dev/null +++ b/docs/logging.md @@ -0,0 +1,51 @@ +# Logging Conventions + +## Log Levels + +| Level | When to use | +|---------|------------------------------------------------| +| off | Default. No logging. | +| trace | Verbose internals, hot-path detail | +| debug | Diagnostic info for developers | +| info | Normal operational events | +| warning | Recoverable issues | +| error | Failures requiring attention | + +## Sensitive Redaction + +**Rule:** If a log value could contain user text, API payloads, or credentials, wrap it in `logger.Redact()`. + +```go +// Safe metadata — no wrapping needed +logger.Info("enhance: start", "provider", cfg.ActiveProvider, "input_len", len(text)) + +// Sensitive data — wrap in Redact() +logger.Debug("enhance: request", "provider", "openai", "payload", logger.Redact(string(body))) +``` + +When `SensitiveLogging` is off, `Redact()` outputs `[redacted]`. When on, the real value is shown. Uses slog's native `LogValuer` interface. + +Never wrap: provider names, status codes, byte lengths, error messages, config keys. +Always wrap: API request/response bodies, user text, clipboard content, API keys. + +## Source Tagging + +All log entries include a `source` attribute: +- `source=backend` — Go backend (automatic via default logger instance) +- `source=frontend` — Angular frontend (via the log bridge service) + +## CLI Usage + +```bash +./bin/KeyLint -fix --log debug "text to fix" +./bin/KeyLint -pyramidize --log info -f input.md +``` + +Valid levels: `off`, `trace`, `debug`, `info`, `warning`, `error`. Default: `off`. +Sensitive logging is always off in CLI mode to prevent credential leaks to terminal. + +## Settings + +- **UI:** Settings > General > Log Level dropdown (Off/Trace/Debug/Info/Warning/Error) +- **JSON field:** `"log_level": "off"` (replaces legacy `"debug_logging": true/false`) +- **Migration:** Legacy `debug_logging: true` is auto-migrated to `log_level: "debug"` on load From 66024457a3e886f7da368dbe6d72f379287d3d03 Mon Sep 17 00:00:00 2001 From: Michael <0xMMA@users.noreply.github.com> Date: Sun, 5 Apr 2026 21:07:17 +0000 Subject: [PATCH 11/11] fix(logger): use atomic.Bool for sensitiveEnabled, extract replaceAttr, update spec Addresses code review: sensitiveEnabled is now goroutine-safe via sync/atomic. Extracted duplicate replaceAttr lambda to package-level function. Updated spec to document that error/warn frontend messages are not redacted. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-05-unified-logging-design.md | 2 +- internal/logger/logger.go | 49 +++++++++---------- internal/logger/logger_test.go | 6 +-- 3 files changed, 26 insertions(+), 31 deletions(-) diff --git a/docs/superpowers/specs/2026-04-05-unified-logging-design.md b/docs/superpowers/specs/2026-04-05-unified-logging-design.md index 2acc569..aef1ceb 100644 --- a/docs/superpowers/specs/2026-04-05-unified-logging-design.md +++ b/docs/superpowers/specs/2026-04-05-unified-logging-design.md @@ -100,7 +100,7 @@ This means: - `Log(level, msg string)` routes through logger functions with `"source", "frontend"` attribute. - Supports: `"trace"`, `"debug"`, `"info"`, `"warn"`, `"error"`. -- The `msg` argument is always wrapped in `Redact()` — frontend messages may contain user text. +- `msg` is wrapped in `Redact()` for trace/debug/info levels — these may contain user text. Error and warn messages pass through unredacted since they are operational (stack traces, lifecycle warnings). - Frontend errors (Angular `ErrorHandler`, uncaught exceptions) should call this service so they appear in `debug.log`. ### Backend Log Tagging diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 750a43c..af31d77 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -10,6 +10,7 @@ import ( "log/slog" "os" "path/filepath" + "sync/atomic" ) // LevelTrace is a custom slog level below Debug. @@ -17,12 +18,22 @@ const LevelTrace = slog.Level(-8) // Package-level state. var ( - l = slog.New(slog.NewTextHandler(io.Discard, nil)) + l = slog.New(slog.NewTextHandler(io.Discard, nil)) baseH slog.Handler = slog.NewTextHandler(io.Discard, nil) - logFile *os.File - sensitiveEnabled bool + logFile *os.File + sensitive atomic.Bool ) +// replaceAttr renders LevelTrace as "TRACE" in log output. +func replaceAttr(_ []string, a slog.Attr) slog.Attr { + if a.Key == slog.LevelKey { + if a.Value.Any().(slog.Level) == LevelTrace { + a.Value = slog.StringValue("TRACE") + } + } + return a +} + // levelNames maps level strings to slog.Level values. var levelNames = map[string]slog.Level{ "trace": LevelTrace, @@ -37,8 +48,8 @@ var levelNames = map[string]slog.Level{ // level is treated as "off". When a valid level is provided, output goes to // ~/.config/KeyLint/debug.log (or the platform equivalent). // sensitive controls whether Redact() reveals the underlying value. -func Init(level string, sensitive bool) { - sensitiveEnabled = sensitive +func Init(level string, sensitiveFlag bool) { + sensitive.Store(sensitiveFlag) if logFile != nil { _ = logFile.Close() @@ -70,26 +81,18 @@ func Init(level string, sensitive bool) { } logFile = f - replaceFunc := func(groups []string, a slog.Attr) slog.Attr { - if a.Key == slog.LevelKey { - if a.Value.Any().(slog.Level) == LevelTrace { - a.Value = slog.StringValue("TRACE") - } - } - return a - } baseH = slog.NewTextHandler(f, &slog.HandlerOptions{ Level: lvl, - ReplaceAttr: replaceFunc, + ReplaceAttr: replaceAttr, }) l = slog.New(baseH).With("source", "backend") - l.Info("logger initialized", "path", logPath, "sensitive", sensitive) + l.Info("logger initialized", "path", logPath, "sensitive", sensitiveFlag) } // InitWithWriter is the same as Init but writes to w instead of a log file. // Used by tests. -func InitWithWriter(w io.Writer, level string, sensitive bool) { - sensitiveEnabled = sensitive +func InitWithWriter(w io.Writer, level string, sensitiveFlag bool) { + sensitive.Store(sensitiveFlag) if logFile != nil { _ = logFile.Close() @@ -103,17 +106,9 @@ func InitWithWriter(w io.Writer, level string, sensitive bool) { return } - replaceFunc := func(groups []string, a slog.Attr) slog.Attr { - if a.Key == slog.LevelKey { - if a.Value.Any().(slog.Level) == LevelTrace { - a.Value = slog.StringValue("TRACE") - } - } - return a - } baseH = slog.NewTextHandler(w, &slog.HandlerOptions{ Level: lvl, - ReplaceAttr: replaceFunc, + ReplaceAttr: replaceAttr, }) l = slog.New(baseH).With("source", "backend") } @@ -151,7 +146,7 @@ type redacted struct{ v any } // LogValue implements slog.LogValuer. func (r redacted) LogValue() slog.Value { - if sensitiveEnabled { + if sensitive.Load() { return slog.AnyValue(r.v) } return slog.StringValue("[redacted]") diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go index b8b66eb..a25c9e2 100644 --- a/internal/logger/logger_test.go +++ b/internal/logger/logger_test.go @@ -9,13 +9,13 @@ import ( // initWithBuffer is a test helper that calls initWithWriter with a bytes.Buffer // and returns the buffer. It registers cleanup to reset package-level state. -func initWithBuffer(t *testing.T, level string, sensitive bool) *bytes.Buffer { +func initWithBuffer(t *testing.T, level string, sensitiveFlag bool) *bytes.Buffer { t.Helper() var buf bytes.Buffer - InitWithWriter(&buf, level, sensitive) + InitWithWriter(&buf, level, sensitiveFlag) t.Cleanup(func() { l = slog.New(slog.NewTextHandler(io.Discard, nil)) - sensitiveEnabled = false + sensitive.Store(false) }) return &buf }