From c8178fcd7e60583885d9398be2506f37fc01139a Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 17 Mar 2026 07:18:38 +0000 Subject: [PATCH 1/3] fix(dx): update CLAUDE.md and raise test coverage to 97.7% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLAUDE.md: add missing exported symbols (NewError, Message, AllOps, FormatStackTrace, Security, Username) to architecture description - Add tests for SetOutput, SetRedactKeys, odd keyval handling, op/stack deduplication, RotationWriterFactory, default output, Username, and all package-level proxy functions - Add ErrCode tests for plain error and nil inputs - Coverage: 86.1% → 97.7% Co-Authored-By: Virgil --- CLAUDE.md | 4 +- errors_test.go | 9 +++ log_test.go | 149 +++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 154 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4c47ae7..af5f958 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,8 +29,8 @@ The `core` CLI is optional; plain `go test` and `gofmt` work without it. Single-package library (`package log`) split into two files that wire together: -- **log.go** — `Logger` type, `Level` enum (Quiet→Error→Warn→Info→Debug), key-value formatting with redaction and injection prevention, `Style*` function hooks for decoration, `RotationWriterFactory` injection point, default logger with package-level proxy functions -- **errors.go** — `Err` structured error type (Op/Msg/Err/Code), creation helpers (`E`, `Wrap`, `WrapCode`, `NewCode`), introspection (`Op`, `ErrCode`, `Root`, `StackTrace`), combined log-and-return helpers (`LogError`, `LogWarn`, `Must`), stdlib wrappers (`Is`, `As`, `Join`) +- **log.go** — `Logger` type, `Level` enum (Quiet→Error→Warn→Info→Debug), `Security` log method (uses Error level with `[SEC]` prefix), key-value formatting with redaction and injection prevention, `Style*` function hooks for decoration, `RotationWriterFactory` injection point, `Username()` utility, default logger with package-level proxy functions +- **errors.go** — `Err` structured error type (Op/Msg/Err/Code), creation helpers (`E`, `Wrap`, `WrapCode`, `NewCode`, `NewError`), introspection (`Op`, `ErrCode`, `Message`, `Root`, `AllOps`, `StackTrace`, `FormatStackTrace`), combined log-and-return helpers (`LogError`, `LogWarn`, `Must`), stdlib wrappers (`Is`, `As`, `Join`) The logger automatically extracts `op` and `stack` from `*Err` values found in key-value pairs. `Wrap` propagates error codes upward through the chain. diff --git a/errors_test.go b/errors_test.go index 0d4f227..574865e 100644 --- a/errors_test.go +++ b/errors_test.go @@ -188,6 +188,15 @@ func TestErrCode_Good_NoCode(t *testing.T) { assert.Equal(t, "", ErrCode(err)) } +func TestErrCode_Good_PlainError(t *testing.T) { + err := errors.New("plain error") + assert.Equal(t, "", ErrCode(err)) +} + +func TestErrCode_Good_Nil(t *testing.T) { + assert.Equal(t, "", ErrCode(nil)) +} + func TestMessage_Good(t *testing.T) { err := E("op", "the message", errors.New("base")) assert.Equal(t, "the message", Message(err)) diff --git a/log_test.go b/log_test.go index 805b7a8..eec88f4 100644 --- a/log_test.go +++ b/log_test.go @@ -2,10 +2,16 @@ package log import ( "bytes" + goio "io" "strings" "testing" ) +// nopWriteCloser wraps a writer with a no-op Close for testing rotation. +type nopWriteCloser struct{ goio.Writer } + +func (nopWriteCloser) Close() error { return nil } + func TestLogger_Levels(t *testing.T) { tests := []struct { name string @@ -184,19 +190,150 @@ func TestLogger_Security(t *testing.T) { } } -func TestDefault(t *testing.T) { - // Default logger should exist +func TestLogger_SetOutput_Good(t *testing.T) { + var buf1, buf2 bytes.Buffer + l := New(Options{Level: LevelInfo, Output: &buf1}) + + l.Info("first") + if buf1.Len() == 0 { + t.Error("expected output in first buffer") + } + + l.SetOutput(&buf2) + l.Info("second") + if buf2.Len() == 0 { + t.Error("expected output in second buffer after SetOutput") + } +} + +func TestLogger_SetRedactKeys_Good(t *testing.T) { + var buf bytes.Buffer + l := New(Options{Level: LevelInfo, Output: &buf}) + + // No redaction initially + l.Info("msg", "secret", "visible") + if !strings.Contains(buf.String(), "secret=\"visible\"") { + t.Errorf("expected visible value, got %q", buf.String()) + } + + buf.Reset() + l.SetRedactKeys("secret") + l.Info("msg", "secret", "hidden") + if !strings.Contains(buf.String(), "secret=\"[REDACTED]\"") { + t.Errorf("expected redacted value, got %q", buf.String()) + } +} + +func TestLogger_OddKeyvals_Good(t *testing.T) { + var buf bytes.Buffer + l := New(Options{Level: LevelInfo, Output: &buf}) + + // Odd number of keyvals — last key should have no value + l.Info("msg", "lonely_key") + output := buf.String() + if !strings.Contains(output, "lonely_key=") { + t.Errorf("expected lonely_key=, got %q", output) + } +} + +func TestLogger_ExistingOpNotDuplicated_Good(t *testing.T) { + var buf bytes.Buffer + l := New(Options{Level: LevelInfo, Output: &buf}) + + err := E("inner.Op", "failed", NewError("cause")) + // Pass op explicitly — should not duplicate + l.Error("failed", "op", "explicit.Op", "err", err) + + output := buf.String() + if strings.Count(output, "op=") != 1 { + t.Errorf("expected exactly one op= in output, got %q", output) + } + if !strings.Contains(output, "op=\"explicit.Op\"") { + t.Errorf("expected explicit op, got %q", output) + } +} + +func TestLogger_ExistingStackNotDuplicated_Good(t *testing.T) { + var buf bytes.Buffer + l := New(Options{Level: LevelInfo, Output: &buf}) + + err := E("inner.Op", "failed", NewError("cause")) + // Pass stack explicitly — should not duplicate + l.Error("failed", "stack", "custom.Stack", "err", err) + + output := buf.String() + if strings.Count(output, "stack=") != 1 { + t.Errorf("expected exactly one stack= in output, got %q", output) + } + if !strings.Contains(output, "stack=\"custom.Stack\"") { + t.Errorf("expected custom stack, got %q", output) + } +} + +func TestNew_RotationFactory_Good(t *testing.T) { + var buf bytes.Buffer + // Set up a mock rotation writer factory + original := RotationWriterFactory + defer func() { RotationWriterFactory = original }() + + RotationWriterFactory = func(opts RotationOptions) goio.WriteCloser { + return nopWriteCloser{&buf} + } + + l := New(Options{ + Level: LevelInfo, + Rotation: &RotationOptions{Filename: "test.log"}, + }) + + l.Info("rotated message") + if buf.Len() == 0 { + t.Error("expected output via rotation writer") + } +} + +func TestNew_DefaultOutput_Good(t *testing.T) { + // No output or rotation — should default to stderr (not nil) + l := New(Options{Level: LevelInfo}) + if l.output == nil { + t.Error("expected non-nil output when no Output specified") + } +} + +func TestUsername_Good(t *testing.T) { + name := Username() + if name == "" { + t.Error("expected Username to return a non-empty string") + } +} + +func TestDefault_Good(t *testing.T) { if Default() == nil { t.Error("expected default logger to exist") } - // Package-level functions should work + // All package-level proxy functions var buf bytes.Buffer l := New(Options{Level: LevelDebug, Output: &buf}) SetDefault(l) + defer SetDefault(New(Options{Level: LevelInfo})) - Info("test") - if buf.Len() == 0 { - t.Error("expected package-level Info to produce output") + SetLevel(LevelDebug) + if l.Level() != LevelDebug { + t.Error("expected package-level SetLevel to work") + } + + SetRedactKeys("secret") + + Debug("debug msg") + Info("info msg") + Warn("warn msg") + Error("error msg") + Security("sec msg") + + output := buf.String() + for _, tag := range []string{"[DBG]", "[INF]", "[WRN]", "[ERR]", "[SEC]"} { + if !strings.Contains(output, tag) { + t.Errorf("expected %s in output, got %q", tag, output) + } } } From 99f85070d9566495a5fa8d1f1b930e3b165eb67e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 23:43:13 +0000 Subject: [PATCH 2/3] chore: migrate to dappco.re vanity import path Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 2 +- go.mod | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index af5f958..7500ba9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,4 +43,4 @@ Zero runtime dependencies. `testify` is test-only. - **Commit messages**: conventional commits (`feat`, `fix`, `docs`, `chore`, etc.) - **Dependencies**: no new runtime dependencies without justification; use `RotationWriterFactory` injection point for log rotation - Requires **Go 1.26+** (uses `iter.Seq`) -- Module path: `forge.lthn.ai/core/go-log` +- Module path: `dappco.re/go/core/log` diff --git a/go.mod b/go.mod index bf25951..401cf79 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module forge.lthn.ai/core/go-log +module dappco.re/go/core/log go 1.26.0 From 426b164b75432cce455f3deb39ccc730bf468a50 Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 23 Mar 2026 07:38:22 +0000 Subject: [PATCH 3/3] fix: replace stdlib helpers with core --- errors.go | 13 ++++++------- errors_test.go | 4 ++-- go.mod | 9 +++++---- go.sum | 7 ++----- log.go | 11 ++++++----- log_test.go | 48 ++++++++++++++++++++++++++---------------------- 6 files changed, 47 insertions(+), 45 deletions(-) diff --git a/errors.go b/errors.go index 01056ea..1e7d260 100644 --- a/errors.go +++ b/errors.go @@ -6,10 +6,9 @@ package log import ( + "dappco.re/go/core" "errors" - "fmt" "iter" - "strings" ) // Err represents a structured error with operational context. @@ -29,14 +28,14 @@ func (e *Err) Error() string { } if e.Err != nil { if e.Code != "" { - return fmt.Sprintf("%s%s [%s]: %v", prefix, e.Msg, e.Code, e.Err) + return core.Sprintf("%s%s [%s]: %v", prefix, e.Msg, e.Code, e.Err) } - return fmt.Sprintf("%s%s: %v", prefix, e.Msg, e.Err) + return core.Sprintf("%s%s: %v", prefix, e.Msg, e.Err) } if e.Code != "" { - return fmt.Sprintf("%s%s [%s]", prefix, e.Msg, e.Code) + return core.Sprintf("%s%s [%s]", prefix, e.Msg, e.Code) } - return fmt.Sprintf("%s%s", prefix, e.Msg) + return core.Sprintf("%s%s", prefix, e.Msg) } // Unwrap returns the underlying error for use with errors.Is and errors.As. @@ -212,7 +211,7 @@ func FormatStackTrace(err error) string { if len(ops) == 0 { return "" } - return strings.Join(ops, " -> ") + return core.Join(" -> ", ops...) } // --- Combined Log-and-Return Helpers --- diff --git a/errors_test.go b/errors_test.go index 574865e..10b99ca 100644 --- a/errors_test.go +++ b/errors_test.go @@ -2,9 +2,9 @@ package log import ( "bytes" + "dappco.re/go/core" "errors" "fmt" - "strings" "testing" "github.com/stretchr/testify/assert" @@ -311,7 +311,7 @@ func TestMust_Ugly_Panics(t *testing.T) { // Verify error was logged before panic output := buf.String() - assert.True(t, strings.Contains(output, "[ERR]") || len(output) > 0) + assert.True(t, core.Contains(output, "[ERR]") || len(output) > 0) } func TestStackTrace_Good(t *testing.T) { diff --git a/go.mod b/go.mod index 401cf79..841a8c0 100644 --- a/go.mod +++ b/go.mod @@ -2,13 +2,14 @@ module dappco.re/go/core/log go 1.26.0 -require github.com/stretchr/testify v1.11.1 +require ( + dappco.re/go/core v0.6.0 + github.com/stretchr/testify v1.11.1 +) require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/kr/pretty v0.3.1 // indirect + github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 5a10c39..0418c8a 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,14 @@ +dappco.re/go/core v0.6.0 h1:0wmuO/UmCWXxJkxQ6XvVLnqkAuWitbd49PhxjCsplyk= +dappco.re/go/core v0.6.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= diff --git a/log.go b/log.go index be73deb..9a8ab8d 100644 --- a/log.go +++ b/log.go @@ -6,6 +6,7 @@ package log import ( + "dappco.re/go/core" "fmt" goio "io" "os" @@ -225,16 +226,16 @@ func (l *Logger) log(level Level, prefix, msg string, keyvals ...any) { } // Redaction logic - keyStr := fmt.Sprintf("%v", key) + keyStr := core.Sprintf("%v", key) if slices.Contains(redactKeys, keyStr) { val = "[REDACTED]" } // Secure formatting to prevent log injection if s, ok := val.(string); ok { - kvStr += fmt.Sprintf("%v=%q", key, s) + kvStr += core.Sprintf("%v=%q", key, s) } else { - kvStr += fmt.Sprintf("%v=%v", key, val) + kvStr += core.Sprintf("%v=%v", key, val) } } } @@ -286,10 +287,10 @@ func Username() string { return u.Username } // Fallback for environments where user lookup might fail - if u := os.Getenv("USER"); u != "" { + if u := core.Env("USER"); u != "" { return u } - return os.Getenv("USERNAME") + return core.Env("USERNAME") } // --- Default logger --- diff --git a/log_test.go b/log_test.go index eec88f4..a127dd7 100644 --- a/log_test.go +++ b/log_test.go @@ -2,8 +2,8 @@ package log import ( "bytes" + "dappco.re/go/core" goio "io" - "strings" "testing" ) @@ -12,6 +12,10 @@ type nopWriteCloser struct{ goio.Writer } func (nopWriteCloser) Close() error { return nil } +func substringCount(s, substr string) int { + return len(core.Split(s, substr)) - 1 +} + func TestLogger_Levels(t *testing.T) { tests := []struct { name string @@ -69,13 +73,13 @@ func TestLogger_KeyValues(t *testing.T) { l.Info("test message", "key1", "value1", "key2", 42) output := buf.String() - if !strings.Contains(output, "test message") { + if !core.Contains(output, "test message") { t.Error("expected message in output") } - if !strings.Contains(output, "key1=\"value1\"") { + if !core.Contains(output, "key1=\"value1\"") { t.Errorf("expected key1=\"value1\" in output, got %q", output) } - if !strings.Contains(output, "key2=42") { + if !core.Contains(output, "key2=42") { t.Error("expected key2=42 in output") } } @@ -90,10 +94,10 @@ func TestLogger_ErrorContext(t *testing.T) { l.Error("something failed", "err", err) got := buf.String() - if !strings.Contains(got, "op=\"outer.Op\"") { + if !core.Contains(got, "op=\"outer.Op\"") { t.Errorf("expected output to contain op=\"outer.Op\", got %q", got) } - if !strings.Contains(got, "stack=\"outer.Op -> test.Op\"") { + if !core.Contains(got, "stack=\"outer.Op -> test.Op\"") { t.Errorf("expected output to contain stack=\"outer.Op -> test.Op\", got %q", got) } } @@ -109,13 +113,13 @@ func TestLogger_Redaction(t *testing.T) { l.Info("login", "user", "admin", "password", "secret123", "token", "abc-123") output := buf.String() - if !strings.Contains(output, "user=\"admin\"") { + if !core.Contains(output, "user=\"admin\"") { t.Error("expected user=\"admin\"") } - if !strings.Contains(output, "password=\"[REDACTED]\"") { + if !core.Contains(output, "password=\"[REDACTED]\"") { t.Errorf("expected password=\"[REDACTED]\", got %q", output) } - if !strings.Contains(output, "token=\"[REDACTED]\"") { + if !core.Contains(output, "token=\"[REDACTED]\"") { t.Errorf("expected token=\"[REDACTED]\", got %q", output) } } @@ -127,11 +131,11 @@ func TestLogger_InjectionPrevention(t *testing.T) { l.Info("message", "key", "value\n[SEC] injected message") output := buf.String() - if !strings.Contains(output, "key=\"value\\n[SEC] injected message\"") { + if !core.Contains(output, "key=\"value\\n[SEC] injected message\"") { t.Errorf("expected escaped newline, got %q", output) } // Ensure it's still a single line (excluding trailing newline) - lines := strings.Split(strings.TrimSpace(output), "\n") + lines := core.Split(core.Trim(output), "\n") if len(lines) != 1 { t.Errorf("expected 1 line, got %d", len(lines)) } @@ -179,13 +183,13 @@ func TestLogger_Security(t *testing.T) { l.Security("unauthorized access", "user", "admin") output := buf.String() - if !strings.Contains(output, "[SEC]") { + if !core.Contains(output, "[SEC]") { t.Error("expected [SEC] prefix in security log") } - if !strings.Contains(output, "unauthorized access") { + if !core.Contains(output, "unauthorized access") { t.Error("expected message in security log") } - if !strings.Contains(output, "user=\"admin\"") { + if !core.Contains(output, "user=\"admin\"") { t.Error("expected context in security log") } } @@ -212,14 +216,14 @@ func TestLogger_SetRedactKeys_Good(t *testing.T) { // No redaction initially l.Info("msg", "secret", "visible") - if !strings.Contains(buf.String(), "secret=\"visible\"") { + if !core.Contains(buf.String(), "secret=\"visible\"") { t.Errorf("expected visible value, got %q", buf.String()) } buf.Reset() l.SetRedactKeys("secret") l.Info("msg", "secret", "hidden") - if !strings.Contains(buf.String(), "secret=\"[REDACTED]\"") { + if !core.Contains(buf.String(), "secret=\"[REDACTED]\"") { t.Errorf("expected redacted value, got %q", buf.String()) } } @@ -231,7 +235,7 @@ func TestLogger_OddKeyvals_Good(t *testing.T) { // Odd number of keyvals — last key should have no value l.Info("msg", "lonely_key") output := buf.String() - if !strings.Contains(output, "lonely_key=") { + if !core.Contains(output, "lonely_key=") { t.Errorf("expected lonely_key=, got %q", output) } } @@ -245,10 +249,10 @@ func TestLogger_ExistingOpNotDuplicated_Good(t *testing.T) { l.Error("failed", "op", "explicit.Op", "err", err) output := buf.String() - if strings.Count(output, "op=") != 1 { + if substringCount(output, "op=") != 1 { t.Errorf("expected exactly one op= in output, got %q", output) } - if !strings.Contains(output, "op=\"explicit.Op\"") { + if !core.Contains(output, "op=\"explicit.Op\"") { t.Errorf("expected explicit op, got %q", output) } } @@ -262,10 +266,10 @@ func TestLogger_ExistingStackNotDuplicated_Good(t *testing.T) { l.Error("failed", "stack", "custom.Stack", "err", err) output := buf.String() - if strings.Count(output, "stack=") != 1 { + if substringCount(output, "stack=") != 1 { t.Errorf("expected exactly one stack= in output, got %q", output) } - if !strings.Contains(output, "stack=\"custom.Stack\"") { + if !core.Contains(output, "stack=\"custom.Stack\"") { t.Errorf("expected custom stack, got %q", output) } } @@ -332,7 +336,7 @@ func TestDefault_Good(t *testing.T) { output := buf.String() for _, tag := range []string{"[DBG]", "[INF]", "[WRN]", "[ERR]", "[SEC]"} { - if !strings.Contains(output, tag) { + if !core.Contains(output, tag) { t.Errorf("expected %s in output, got %q", tag, output) } }