[agent/codex] Fix ALL findings from issue #2. Read CLAUDE.md first. os.Get... #3
7 changed files with 195 additions and 47 deletions
|
|
@ -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.
|
||||
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
13
errors.go
13
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 ---
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ package log
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"dappco.re/go/core"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -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))
|
||||
|
|
@ -302,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) {
|
||||
|
|
|
|||
11
go.mod
11
go.mod
|
|
@ -1,14 +1,15 @@
|
|||
module forge.lthn.ai/core/go-log
|
||||
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
|
||||
)
|
||||
|
|
|
|||
7
go.sum
7
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=
|
||||
|
|
|
|||
11
log.go
11
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 ---
|
||||
|
|
|
|||
181
log_test.go
181
log_test.go
|
|
@ -2,10 +2,20 @@ package log
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"dappco.re/go/core"
|
||||
goio "io"
|
||||
"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 substringCount(s, substr string) int {
|
||||
return len(core.Split(s, substr)) - 1
|
||||
}
|
||||
|
||||
func TestLogger_Levels(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -63,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")
|
||||
}
|
||||
}
|
||||
|
|
@ -84,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)
|
||||
}
|
||||
}
|
||||
|
|
@ -103,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)
|
||||
}
|
||||
}
|
||||
|
|
@ -121,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))
|
||||
}
|
||||
|
|
@ -173,30 +183,161 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
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 !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 !core.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 !core.Contains(output, "lonely_key=<nil>") {
|
||||
t.Errorf("expected lonely_key=<nil>, 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 substringCount(output, "op=") != 1 {
|
||||
t.Errorf("expected exactly one op= in output, got %q", output)
|
||||
}
|
||||
if !core.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 substringCount(output, "stack=") != 1 {
|
||||
t.Errorf("expected exactly one stack= in output, got %q", output)
|
||||
}
|
||||
if !core.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 !core.Contains(output, tag) {
|
||||
t.Errorf("expected %s in output, got %q", tag, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue