From 2816f9a3ea904cc055497ee471d3813cb41b8b45 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 26 Mar 2026 14:25:13 +0000 Subject: [PATCH 1/6] chore(deps): upgrade core to v0.8.0-alpha.1 Co-Authored-By: Virgil --- go.mod | 28 ++++------------------------ go.sum | 28 ++++++++++++++++++++++------ 2 files changed, 26 insertions(+), 30 deletions(-) diff --git a/go.mod b/go.mod index 09132bd..12b86e9 100644 --- a/go.mod +++ b/go.mod @@ -3,16 +3,16 @@ module dappco.re/go/core/ai go 1.26.0 require ( - dappco.re/go/core/i18n v0.1.7 - dappco.re/go/core/io v0.1.7 - dappco.re/go/core/log v0.0.4 + dappco.re/go/core/i18n v0.2.0 + dappco.re/go/core/io v0.2.0 + dappco.re/go/core/log v0.1.0 forge.lthn.ai/core/cli v0.3.7 forge.lthn.ai/core/go-rag v0.1.11 forge.lthn.ai/core/go-scm v0.2.0 ) require ( - dappco.re/go/core v0.4.7 // indirect + dappco.re/go/core v0.8.0-alpha.1 // indirect forge.lthn.ai/core/go v0.3.3 // indirect forge.lthn.ai/core/go-i18n v0.1.7 // indirect forge.lthn.ai/core/go-inference v0.1.7 // indirect @@ -57,23 +57,3 @@ require ( google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) - -replace ( - dappco.re/go/core => ../go - dappco.re/go/core/i18n => ../go-i18n - dappco.re/go/core/io => ../go-io - dappco.re/go/core/log => ../go-log - dappco.re/go/core/webview => ../go-webview - dappco.re/go/core/ws => ../go-ws - forge.lthn.ai/core/api => ../api - forge.lthn.ai/core/cli => ../cli - forge.lthn.ai/core/go-crypt => ../go-crypt - forge.lthn.ai/core/go-inference => ../go-inference - forge.lthn.ai/core/go-ml => ../go-ml - forge.lthn.ai/core/go-mlx => ../go-mlx - forge.lthn.ai/core/go-process => ../go-process - forge.lthn.ai/core/go-rag => ../go-rag - forge.lthn.ai/core/go-scm => ../go-scm - forge.lthn.ai/core/mcp => ../mcp - forge.lthn.ai/lthn/lem => ../../lthn/LEM -) diff --git a/go.sum b/go.sum index b2a289c..71a6b5e 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,27 @@ +dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= +dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= +dappco.re/go/core/i18n v0.2.0 h1:NHzk6RCU93/qVRA3f2jvMr9P1R6FYheR/sHL+TnvKbI= +dappco.re/go/core/i18n v0.2.0/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok= +dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4= +dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E= +dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc= +dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs= +forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg= +forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs= forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0= forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ= forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA= forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8= +forge.lthn.ai/core/go-inference v0.1.7 h1:9Dy6v03jX5ZRH3n5iTzlYyGtucuBIgSe+S7GWvBzx9Q= +forge.lthn.ai/core/go-inference v0.1.7/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw= forge.lthn.ai/core/go-io v0.1.7 h1:Tdb6sqh+zz1lsGJaNX9RFWM6MJ/RhSAyxfulLXrJsbk= forge.lthn.ai/core/go-io v0.1.7/go.mod h1:8lRLFk4Dnp5cR/Cyzh9WclD5566TbpdRgwcH7UZLWn4= forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= +forge.lthn.ai/core/go-rag v0.1.11 h1:KXTOtnOdrx8YKmvnj0EOi2EI/+cKjE8w2PpJCQIrSd8= +forge.lthn.ai/core/go-rag v0.1.11/go.mod h1:vIlOKVD1SdqqjkJ2XQyXPuKPtiajz/STPLCaDpqOzk8= +forge.lthn.ai/core/go-scm v0.2.0 h1:TvDyCzw0HWzXjmqe6uPc46nPaRzc7MPGswmwZt0CmXo= +forge.lthn.ai/core/go-scm v0.2.0/go.mod h1:Q/PV2FbqDlWnAOsXAd1pgSiHOlRCPW4HcPmOt8Z9H+E= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= @@ -93,14 +109,14 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= -go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -- 2.45.3 From 5cf7f37444745f0de67eb685deb85c5ba7e7cecf Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 26 Mar 2026 16:41:10 +0000 Subject: [PATCH 2/6] refactor(core): complete v0.8.0 polish pass Co-Authored-By: Virgil --- ai/metrics.go | 76 +++--- ai/metrics_bench_test.go | 70 ++---- ai/metrics_test.go | 451 +++++++++++++++++------------------ ai/rag.go | 18 +- cmd/embed-bench/main.go | 101 ++++---- cmd/lab/cmd_lab.go | 11 +- cmd/metrics/cmd.go | 31 +-- cmd/metrics/cmd_test.go | 4 +- cmd/rag/cmd.go | 4 + cmd/security/cmd_alerts.go | 66 +++-- cmd/security/cmd_deps.go | 34 +-- cmd/security/cmd_jobs.go | 53 ++-- cmd/security/cmd_scan.go | 33 +-- cmd/security/cmd_secrets.go | 32 +-- cmd/security/cmd_security.go | 87 ++++--- cmd/security/github.go | 176 ++++++++++++++ go.mod | 4 +- 17 files changed, 694 insertions(+), 557 deletions(-) create mode 100644 cmd/security/github.go diff --git a/ai/metrics.go b/ai/metrics.go index 4bb256c..6564a11 100644 --- a/ai/metrics.go +++ b/ai/metrics.go @@ -3,21 +3,22 @@ package ai import ( "bufio" "cmp" - "encoding/json" - "os" - "path/filepath" "slices" "sync" "time" + "dappco.re/go/core" coreio "dappco.re/go/core/io" - coreerr "dappco.re/go/core/log" ) // metricsMu protects concurrent file writes in Record. var metricsMu sync.Mutex // Event represents a recorded AI/security metric event. +// +// Example: +// +// _ = Record(Event{Type: "security.scan", Repo: "host-uk/core"}) type Event struct { Type string `json:"type"` Timestamp time.Time `json:"timestamp"` @@ -29,20 +30,30 @@ type Event struct { // metricsDir returns the base directory for metrics storage. func metricsDir() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", coreerr.E("ai.metricsDir", "get home directory", err) + home := core.Env("CORE_HOME") + if home == "" { + home = core.Env("HOME") } - return filepath.Join(home, ".core", "ai", "metrics"), nil + if home == "" { + home = core.Env("DIR_HOME") + } + if home == "" { + return "", core.E("ai.metricsDir", "home directory unavailable", nil) + } + return core.Path(home, ".core", "ai", "metrics"), nil } // metricsFilePath returns the JSONL file path for the given date. func metricsFilePath(dir string, t time.Time) string { - return filepath.Join(dir, t.Format("2006-01-02")+".jsonl") + return core.Path(dir, core.Concat(t.Format("2006-01-02"), ".jsonl")) } // Record appends an event to the daily JSONL file at // ~/.core/ai/metrics/YYYY-MM-DD.jsonl. +// +// Example: +// +// err := Record(Event{Type: "metrics.read", AgentID: "agent-1"}) func Record(event Event) (err error) { if event.Timestamp.IsZero() { event.Timestamp = time.Now() @@ -56,35 +67,39 @@ func Record(event Event) (err error) { return err } - if err := coreio.Local.EnsureDir(dir); err != nil { - return coreerr.E("ai.Record", "create metrics directory", err) + if err := coreio.EnsureDir(coreio.Local, dir); err != nil { + return core.E("ai.Record", "create metrics directory", err) } path := metricsFilePath(dir, event.Timestamp) - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + f, err := coreio.Local.Append(path) if err != nil { - return coreerr.E("ai.Record", "open metrics file", err) + return core.E("ai.Record", "open metrics file", err) } defer func() { if cerr := f.Close(); cerr != nil && err == nil { - err = coreerr.E("ai.Record", "close metrics file", cerr) + err = core.E("ai.Record", "close metrics file", cerr) } }() - data, err := json.Marshal(event) - if err != nil { - return coreerr.E("ai.Record", "marshal event", err) + data := core.JSONMarshal(event) + if !data.OK { + return core.E("ai.Record", "marshal event", data.Value.(error)) } - if _, err := f.Write(append(data, '\n')); err != nil { - return coreerr.E("ai.Record", "write event", err) + if _, err := f.Write(append(data.Value.([]byte), '\n')); err != nil { + return core.E("ai.Record", "write event", err) } return nil } // ReadEvents reads events from JSONL files within the given time range. +// +// Example: +// +// events, err := ReadEvents(time.Now().Add(-24 * time.Hour)) func ReadEvents(since time.Time) ([]Event, error) { dir, err := metricsDir() if err != nil { @@ -110,20 +125,21 @@ func ReadEvents(since time.Time) ([]Event, error) { // readMetricsFile reads events from a single JSONL file, returning only those at or after since. func readMetricsFile(path string, since time.Time) ([]Event, error) { - f, err := os.Open(path) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, coreerr.E("ai.readMetricsFile", "open metrics file", err) + if !coreio.Local.Exists(path) { + return nil, nil } - defer func() { _ = f.Close() }() + + f, err := coreio.Local.Open(path) + if err != nil { + return nil, core.E("ai.readMetricsFile", "open metrics file", err) + } + defer core.CloseStream(f) var events []Event scanner := bufio.NewScanner(f) for scanner.Scan() { var ev Event - if err := json.Unmarshal(scanner.Bytes(), &ev); err != nil { + if result := core.JSONUnmarshal(scanner.Bytes(), &ev); !result.OK { continue // skip malformed lines } if !ev.Timestamp.Before(since) { @@ -131,12 +147,16 @@ func readMetricsFile(path string, since time.Time) ([]Event, error) { } } if err := scanner.Err(); err != nil { - return nil, coreerr.E("ai.readMetricsFile", "read metrics file", err) + return nil, core.E("ai.readMetricsFile", "read metrics file", err) } return events, nil } // Summary aggregates events into counts by type, repo, and agent. +// +// Example: +// +// summary := Summary(events) func Summary(events []Event) map[string]any { byType := make(map[string]int) byRepo := make(map[string]int) diff --git a/ai/metrics_bench_test.go b/ai/metrics_bench_test.go index ffe774d..3c9dbb9 100644 --- a/ai/metrics_bench_test.go +++ b/ai/metrics_bench_test.go @@ -1,48 +1,22 @@ package ai import ( - "fmt" - "os" - "path/filepath" "testing" "time" - coreio "dappco.re/go/core/io" + "dappco.re/go/core" ) -// --- Helpers --- - -// setupBenchMetricsDir overrides the metrics directory to a temp dir for benchmarks. -// Returns a cleanup function to restore the original. -func setupBenchMetricsDir(b *testing.B) string { - b.Helper() - dir := b.TempDir() - // Override HOME so metricsDir() resolves to our temp dir - origHome := os.Getenv("HOME") - tmpHome := b.TempDir() - // Create the metrics path under the fake HOME - metricsPath := filepath.Join(tmpHome, ".core", "ai", "metrics") - if err := coreio.Local.EnsureDir(metricsPath); err != nil { - b.Fatalf("Failed to create metrics dir: %v", err) - } - os.Setenv("HOME", tmpHome) - b.Cleanup(func() { - os.Setenv("HOME", origHome) - }) - _ = dir - return metricsPath -} - // seedEvents writes n events to the metrics directory for the current day. func seedEvents(b *testing.B, n int) { b.Helper() now := time.Now() for i := range n { ev := Event{ - Type: fmt.Sprintf("type-%d", i%10), + Type: core.Sprintf("type-%d", i%10), Timestamp: now.Add(-time.Duration(i) * time.Millisecond), - AgentID: fmt.Sprintf("agent-%d", i%5), - Repo: fmt.Sprintf("repo-%d", i%3), + AgentID: core.Sprintf("agent-%d", i%5), + Repo: core.Sprintf("repo-%d", i%3), Data: map[string]any{"i": i, "tool": "bench_tool"}, } if err := Record(ev); err != nil { @@ -55,7 +29,7 @@ func seedEvents(b *testing.B, n int) { // BenchmarkMetricsRecord benchmarks writing individual metric events. func BenchmarkMetricsRecord(b *testing.B) { - setupBenchMetricsDir(b) + withTempHome(b) now := time.Now() b.ResetTimer() @@ -76,7 +50,7 @@ func BenchmarkMetricsRecord(b *testing.B) { // BenchmarkMetricsRecord_Parallel benchmarks concurrent metric recording. func BenchmarkMetricsRecord_Parallel(b *testing.B) { - setupBenchMetricsDir(b) + withTempHome(b) now := time.Now() b.ResetTimer() @@ -101,7 +75,7 @@ func BenchmarkMetricsRecord_Parallel(b *testing.B) { // BenchmarkMetricsQuery_10K benchmarks querying 10K events. func BenchmarkMetricsQuery_10K(b *testing.B) { - setupBenchMetricsDir(b) + withTempHome(b) seedEvents(b, 10_000) since := time.Now().Add(-24 * time.Hour) @@ -120,7 +94,7 @@ func BenchmarkMetricsQuery_10K(b *testing.B) { // BenchmarkMetricsQuery_50K benchmarks querying 50K events. func BenchmarkMetricsQuery_50K(b *testing.B) { - setupBenchMetricsDir(b) + withTempHome(b) seedEvents(b, 50_000) since := time.Now().Add(-24 * time.Hour) @@ -139,7 +113,7 @@ func BenchmarkMetricsQuery_50K(b *testing.B) { // BenchmarkMetricsSummary_10K benchmarks summarising 10K events. func BenchmarkMetricsSummary_10K(b *testing.B) { - setupBenchMetricsDir(b) + withTempHome(b) seedEvents(b, 10_000) since := time.Now().Add(-24 * time.Hour) @@ -159,14 +133,14 @@ func BenchmarkMetricsSummary_10K(b *testing.B) { // BenchmarkMetricsRecordAndQuery benchmarks the full write-then-read cycle at 10K scale. func BenchmarkMetricsRecordAndQuery(b *testing.B) { - setupBenchMetricsDir(b) + withTempHome(b) now := time.Now() // Write 10K events for i := range 10_000 { ev := Event{ - Type: fmt.Sprintf("type-%d", i%10), + Type: core.Sprintf("type-%d", i%10), Timestamp: now, AgentID: "bench", Repo: "bench-repo", @@ -190,19 +164,9 @@ func BenchmarkMetricsRecordAndQuery(b *testing.B) { // --- Unit tests for metrics at scale --- -// TestMetricsRecordAndRead_10K_Good writes 10K events and reads them back. -func TestMetricsRecordAndRead_10K_Good(t *testing.T) { - // Override HOME to temp dir - origHome := os.Getenv("HOME") - tmpHome := t.TempDir() - metricsPath := filepath.Join(tmpHome, ".core", "ai", "metrics") - if err := coreio.Local.EnsureDir(metricsPath); err != nil { - t.Fatalf("Failed to create metrics dir: %v", err) - } - os.Setenv("HOME", tmpHome) - t.Cleanup(func() { - os.Setenv("HOME", origHome) - }) +// TestMetricsBench_MetricsRecordAndRead_Good writes 10K events and reads them back. +func TestMetricsBench_MetricsRecordAndRead_Good(t *testing.T) { + withTempHome(t) now := time.Now() const n = 10_000 @@ -210,10 +174,10 @@ func TestMetricsRecordAndRead_10K_Good(t *testing.T) { // Write events for i := range n { ev := Event{ - Type: fmt.Sprintf("type-%d", i%10), + Type: core.Sprintf("type-%d", i%10), Timestamp: now.Add(-time.Duration(i) * time.Millisecond), - AgentID: fmt.Sprintf("agent-%d", i%5), - Repo: fmt.Sprintf("repo-%d", i%3), + AgentID: core.Sprintf("agent-%d", i%5), + Repo: core.Sprintf("repo-%d", i%3), Data: map[string]any{"index": i}, } if err := Record(ev); err != nil { diff --git a/ai/metrics_test.go b/ai/metrics_test.go index d4363c2..f34fd6b 100644 --- a/ai/metrics_test.go +++ b/ai/metrics_test.go @@ -1,213 +1,198 @@ package ai import ( - "os" - "path/filepath" "testing" "time" + "dappco.re/go/core" coreio "dappco.re/go/core/io" ) // withTempHome overrides HOME to a temp dir for the duration of the test. -func withTempHome(t *testing.T) { - t.Helper() - origHome := os.Getenv("HOME") - tmpHome := t.TempDir() - metricsPath := filepath.Join(tmpHome, ".core", "ai", "metrics") - if err := coreio.Local.EnsureDir(metricsPath); err != nil { - t.Fatalf("create metrics dir: %v", err) +func withTempHome(tb testing.TB) { + tb.Helper() + tmpHome := tb.TempDir() + metricsPath := core.Path(tmpHome, ".core", "ai", "metrics") + if err := coreio.EnsureDir(coreio.Local, metricsPath); err != nil { + tb.Fatalf("create metrics dir: %v", err) } - os.Setenv("HOME", tmpHome) - t.Cleanup(func() { os.Setenv("HOME", origHome) }) + tb.Setenv("HOME", tmpHome) + tb.Setenv("CORE_HOME", tmpHome) } -// --- Record --- +func TestMetrics_Record_Good(t *testing.T) { + t.Run("PersistsEvent", func(t *testing.T) { + withTempHome(t) -func TestRecord_Good(t *testing.T) { - withTempHome(t) + ev := Event{ + Type: "test_event", + AgentID: "agent-1", + Repo: "repo-1", + Data: map[string]any{"key": "value"}, + } + if err := Record(ev); err != nil { + t.Fatalf("Record: %v", err) + } - ev := Event{ - Type: "test_event", - AgentID: "agent-1", - Repo: "repo-1", - Data: map[string]any{"key": "value"}, - } - if err := Record(ev); err != nil { - t.Fatalf("Record: %v", err) - } + events, err := ReadEvents(time.Now().Add(-time.Hour)) + if err != nil { + t.Fatalf("ReadEvents: %v", err) + } + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + if events[0].Type != "test_event" { + t.Errorf("expected type test_event, got %s", events[0].Type) + } + if events[0].Timestamp.IsZero() { + t.Error("expected auto-set timestamp, got zero") + } + }) - events, err := ReadEvents(time.Now().Add(-time.Hour)) - if err != nil { - t.Fatalf("ReadEvents: %v", err) - } - if len(events) != 1 { - t.Fatalf("expected 1 event, got %d", len(events)) - } - if events[0].Type != "test_event" { - t.Errorf("expected type test_event, got %s", events[0].Type) - } - if events[0].Timestamp.IsZero() { - t.Error("expected auto-set timestamp, got zero") - } + t.Run("AutoTimestamp", func(t *testing.T) { + withTempHome(t) + + before := time.Now() + ev := Event{Type: "auto_ts"} + if err := Record(ev); err != nil { + t.Fatalf("Record: %v", err) + } + after := time.Now() + + events, err := ReadEvents(before) + if err != nil { + t.Fatalf("ReadEvents: %v", err) + } + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + ts := events[0].Timestamp + if ts.Before(before) || ts.After(after) { + t.Errorf("timestamp %v not in range [%v, %v]", ts, before, after) + } + }) + + t.Run("PresetTimestamp", func(t *testing.T) { + withTempHome(t) + + fixed := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) + ev := Event{Type: "preset_ts", Timestamp: fixed} + if err := Record(ev); err != nil { + t.Fatalf("Record: %v", err) + } + + events, err := ReadEvents(fixed.Add(-time.Hour)) + if err != nil { + t.Fatalf("ReadEvents: %v", err) + } + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + if !events[0].Timestamp.Equal(fixed) { + t.Errorf("expected timestamp %v, got %v", fixed, events[0].Timestamp) + } + }) } -func TestRecord_Good_AutoTimestamp(t *testing.T) { - withTempHome(t) +func TestMetrics_ReadEvents_Good(t *testing.T) { + t.Run("Empty", func(t *testing.T) { + withTempHome(t) - before := time.Now() - ev := Event{Type: "auto_ts"} - if err := Record(ev); err != nil { - t.Fatalf("Record: %v", err) - } - after := time.Now() + events, err := ReadEvents(time.Now().Add(-24 * time.Hour)) + if err != nil { + t.Fatalf("ReadEvents: %v", err) + } + if len(events) != 0 { + t.Errorf("expected 0 events, got %d", len(events)) + } + }) - events, err := ReadEvents(before) - if err != nil { - t.Fatalf("ReadEvents: %v", err) - } - if len(events) != 1 { - t.Fatalf("expected 1 event, got %d", len(events)) - } - ts := events[0].Timestamp - if ts.Before(before) || ts.After(after) { - t.Errorf("timestamp %v not in range [%v, %v]", ts, before, after) - } + t.Run("MultiDay", func(t *testing.T) { + withTempHome(t) + + now := time.Now() + yesterday := now.AddDate(0, 0, -1) + + if err := Record(Event{Type: "yesterday", Timestamp: yesterday}); err != nil { + t.Fatalf("Record yesterday: %v", err) + } + if err := Record(Event{Type: "today", Timestamp: now}); err != nil { + t.Fatalf("Record today: %v", err) + } + + events, err := ReadEvents(now.AddDate(0, 0, -2)) + if err != nil { + t.Fatalf("ReadEvents: %v", err) + } + if len(events) != 2 { + t.Fatalf("expected 2 events, got %d", len(events)) + } + }) + + t.Run("FiltersBySince", func(t *testing.T) { + withTempHome(t) + + now := time.Now() + if err := Record(Event{Type: "old", Timestamp: now.Add(-2 * time.Hour)}); err != nil { + t.Fatalf("Record old: %v", err) + } + if err := Record(Event{Type: "recent", Timestamp: now}); err != nil { + t.Fatalf("Record recent: %v", err) + } + + events, err := ReadEvents(now.Add(-time.Hour)) + if err != nil { + t.Fatalf("ReadEvents: %v", err) + } + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + if events[0].Type != "recent" { + t.Errorf("expected type recent, got %s", events[0].Type) + } + }) } -func TestRecord_Good_PresetTimestamp(t *testing.T) { - withTempHome(t) +func TestMetrics_ReadMetricsFile_Good(t *testing.T) { + t.Run("MalformedLines", func(t *testing.T) { + withTempHome(t) - fixed := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) - ev := Event{Type: "preset_ts", Timestamp: fixed} - if err := Record(ev); err != nil { - t.Fatalf("Record: %v", err) - } + dir, err := metricsDir() + if err != nil { + t.Fatalf("metricsDir: %v", err) + } - events, err := ReadEvents(fixed.Add(-time.Hour)) - if err != nil { - t.Fatalf("ReadEvents: %v", err) - } - if len(events) != 1 { - t.Fatalf("expected 1 event, got %d", len(events)) - } - if !events[0].Timestamp.Equal(fixed) { - t.Errorf("expected timestamp %v, got %v", fixed, events[0].Timestamp) - } -} - -// --- ReadEvents --- - -func TestReadEvents_Good_Empty(t *testing.T) { - withTempHome(t) - - events, err := ReadEvents(time.Now().Add(-24 * time.Hour)) - if err != nil { - t.Fatalf("ReadEvents: %v", err) - } - if len(events) != 0 { - t.Errorf("expected 0 events, got %d", len(events)) - } -} - -func TestReadEvents_Good_MultiDay(t *testing.T) { - withTempHome(t) - - now := time.Now() - yesterday := now.AddDate(0, 0, -1) - - // Write event for yesterday - ev1 := Event{Type: "yesterday", Timestamp: yesterday} - if err := Record(ev1); err != nil { - t.Fatalf("Record yesterday: %v", err) - } - - // Write event for today - ev2 := Event{Type: "today", Timestamp: now} - if err := Record(ev2); err != nil { - t.Fatalf("Record today: %v", err) - } - - // Read from 2 days ago — should get both - events, err := ReadEvents(now.AddDate(0, 0, -2)) - if err != nil { - t.Fatalf("ReadEvents: %v", err) - } - if len(events) != 2 { - t.Fatalf("expected 2 events, got %d", len(events)) - } -} - -func TestReadEvents_Good_FiltersBySince(t *testing.T) { - withTempHome(t) - - now := time.Now() - // Write an old event and a recent one - old := Event{Type: "old", Timestamp: now.Add(-2 * time.Hour)} - if err := Record(old); err != nil { - t.Fatalf("Record old: %v", err) - } - recent := Event{Type: "recent", Timestamp: now} - if err := Record(recent); err != nil { - t.Fatalf("Record recent: %v", err) - } - - // Read only events from the last hour - events, err := ReadEvents(now.Add(-time.Hour)) - if err != nil { - t.Fatalf("ReadEvents: %v", err) - } - if len(events) != 1 { - t.Fatalf("expected 1 event, got %d", len(events)) - } - if events[0].Type != "recent" { - t.Errorf("expected type recent, got %s", events[0].Type) - } -} - -// --- readMetricsFile --- - -func TestReadMetricsFile_Good_MalformedLines(t *testing.T) { - withTempHome(t) - - dir, err := metricsDir() - if err != nil { - t.Fatalf("metricsDir: %v", err) - } - - // Write a file with a mix of valid and invalid lines - path := metricsFilePath(dir, time.Now()) - content := `{"type":"valid","timestamp":"2026-03-15T10:00:00Z"} + path := metricsFilePath(dir, time.Now()) + content := `{"type":"valid","timestamp":"2026-03-15T10:00:00Z"} not-json {"type":"also_valid","timestamp":"2026-03-15T11:00:00Z"} {broken ` - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatalf("write test file: %v", err) - } + if err := coreio.Write(coreio.Local, path, content); err != nil { + t.Fatalf("write test file: %v", err) + } - events, err := readMetricsFile(path, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) - if err != nil { - t.Fatalf("readMetricsFile: %v", err) - } - if len(events) != 2 { - t.Errorf("expected 2 valid events (skipping malformed), got %d", len(events)) - } + events, err := readMetricsFile(path, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("readMetricsFile: %v", err) + } + if len(events) != 2 { + t.Errorf("expected 2 valid events (skipping malformed), got %d", len(events)) + } + }) + + t.Run("NonExistent", func(t *testing.T) { + events, err := readMetricsFile(core.Path(t.TempDir(), "nonexistent-metrics-file.jsonl"), time.Time{}) + if err != nil { + t.Fatalf("expected nil error for missing file, got: %v", err) + } + if len(events) != 0 { + t.Errorf("expected 0 events, got %d", len(events)) + } + }) } -func TestReadMetricsFile_Good_NonExistent(t *testing.T) { - events, err := readMetricsFile("/tmp/nonexistent-metrics-file.jsonl", time.Time{}) - if err != nil { - t.Fatalf("expected nil error for missing file, got: %v", err) - } - if len(events) != 0 { - t.Errorf("expected 0 events, got %d", len(events)) - } -} - -// --- metricsFilePath --- - -func TestMetricsFilePath_Good(t *testing.T) { +func TestMetrics_MetricsFilePath_Good(t *testing.T) { ts := time.Date(2026, 3, 17, 14, 30, 0, 0, time.UTC) got := metricsFilePath("/base", ts) want := "/base/2026-03-17.jsonl" @@ -216,63 +201,61 @@ func TestMetricsFilePath_Good(t *testing.T) { } } -// --- Summary --- +func TestMetrics_Summary_Good(t *testing.T) { + t.Run("Empty", func(t *testing.T) { + s := Summary(nil) + total, ok := s["total"].(int) + if !ok || total != 0 { + t.Errorf("expected total 0, got %v", s["total"]) + } + }) -func TestSummary_Good_Empty(t *testing.T) { - s := Summary(nil) - total, ok := s["total"].(int) - if !ok || total != 0 { - t.Errorf("expected total 0, got %v", s["total"]) - } + t.Run("Aggregates", func(t *testing.T) { + events := []Event{ + {Type: "build", Repo: "core-php", AgentID: "agent-1"}, + {Type: "build", Repo: "core-php", AgentID: "agent-2"}, + {Type: "test", Repo: "core-api", AgentID: "agent-1"}, + } + + s := Summary(events) + + total, _ := s["total"].(int) + if total != 3 { + t.Errorf("expected total 3, got %d", total) + } + + byType, _ := s["by_type"].([]map[string]any) + if len(byType) != 2 { + t.Fatalf("expected 2 types, got %d", len(byType)) + } + if byType[0]["key"] != "build" || byType[0]["count"] != 2 { + t.Errorf("expected build:2 first, got %v:%v", byType[0]["key"], byType[0]["count"]) + } + }) } -func TestSummary_Good(t *testing.T) { - events := []Event{ - {Type: "build", Repo: "core-php", AgentID: "agent-1"}, - {Type: "build", Repo: "core-php", AgentID: "agent-2"}, - {Type: "test", Repo: "core-api", AgentID: "agent-1"}, - } +func TestMetrics_SortedMap_Good(t *testing.T) { + t.Run("Empty", func(t *testing.T) { + result := sortedMap(map[string]int{}) + if len(result) != 0 { + t.Errorf("expected empty slice, got %d entries", len(result)) + } + }) - s := Summary(events) - - total, _ := s["total"].(int) - if total != 3 { - t.Errorf("expected total 3, got %d", total) - } - - byType, _ := s["by_type"].([]map[string]any) - if len(byType) != 2 { - t.Fatalf("expected 2 types, got %d", len(byType)) - } - // Sorted by count descending — "build" (2) first - if byType[0]["key"] != "build" || byType[0]["count"] != 2 { - t.Errorf("expected build:2 first, got %v:%v", byType[0]["key"], byType[0]["count"]) - } -} - -// --- sortedMap --- - -func TestSortedMap_Good_Empty(t *testing.T) { - result := sortedMap(map[string]int{}) - if len(result) != 0 { - t.Errorf("expected empty slice, got %d entries", len(result)) - } -} - -func TestSortedMap_Good_Ordering(t *testing.T) { - m := map[string]int{"a": 1, "b": 3, "c": 2} - result := sortedMap(m) - if len(result) != 3 { - t.Fatalf("expected 3 entries, got %d", len(result)) - } - // Descending by count - if result[0]["key"] != "b" { - t.Errorf("expected first key 'b', got %v", result[0]["key"]) - } - if result[1]["key"] != "c" { - t.Errorf("expected second key 'c', got %v", result[1]["key"]) - } - if result[2]["key"] != "a" { - t.Errorf("expected third key 'a', got %v", result[2]["key"]) - } + t.Run("Ordering", func(t *testing.T) { + m := map[string]int{"a": 1, "b": 3, "c": 2} + result := sortedMap(m) + if len(result) != 3 { + t.Fatalf("expected 3 entries, got %d", len(result)) + } + if result[0]["key"] != "b" { + t.Errorf("expected first key 'b', got %v", result[0]["key"]) + } + if result[1]["key"] != "c" { + t.Errorf("expected second key 'c', got %v", result[1]["key"]) + } + if result[2]["key"] != "a" { + t.Errorf("expected third key 'a', got %v", result[2]["key"]) + } + }) } diff --git a/ai/rag.go b/ai/rag.go index f74a2b3..589ae7f 100644 --- a/ai/rag.go +++ b/ai/rag.go @@ -4,12 +4,16 @@ import ( "context" "time" - coreerr "dappco.re/go/core/log" + "dappco.re/go/core" "forge.lthn.ai/core/go-rag" ) // TaskInfo carries the minimal task data needed for RAG queries, // avoiding a direct dependency on pkg/agentic (which imports pkg/ai). +// +// Example: +// +// task := TaskInfo{Title: "Fix metrics", Description: "Need storage docs"} type TaskInfo struct { Title string Description string @@ -18,8 +22,12 @@ type TaskInfo struct { // QueryRAGForTask queries Qdrant for documentation relevant to a task. // It builds a query from the task title and description, queries with // sensible defaults, and returns formatted context. +// +// Example: +// +// context, err := QueryRAGForTask(TaskInfo{Title: "Deploy", Description: "Need release steps"}) func QueryRAGForTask(task TaskInfo) (string, error) { - query := task.Title + " " + task.Description + query := core.Concat(task.Title, " ", task.Description) // Truncate to 500 runes to keep the embedding focused. runes := []rune(query) @@ -30,14 +38,14 @@ func QueryRAGForTask(task TaskInfo) (string, error) { qdrantCfg := rag.DefaultQdrantConfig() qdrantClient, err := rag.NewQdrantClient(qdrantCfg) if err != nil { - return "", coreerr.E("ai.QueryRAGForTask", "rag qdrant client", err) + return "", core.E("ai.QueryRAGForTask", "rag qdrant client", err) } defer func() { _ = qdrantClient.Close() }() ollamaCfg := rag.DefaultOllamaConfig() ollamaClient, err := rag.NewOllamaClient(ollamaCfg) if err != nil { - return "", coreerr.E("ai.QueryRAGForTask", "rag ollama client", err) + return "", core.E("ai.QueryRAGForTask", "rag ollama client", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -51,7 +59,7 @@ func QueryRAGForTask(task TaskInfo) (string, error) { results, err := rag.Query(ctx, qdrantClient, ollamaClient, query, queryCfg) if err != nil { - return "", coreerr.E("ai.QueryRAGForTask", "rag query", err) + return "", core.E("ai.QueryRAGForTask", "rag query", err) } return rag.FormatResultsContext(results), nil diff --git a/cmd/embed-bench/main.go b/cmd/embed-bench/main.go index c476f07..93b4340 100644 --- a/cmd/embed-bench/main.go +++ b/cmd/embed-bench/main.go @@ -10,19 +10,14 @@ package main import ( - "bytes" "crypto/tls" - "encoding/json" "flag" - "fmt" "math" "net/http" - "os" "sort" - "strings" "time" - coreerr "dappco.re/go/core/log" + "dappco.re/go/core" ) var ollamaURL = flag.String("ollama", "http://localhost:11434", "Ollama base URL") @@ -91,16 +86,16 @@ var queries = []struct { func main() { flag.Parse() - fmt.Println("OpenBrain Embedding Model Benchmark") - fmt.Println(strings.Repeat("=", 60)) + core.Println("OpenBrain Embedding Model Benchmark") + core.Println(repeat("=", 60)) for _, model := range models { - fmt.Printf("\n## Model: %s\n", model) - fmt.Println(strings.Repeat("-", 40)) + core.Println(core.Sprintf("\n## Model: %s", model)) + core.Println(repeat("-", 40)) // Check model is available if !modelAvailable(model) { - fmt.Printf(" SKIPPED — model not pulled (run: ollama pull %s)\n", model) + core.Println(core.Sprintf(" SKIPPED - model not pulled (run: ollama pull %s)", model)) continue } @@ -114,20 +109,20 @@ func main() { } } - fmt.Printf(" Embedding %d memories...\n", len(allMemories)) + core.Println(core.Sprintf(" Embedding %d memories...", len(allMemories))) start := time.Now() memVectors := make([][]float64, len(allMemories)) for i, mem := range allMemories { vec, err := embed(model, mem) if err != nil { - fmt.Printf(" ERROR embedding memory %d: %v\n", i, err) + core.Println(core.Sprintf(" ERROR embedding memory %d: %v", i, err)) break } memVectors[i] = vec } embedTime := time.Since(start) - fmt.Printf(" Embedded in %v (%.0fms/memory)\n", embedTime, float64(embedTime.Milliseconds())/float64(len(allMemories))) - fmt.Printf(" Vector dimension: %d\n", len(memVectors[0])) + core.Println(core.Sprintf(" Embedded in %v (%.0fms/memory)", embedTime, float64(embedTime.Milliseconds())/float64(len(allMemories)))) + core.Println(core.Sprintf(" Vector dimension: %d", len(memVectors[0]))) // 2. Intra-group vs inter-group similarity var intraSims, interSims []float64 @@ -146,18 +141,18 @@ func main() { interAvg := avg(interSims) separation := intraAvg - interAvg - fmt.Printf("\n Cluster separation:\n") - fmt.Printf(" Intra-group similarity (same topic): %.4f\n", intraAvg) - fmt.Printf(" Inter-group similarity (diff topic): %.4f\n", interAvg) - fmt.Printf(" Separation gap: %.4f %s\n", separation, qualityLabel(separation)) + core.Println("\n Cluster separation:") + core.Println(core.Sprintf(" Intra-group similarity (same topic): %.4f", intraAvg)) + core.Println(core.Sprintf(" Inter-group similarity (diff topic): %.4f", interAvg)) + core.Println(core.Sprintf(" Separation gap: %.4f %s", separation, qualityLabel(separation))) // 3. Query recall accuracy - fmt.Printf("\n Query recall (top-1 accuracy):\n") + core.Println("\n Query recall (top-1 accuracy):") correct := 0 for _, q := range queries { qVec, err := embed(model, q.query) if err != nil { - fmt.Printf(" ERROR: %v\n", err) + core.Println(core.Sprintf(" ERROR: %v", err)) continue } @@ -181,11 +176,11 @@ func main() { if !hit { marker = "✗" } - fmt.Printf(" %s %.4f %q → %s (want: %s)\n", marker, bestSim, truncate(q.query, 40), matchTopic, q.targetTopic) + core.Println(core.Sprintf(" %s %.4f %q -> %s (want: %s)", marker, bestSim, truncate(q.query, 40), matchTopic, q.targetTopic)) } accuracy := float64(correct) / float64(len(queries)) * 100 - fmt.Printf("\n Top-1 accuracy: %.0f%% (%d/%d)\n", accuracy, correct, len(queries)) + core.Println(core.Sprintf("\n Top-1 accuracy: %.0f%% (%d/%d)", accuracy, correct, len(queries))) // 4. Top-3 recall correct3 := 0 @@ -210,11 +205,11 @@ func main() { } } accuracy3 := float64(correct3) / float64(len(queries)) * 100 - fmt.Printf(" Top-3 accuracy: %.0f%% (%d/%d)\n", accuracy3, correct3, len(queries)) + core.Println(core.Sprintf(" Top-3 accuracy: %.0f%% (%d/%d)", accuracy3, correct3, len(queries))) } - fmt.Println("\n" + strings.Repeat("=", 60)) - fmt.Println("Done.") + core.Println(core.Concat("\n", repeat("=", 60))) + core.Println("Done.") } // -- Ollama helpers -- @@ -236,40 +231,57 @@ type embedResponse struct { } func embed(model, text string) ([]float64, error) { - body, _ := json.Marshal(embedRequest{Model: model, Prompt: text}) - resp, err := httpClient.Post(*ollamaURL+"/api/embeddings", "application/json", bytes.NewReader(body)) + body := core.JSONMarshal(embedRequest{Model: model, Prompt: text}) + if !body.OK { + return nil, core.E("embed", "marshal request", body.Value.(error)) + } + + resp, err := httpClient.Post(core.Concat(*ollamaURL, "/api/embeddings"), "application/json", core.NewReader(string(body.Value.([]byte)))) if err != nil { return nil, err } - defer resp.Body.Close() - if resp.StatusCode != 200 { - return nil, coreerr.E("embed", fmt.Sprintf("HTTP %d", resp.StatusCode), nil) + if resp.StatusCode != http.StatusOK { + core.CloseStream(resp.Body) + return nil, core.E("embed", core.Sprintf("HTTP %d", resp.StatusCode), nil) } + + payload := core.ReadAll(resp.Body) + if !payload.OK { + return nil, core.E("embed", "read response body", payload.Value.(error)) + } + var result embedResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err + if decoded := core.JSONUnmarshalString(payload.Value.(string), &result); !decoded.OK { + return nil, core.E("embed", "decode response", decoded.Value.(error)) } if len(result.Embedding) == 0 { - return nil, coreerr.E("embed", "empty embedding", nil) + return nil, core.E("embed", "empty embedding", nil) } return result.Embedding, nil } func modelAvailable(model string) bool { - resp, err := httpClient.Get(*ollamaURL + "/api/tags") + resp, err := httpClient.Get(core.Concat(*ollamaURL, "/api/tags")) if err != nil { return false } - defer resp.Body.Close() + + payload := core.ReadAll(resp.Body) + if !payload.OK { + return false + } + var result struct { Models []struct { Name string `json:"name"` } `json:"models"` } - json.NewDecoder(resp.Body).Decode(&result) + if decoded := core.JSONUnmarshalString(payload.Value.(string), &result); !decoded.OK { + return false + } for _, m := range result.Models { // Match "nomic-embed-text:latest" against "nomic-embed-text" - if m.Name == model || strings.HasPrefix(m.Name, model+":") { + if m.Name == model || core.HasPrefix(m.Name, core.Concat(model, ":")) { return true } } @@ -316,14 +328,17 @@ func qualityLabel(gap float64) string { } } +func repeat(s string, n int) string { + b := core.NewBuilder() + for range n { + b.WriteString(s) + } + return b.String() +} + func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n-3] + "..." } - -func init() { - // Ensure stderr doesn't buffer - os.Stderr.Sync() -} diff --git a/cmd/lab/cmd_lab.go b/cmd/lab/cmd_lab.go index e814079..2a6d28f 100644 --- a/cmd/lab/cmd_lab.go +++ b/cmd/lab/cmd_lab.go @@ -5,10 +5,11 @@ package lab import ( "context" + "io" "log/slog" "net/http" - "os" "os/signal" + "syscall" "time" "forge.lthn.ai/core/cli/pkg/cli" @@ -43,6 +44,10 @@ func init() { } // AddLabCommands registers the 'lab' command and subcommands. +// +// Example: +// +// AddLabCommands(root) func AddLabCommands(root *cli.Command) { labCmd.AddCommand(serveCmd) root.AddCommand(labCmd) @@ -53,7 +58,7 @@ func runServe(cmd *cli.Command, args []string) error { cfg.Addr = labBind store := lab.NewStore() - logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) // Setup collectors. reg := collector.NewRegistry(logger) @@ -79,7 +84,7 @@ func runServe(cmd *cli.Command, args []string) error { time.Duration(cfg.InfluxInterval)*time.Second) } - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT) defer cancel() reg.Start(ctx) defer reg.Stop() diff --git a/cmd/metrics/cmd.go b/cmd/metrics/cmd.go index e1b9b27..e31c5a8 100644 --- a/cmd/metrics/cmd.go +++ b/cmd/metrics/cmd.go @@ -2,14 +2,13 @@ package metrics import ( - "encoding/json" - "fmt" + "strconv" "time" - "forge.lthn.ai/core/cli/pkg/cli" + "dappco.re/go/core" "dappco.re/go/core/ai/ai" "dappco.re/go/core/i18n" - coreerr "dappco.re/go/core/log" + "forge.lthn.ai/core/cli/pkg/cli" ) var ( @@ -32,6 +31,10 @@ func initMetricsFlags() { } // AddMetricsCommand adds the 'metrics' command to the parent. +// +// Example: +// +// AddMetricsCommand(root) func AddMetricsCommand(parent *cli.Command) { initMetricsFlags() parent.AddCommand(metricsCmd) @@ -51,11 +54,11 @@ func runMetrics() error { if metricsJSON { summary := ai.Summary(events) - output, err := json.MarshalIndent(summary, "", " ") - if err != nil { - return cli.Wrap(err, "marshal JSON output") + output := core.JSONMarshal(summary) + if !output.OK { + return cli.Wrap(output.Value.(error), "marshal JSON output") } - cli.Text(string(output)) + cli.Text(string(output.Value.([]byte))) return nil } @@ -104,19 +107,19 @@ func runMetrics() error { // parseDuration parses a human-friendly duration like "7d", "24h", "30d". func parseDuration(s string) (time.Duration, error) { if len(s) < 2 { - return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("invalid duration: %s", s), nil) + return 0, core.E("metrics.parseDuration", core.Concat("invalid duration: ", s), nil) } unit := s[len(s)-1] value := s[:len(s)-1] - var n int - if _, err := fmt.Sscanf(value, "%d", &n); err != nil { - return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("invalid duration: %s", s), nil) + n, err := strconv.Atoi(value) + if err != nil { + return 0, core.E("metrics.parseDuration", core.Concat("invalid duration: ", s), err) } if n <= 0 { - return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("duration must be positive: %s", s), nil) + return 0, core.E("metrics.parseDuration", core.Concat("duration must be positive: ", s), nil) } switch unit { @@ -127,6 +130,6 @@ func parseDuration(s string) (time.Duration, error) { case 'm': return time.Duration(n) * time.Minute, nil default: - return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("unknown unit %c in duration: %s", unit, s), nil) + return 0, core.E("metrics.parseDuration", core.Concat("unknown unit ", string(unit), " in duration: ", s), nil) } } diff --git a/cmd/metrics/cmd_test.go b/cmd/metrics/cmd_test.go index 7fb9c78..4712151 100644 --- a/cmd/metrics/cmd_test.go +++ b/cmd/metrics/cmd_test.go @@ -5,7 +5,7 @@ import ( "time" ) -func TestParseDuration_Good(t *testing.T) { +func TestCmd_ParseDuration_Good(t *testing.T) { tests := []struct { input string want time.Duration @@ -29,7 +29,7 @@ func TestParseDuration_Good(t *testing.T) { } } -func TestParseDuration_Bad(t *testing.T) { +func TestCmd_ParseDuration_Bad(t *testing.T) { bad := []string{ "", // too short "d", // too short diff --git a/cmd/rag/cmd.go b/cmd/rag/cmd.go index ad27204..1d86c1d 100644 --- a/cmd/rag/cmd.go +++ b/cmd/rag/cmd.go @@ -4,4 +4,8 @@ package rag import ragcmd "forge.lthn.ai/core/go-rag/cmd/rag" // AddRAGSubcommands registers RAG commands as subcommands of parent. +// +// Example: +// +// AddRAGSubcommands(root) var AddRAGSubcommands = ragcmd.AddRAGSubcommands diff --git a/cmd/security/cmd_alerts.go b/cmd/security/cmd_alerts.go index 1ad7025..9c984ab 100644 --- a/cmd/security/cmd_alerts.go +++ b/cmd/security/cmd_alerts.go @@ -1,11 +1,9 @@ package security import ( - "encoding/json" - "fmt" - - "forge.lthn.ai/core/cli/pkg/cli" + "dappco.re/go/core" "dappco.re/go/core/i18n" + "forge.lthn.ai/core/cli/pkg/cli" ) func addAlertsCommand(parent *cli.Command) { @@ -28,6 +26,10 @@ func addAlertsCommand(parent *cli.Command) { } // AlertOutput represents a unified alert for output. +// +// Example: +// +// var alert AlertOutput type AlertOutput struct { Repo string `json:"repo"` Severity string `json:"severity"` @@ -63,7 +65,7 @@ func runAlerts() error { summary := &AlertSummary{} for _, repo := range repoList { - repoFullName := fmt.Sprintf("%s/%s", reg.Org, repo.Name) + repoFullName := core.Concat(reg.Org, "/", repo.Name) // Fetch Dependabot alerts depAlerts, err := fetchDependabotAlerts(repoFullName) @@ -101,7 +103,7 @@ func runAlerts() error { continue } summary.Add(severity) - location := fmt.Sprintf("%s:%d", alert.MostRecentInstance.Location.Path, alert.MostRecentInstance.Location.StartLine) + location := core.Sprintf("%s:%d", alert.MostRecentInstance.Location.Path, alert.MostRecentInstance.Location.StartLine) allAlerts = append(allAlerts, AlertOutput{ Repo: repo.Name, Severity: severity, @@ -127,7 +129,7 @@ func runAlerts() error { allAlerts = append(allAlerts, AlertOutput{ Repo: repo.Name, Severity: "high", - ID: fmt.Sprintf("secret-%d", alert.Number), + ID: core.Sprintf("secret-%d", alert.Number), Type: "secret-scanning", Message: alert.SecretType, }) @@ -136,12 +138,7 @@ func runAlerts() error { } if securityJSON { - output, err := json.MarshalIndent(allAlerts, "", " ") - if err != nil { - return cli.Wrap(err, "marshal JSON output") - } - cli.Text(string(output)) - return nil + return printJSON(allAlerts) } // Print summary @@ -163,12 +160,12 @@ func runAlerts() error { location = alert.Location } if alert.Version != "" { - location = fmt.Sprintf("%s %s", location, cli.DimStyle.Render(alert.Version)) + location = core.Sprintf("%s %s", location, cli.DimStyle.Render(alert.Version)) } cli.Print("%-20s %s %-16s %-40s %s\n", cli.ValueStyle.Render(alert.Repo), - sevStyle.Render(fmt.Sprintf("%-8s", alert.Severity)), + sevStyle.Render(core.Sprintf("%-8s", alert.Severity)), alert.ID, location, cli.DimStyle.Render(alert.Type), @@ -225,7 +222,7 @@ func runAlertsForTarget(target string) error { continue } summary.Add(severity) - location := fmt.Sprintf("%s:%d", alert.MostRecentInstance.Location.Path, alert.MostRecentInstance.Location.StartLine) + location := core.Sprintf("%s:%d", alert.MostRecentInstance.Location.Path, alert.MostRecentInstance.Location.StartLine) allAlerts = append(allAlerts, AlertOutput{ Repo: repo.Name, Severity: severity, @@ -251,7 +248,7 @@ func runAlertsForTarget(target string) error { allAlerts = append(allAlerts, AlertOutput{ Repo: repo.Name, Severity: "high", - ID: fmt.Sprintf("secret-%d", alert.Number), + ID: core.Sprintf("secret-%d", alert.Number), Type: "secret-scanning", Message: alert.SecretType, }) @@ -259,12 +256,7 @@ func runAlertsForTarget(target string) error { } if securityJSON { - output, err := json.MarshalIndent(allAlerts, "", " ") - if err != nil { - return cli.Wrap(err, "marshal JSON output") - } - cli.Text(string(output)) - return nil + return printJSON(allAlerts) } cli.Blank() @@ -282,11 +274,11 @@ func runAlertsForTarget(target string) error { location = alert.Location } if alert.Version != "" { - location = fmt.Sprintf("%s %s", location, cli.DimStyle.Render(alert.Version)) + location = core.Sprintf("%s %s", location, cli.DimStyle.Render(alert.Version)) } cli.Print("%-20s %s %-16s %-40s %s\n", cli.ValueStyle.Render(alert.Repo), - sevStyle.Render(fmt.Sprintf("%-8s", alert.Severity)), + sevStyle.Render(core.Sprintf("%-8s", alert.Severity)), alert.ID, location, cli.DimStyle.Render(alert.Type), @@ -298,43 +290,43 @@ func runAlertsForTarget(target string) error { } func fetchDependabotAlerts(repoFullName string) ([]DependabotAlert, error) { - endpoint := fmt.Sprintf("repos/%s/dependabot/alerts?state=open", repoFullName) + endpoint := core.Concat("repos/", repoFullName, "/dependabot/alerts?state=open") output, err := runGHAPI(endpoint) if err != nil { - return nil, cli.Wrap(err, fmt.Sprintf("fetch dependabot alerts for %s", repoFullName)) + return nil, cli.Wrap(err, core.Sprintf("fetch dependabot alerts for %s", repoFullName)) } var alerts []DependabotAlert - if err := json.Unmarshal(output, &alerts); err != nil { - return nil, cli.Wrap(err, fmt.Sprintf("parse dependabot alerts for %s", repoFullName)) + if decoded := core.JSONUnmarshal(output, &alerts); !decoded.OK { + return nil, cli.Wrap(decoded.Value.(error), core.Sprintf("parse dependabot alerts for %s", repoFullName)) } return alerts, nil } func fetchCodeScanningAlerts(repoFullName string) ([]CodeScanningAlert, error) { - endpoint := fmt.Sprintf("repos/%s/code-scanning/alerts?state=open", repoFullName) + endpoint := core.Concat("repos/", repoFullName, "/code-scanning/alerts?state=open") output, err := runGHAPI(endpoint) if err != nil { - return nil, cli.Wrap(err, fmt.Sprintf("fetch code-scanning alerts for %s", repoFullName)) + return nil, cli.Wrap(err, core.Sprintf("fetch code-scanning alerts for %s", repoFullName)) } var alerts []CodeScanningAlert - if err := json.Unmarshal(output, &alerts); err != nil { - return nil, cli.Wrap(err, fmt.Sprintf("parse code-scanning alerts for %s", repoFullName)) + if decoded := core.JSONUnmarshal(output, &alerts); !decoded.OK { + return nil, cli.Wrap(decoded.Value.(error), core.Sprintf("parse code-scanning alerts for %s", repoFullName)) } return alerts, nil } func fetchSecretScanningAlerts(repoFullName string) ([]SecretScanningAlert, error) { - endpoint := fmt.Sprintf("repos/%s/secret-scanning/alerts?state=open", repoFullName) + endpoint := core.Concat("repos/", repoFullName, "/secret-scanning/alerts?state=open") output, err := runGHAPI(endpoint) if err != nil { - return nil, cli.Wrap(err, fmt.Sprintf("fetch secret-scanning alerts for %s", repoFullName)) + return nil, cli.Wrap(err, core.Sprintf("fetch secret-scanning alerts for %s", repoFullName)) } var alerts []SecretScanningAlert - if err := json.Unmarshal(output, &alerts); err != nil { - return nil, cli.Wrap(err, fmt.Sprintf("parse secret-scanning alerts for %s", repoFullName)) + if decoded := core.JSONUnmarshal(output, &alerts); !decoded.OK { + return nil, cli.Wrap(decoded.Value.(error), core.Sprintf("parse secret-scanning alerts for %s", repoFullName)) } return alerts, nil } diff --git a/cmd/security/cmd_deps.go b/cmd/security/cmd_deps.go index 31308b1..5bef48a 100644 --- a/cmd/security/cmd_deps.go +++ b/cmd/security/cmd_deps.go @@ -1,11 +1,9 @@ package security import ( - "encoding/json" - "fmt" - - "forge.lthn.ai/core/cli/pkg/cli" + "dappco.re/go/core" "dappco.re/go/core/i18n" + "forge.lthn.ai/core/cli/pkg/cli" ) func addDepsCommand(parent *cli.Command) { @@ -28,6 +26,10 @@ func addDepsCommand(parent *cli.Command) { } // DepAlert represents a dependency vulnerability for output. +// +// Example: +// +// var alert DepAlert type DepAlert struct { Repo string `json:"repo"` Severity string `json:"severity"` @@ -64,7 +66,7 @@ func runDeps() error { summary := &AlertSummary{} for _, repo := range repoList { - repoFullName := fmt.Sprintf("%s/%s", reg.Org, repo.Name) + repoFullName := core.Concat(reg.Org, "/", repo.Name) alerts, err := fetchDependabotAlerts(repoFullName) if err != nil { @@ -100,12 +102,7 @@ func runDeps() error { } if securityJSON { - output, err := json.MarshalIndent(allAlerts, "", " ") - if err != nil { - return cli.Wrap(err, "marshal JSON output") - } - cli.Text(string(output)) - return nil + return printJSON(allAlerts) } // Print summary @@ -124,12 +121,12 @@ func runDeps() error { // Format upgrade suggestion upgrade := alert.Vulnerable if alert.PatchedVersion != "" { - upgrade = fmt.Sprintf("%s -> %s", alert.Vulnerable, cli.SuccessStyle.Render(alert.PatchedVersion)) + upgrade = core.Sprintf("%s -> %s", alert.Vulnerable, cli.SuccessStyle.Render(alert.PatchedVersion)) } cli.Print("%-16s %s %-16s %-30s %s\n", cli.ValueStyle.Render(alert.Repo), - sevStyle.Render(fmt.Sprintf("%-8s", alert.Severity)), + sevStyle.Render(core.Sprintf("%-8s", alert.Severity)), alert.CVE, alert.Package, upgrade, @@ -178,12 +175,7 @@ func runDepsForTarget(target string) error { } if securityJSON { - output, err := json.MarshalIndent(allAlerts, "", " ") - if err != nil { - return cli.Wrap(err, "marshal JSON output") - } - cli.Text(string(output)) - return nil + return printJSON(allAlerts) } cli.Blank() @@ -194,11 +186,11 @@ func runDepsForTarget(target string) error { sevStyle := severityStyle(alert.Severity) upgrade := alert.Vulnerable if alert.PatchedVersion != "" { - upgrade = fmt.Sprintf("%s -> %s", alert.Vulnerable, cli.SuccessStyle.Render(alert.PatchedVersion)) + upgrade = core.Sprintf("%s -> %s", alert.Vulnerable, cli.SuccessStyle.Render(alert.PatchedVersion)) } cli.Print("%-16s %s %-16s %-30s %s\n", cli.ValueStyle.Render(alert.Repo), - sevStyle.Render(fmt.Sprintf("%-8s", alert.Severity)), + sevStyle.Render(core.Sprintf("%-8s", alert.Severity)), alert.CVE, alert.Package, upgrade, diff --git a/cmd/security/cmd_jobs.go b/cmd/security/cmd_jobs.go index 48a99a1..953f99a 100644 --- a/cmd/security/cmd_jobs.go +++ b/cmd/security/cmd_jobs.go @@ -1,15 +1,12 @@ package security import ( - "fmt" - "os/exec" - "strings" "time" - "forge.lthn.ai/core/cli/pkg/cli" + "dappco.re/go/core" "dappco.re/go/core/ai/ai" "dappco.re/go/core/i18n" - coreerr "dappco.re/go/core/log" + "forge.lthn.ai/core/cli/pkg/cli" ) var ( @@ -67,9 +64,9 @@ func runJobs() error { } func createJobForTarget(target string) error { - parts := strings.SplitN(target, "/", 2) + parts := core.SplitN(target, "/", 2) if len(parts) != 2 { - return coreerr.E("security.createJobForTarget", "invalid target format: use owner/repo", nil) + return core.E("security.createJobForTarget", "invalid target format: use owner/repo", nil) } // Gather findings @@ -93,8 +90,8 @@ func createJobForTarget(target string) error { severity = "medium" } summary.Add(severity) - findings = append(findings, fmt.Sprintf("- [%s] %s: %s (%s:%d)", - strings.ToUpper(severity), alert.Tool.Name, alert.Rule.Description, + findings = append(findings, core.Sprintf("- [%s] %s: %s (%s:%d)", + core.Upper(severity), alert.Tool.Name, alert.Rule.Description, alert.MostRecentInstance.Location.Path, alert.MostRecentInstance.Location.StartLine)) } } @@ -111,8 +108,8 @@ func createJobForTarget(target string) error { continue } summary.Add(alert.Advisory.Severity) - findings = append(findings, fmt.Sprintf("- [%s] %s: %s (%s)", - strings.ToUpper(alert.Advisory.Severity), alert.Dependency.Package.Name, + findings = append(findings, core.Sprintf("- [%s] %s: %s (%s)", + core.Upper(alert.Advisory.Severity), alert.Dependency.Package.Name, alert.Advisory.Summary, alert.Advisory.CVEID)) } } @@ -129,12 +126,12 @@ func createJobForTarget(target string) error { continue } summary.Add("high") - findings = append(findings, fmt.Sprintf("- [HIGH] Secret: %s (#%d)", alert.SecretType, alert.Number)) + findings = append(findings, core.Sprintf("- [HIGH] Secret: %s (#%d)", alert.SecretType, alert.Number)) } } if fetchErrors == 3 { - return coreerr.E("security.createJobForTarget", fmt.Sprintf("failed to fetch any alerts for %s", target), nil) + return core.E("security.createJobForTarget", core.Sprintf("failed to fetch any alerts for %s", target), nil) } if summary.Total == 0 { @@ -143,13 +140,13 @@ func createJobForTarget(target string) error { } // Build issue body - title := fmt.Sprintf("Security scan: %s", target) + title := core.Sprintf("Security scan: %s", target) body := buildJobIssueBody(target, summary, findings) for i := range jobsCopies { issueTitle := title if jobsCopies > 1 { - issueTitle = fmt.Sprintf("%s (#%d)", title, i+1) + issueTitle = core.Sprintf("%s (#%d)", title, i+1) } if jobsDryRun { @@ -161,20 +158,16 @@ func createJobForTarget(target string) error { continue } - // Create issue via gh CLI - cmd := exec.Command("gh", "issue", "create", - "--repo", jobsIssueRepo, - "--title", issueTitle, - "--body", body, - "--label", "type:security-scan,repo:"+target, + issueURL, err := createGitHubIssue( + jobsIssueRepo, + issueTitle, + body, + []string{"type:security-scan", core.Concat("repo:", target)}, ) - - output, err := cmd.CombinedOutput() if err != nil { - return cli.Wrap(err, fmt.Sprintf("create issue for %s: %s", target, string(output))) + return cli.Wrap(err, core.Sprintf("create issue for %s", target)) } - issueURL := strings.TrimSpace(string(output)) cli.Print("%s %s: %s\n", cli.SuccessStyle.Render(">>"), issueTitle, issueURL) // Record metrics @@ -196,10 +189,10 @@ func createJobForTarget(target string) error { } func buildJobIssueBody(target string, summary *AlertSummary, findings []string) string { - var sb strings.Builder + sb := core.NewBuilder() - fmt.Fprintf(&sb, "## Security Scan: %s\n\n", target) - fmt.Fprintf(&sb, "**Summary:** %s\n\n", summary.String()) + sb.WriteString(core.Sprintf("## Security Scan: %s\n\n", target)) + sb.WriteString(core.Sprintf("**Summary:** %s\n\n", summary.String())) sb.WriteString("### Findings\n\n") if len(findings) > 50 { @@ -207,7 +200,7 @@ func buildJobIssueBody(target string, summary *AlertSummary, findings []string) for _, f := range findings[:50] { sb.WriteString(f + "\n") } - fmt.Fprintf(&sb, "\n... and %d more\n", len(findings)-50) + sb.WriteString(core.Sprintf("\n... and %d more\n", len(findings)-50)) } else { for _, f := range findings { sb.WriteString(f + "\n") @@ -222,7 +215,7 @@ func buildJobIssueBody(target string, summary *AlertSummary, findings []string) sb.WriteString("\n### Instructions\n\n") sb.WriteString("1. Claim this issue by assigning yourself\n") - fmt.Fprintf(&sb, "2. Run `core security alerts --target %s` for the latest findings\n", target) + sb.WriteString(core.Sprintf("2. Run `core security alerts --target %s` for the latest findings\n", target)) sb.WriteString("3. Work through the checklist above\n") sb.WriteString("4. Close this issue when all findings are addressed\n") diff --git a/cmd/security/cmd_scan.go b/cmd/security/cmd_scan.go index a9ea6f0..1893c17 100644 --- a/cmd/security/cmd_scan.go +++ b/cmd/security/cmd_scan.go @@ -1,13 +1,12 @@ package security import ( - "encoding/json" - "fmt" "time" + "dappco.re/go/core" "dappco.re/go/core/ai/ai" - "forge.lthn.ai/core/cli/pkg/cli" "dappco.re/go/core/i18n" + "forge.lthn.ai/core/cli/pkg/cli" ) var ( @@ -35,6 +34,10 @@ func addScanCommand(parent *cli.Command) { } // ScanAlert represents a code scanning alert for output. +// +// Example: +// +// var alert ScanAlert type ScanAlert struct { Repo string `json:"repo"` Severity string `json:"severity"` @@ -70,7 +73,7 @@ func runScan() error { summary := &AlertSummary{} for _, repo := range repoList { - repoFullName := fmt.Sprintf("%s/%s", reg.Org, repo.Name) + repoFullName := core.Concat(reg.Org, "/", repo.Name) alerts, err := fetchCodeScanningAlerts(repoFullName) if err != nil { @@ -127,12 +130,7 @@ func runScan() error { }) if securityJSON { - output, err := json.MarshalIndent(allAlerts, "", " ") - if err != nil { - return cli.Wrap(err, "marshal JSON output") - } - cli.Text(string(output)) - return nil + return printJSON(allAlerts) } // Print summary @@ -148,11 +146,11 @@ func runScan() error { for _, alert := range allAlerts { sevStyle := severityStyle(alert.Severity) - location := fmt.Sprintf("%s:%d", alert.Path, alert.Line) + location := core.Sprintf("%s:%d", alert.Path, alert.Line) cli.Print("%-16s %s %-20s %-40s %s\n", cli.ValueStyle.Render(alert.Repo), - sevStyle.Render(fmt.Sprintf("%-8s", alert.Severity)), + sevStyle.Render(core.Sprintf("%-8s", alert.Severity)), alert.RuleID, location, cli.DimStyle.Render(alert.Tool), @@ -221,12 +219,7 @@ func runScanForTarget(target string) error { }) if securityJSON { - output, err := json.MarshalIndent(allAlerts, "", " ") - if err != nil { - return cli.Wrap(err, "marshal JSON output") - } - cli.Text(string(output)) - return nil + return printJSON(allAlerts) } cli.Blank() @@ -239,10 +232,10 @@ func runScanForTarget(target string) error { for _, alert := range allAlerts { sevStyle := severityStyle(alert.Severity) - location := fmt.Sprintf("%s:%d", alert.Path, alert.Line) + location := core.Sprintf("%s:%d", alert.Path, alert.Line) cli.Print("%-16s %s %-20s %-40s %s\n", cli.ValueStyle.Render(alert.Repo), - sevStyle.Render(fmt.Sprintf("%-8s", alert.Severity)), + sevStyle.Render(core.Sprintf("%-8s", alert.Severity)), alert.RuleID, location, cli.DimStyle.Render(alert.Tool), diff --git a/cmd/security/cmd_secrets.go b/cmd/security/cmd_secrets.go index efb0927..e061dd5 100644 --- a/cmd/security/cmd_secrets.go +++ b/cmd/security/cmd_secrets.go @@ -1,11 +1,9 @@ package security import ( - "encoding/json" - "fmt" - - "forge.lthn.ai/core/cli/pkg/cli" + "dappco.re/go/core" "dappco.re/go/core/i18n" + "forge.lthn.ai/core/cli/pkg/cli" ) func addSecretsCommand(parent *cli.Command) { @@ -27,6 +25,10 @@ func addSecretsCommand(parent *cli.Command) { } // SecretAlert represents a secret scanning alert for output. +// +// Example: +// +// var alert SecretAlert type SecretAlert struct { Repo string `json:"repo"` Number int `json:"number"` @@ -60,7 +62,7 @@ func runSecrets() error { openCount := 0 for _, repo := range repoList { - repoFullName := fmt.Sprintf("%s/%s", reg.Org, repo.Name) + repoFullName := core.Concat(reg.Org, "/", repo.Name) alerts, err := fetchSecretScanningAlerts(repoFullName) if err != nil { @@ -86,18 +88,13 @@ func runSecrets() error { } if securityJSON { - output, err := json.MarshalIndent(allAlerts, "", " ") - if err != nil { - return cli.Wrap(err, "marshal JSON output") - } - cli.Text(string(output)) - return nil + return printJSON(allAlerts) } // Print summary cli.Blank() if openCount > 0 { - cli.Print("%s %s\n", cli.DimStyle.Render("Secrets:"), cli.ErrorStyle.Render(fmt.Sprintf("%d open", openCount))) + cli.Print("%s %s\n", cli.DimStyle.Render("Secrets:"), cli.ErrorStyle.Render(core.Sprintf("%d open", openCount))) } else { cli.Print("%s %s\n", cli.DimStyle.Render("Secrets:"), cli.SuccessStyle.Render("No exposed secrets")) } @@ -157,19 +154,14 @@ func runSecretsForTarget(target string) error { } if securityJSON { - output, err := json.MarshalIndent(allAlerts, "", " ") - if err != nil { - return cli.Wrap(err, "marshal JSON output") - } - cli.Text(string(output)) - return nil + return printJSON(allAlerts) } cli.Blank() if openCount > 0 { - cli.Print("%s %s\n", cli.DimStyle.Render("Secrets ("+fullName+"):"), cli.ErrorStyle.Render(fmt.Sprintf("%d open", openCount))) + cli.Print("%s %s\n", cli.DimStyle.Render(core.Concat("Secrets (", fullName, "):")), cli.ErrorStyle.Render(core.Sprintf("%d open", openCount))) } else { - cli.Print("%s %s\n", cli.DimStyle.Render("Secrets ("+fullName+"):"), cli.SuccessStyle.Render("No exposed secrets")) + cli.Print("%s %s\n", cli.DimStyle.Render(core.Concat("Secrets (", fullName, "):")), cli.SuccessStyle.Render("No exposed secrets")) } cli.Blank() diff --git a/cmd/security/cmd_security.go b/cmd/security/cmd_security.go index 44a815f..362c57a 100644 --- a/cmd/security/cmd_security.go +++ b/cmd/security/cmd_security.go @@ -1,15 +1,12 @@ package security import ( - "fmt" - "os/exec" "slices" - "strings" - "forge.lthn.ai/core/cli/pkg/cli" + "dappco.re/go/core" "dappco.re/go/core/i18n" "dappco.re/go/core/io" - coreerr "dappco.re/go/core/log" + "forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/go-scm/repos" ) @@ -23,6 +20,10 @@ var ( ) // AddSecurityCommands adds the 'security' command to the root. +// +// Example: +// +// AddSecurityCommands(root) func AddSecurityCommands(root *cli.Command) { secCmd := &cli.Command{ Use: "security", @@ -40,6 +41,10 @@ func AddSecurityCommands(root *cli.Command) { } // DependabotAlert represents a Dependabot vulnerability alert. +// +// Example: +// +// var alert DependabotAlert type DependabotAlert struct { Number int `json:"number"` State string `json:"state"` @@ -69,6 +74,10 @@ type DependabotAlert struct { } // CodeScanningAlert represents a code scanning alert. +// +// Example: +// +// var alert CodeScanningAlert type CodeScanningAlert struct { Number int `json:"number"` State string `json:"state"` @@ -96,6 +105,10 @@ type CodeScanningAlert struct { } // SecretScanningAlert represents a secret scanning alert. +// +// Example: +// +// var alert SecretScanningAlert type SecretScanningAlert struct { Number int `json:"number"` State string `json:"state"` @@ -126,37 +139,9 @@ func loadRegistry(registryPath string) (*repos.Registry, error) { return reg, nil } -// checkGH verifies gh CLI is available. -func checkGH() error { - if _, err := exec.LookPath("gh"); err != nil { - return coreerr.E("security.checkGH", i18n.T("error.gh_not_found"), nil) - } - return nil -} - -// runGHAPI runs a gh api command and returns the output. -func runGHAPI(endpoint string) ([]byte, error) { - cmd := exec.Command("gh", "api", endpoint, "--paginate") - output, err := cmd.Output() - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - stderr := string(exitErr.Stderr) - // Handle common errors gracefully - if strings.Contains(stderr, "404") || strings.Contains(stderr, "Not Found") { - return []byte("[]"), nil // Return empty array for not found - } - if strings.Contains(stderr, "403") { - return nil, coreerr.E("security.runGHAPI", "access denied (check token permissions)", nil) - } - } - return nil, cli.Wrap(err, "run gh api") - } - return output, nil -} - // severityStyle returns the appropriate style for a severity level. func severityStyle(severity string) *cli.AnsiStyle { - switch strings.ToLower(severity) { + switch core.Lower(severity) { case "critical": return cli.ErrorStyle case "high": @@ -174,9 +159,9 @@ func filterBySeverity(severity, filter string) bool { return true } - sev := strings.ToLower(severity) - return slices.ContainsFunc(slices.Collect(strings.SplitSeq(strings.ToLower(filter), ",")), func(s string) bool { - return strings.TrimSpace(s) == sev + sev := core.Lower(severity) + return slices.ContainsFunc(core.Split(core.Lower(filter), ","), func(s string) bool { + return core.Trim(s) == sev }) } @@ -193,7 +178,7 @@ func getReposToCheck(reg *repos.Registry, repoFilter string) []*repos.Repo { // buildTargetRepo creates a synthetic Repo entry for an external target (e.g. "wailsapp/wails"). func buildTargetRepo(target string) (*repos.Repo, string) { - parts := strings.SplitN(target, "/", 2) + parts := core.SplitN(target, "/", 2) if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return nil, "" } @@ -201,6 +186,10 @@ func buildTargetRepo(target string) (*repos.Repo, string) { } // AlertSummary holds aggregated alert counts. +// +// Example: +// +// summary := &AlertSummary{} type AlertSummary struct { Critical int High int @@ -211,9 +200,13 @@ type AlertSummary struct { } // Add increments summary counters for the provided severity. +// +// Example: +// +// summary.Add("high") func (s *AlertSummary) Add(severity string) { s.Total++ - switch strings.ToLower(severity) { + switch core.Lower(severity) { case "critical": s.Critical++ case "high": @@ -228,25 +221,29 @@ func (s *AlertSummary) Add(severity string) { } // String renders a human-readable summary of alert counts. +// +// Example: +// +// text := summary.String() func (s *AlertSummary) String() string { parts := []string{} if s.Critical > 0 { - parts = append(parts, cli.ErrorStyle.Render(fmt.Sprintf("%d critical", s.Critical))) + parts = append(parts, cli.ErrorStyle.Render(core.Sprintf("%d critical", s.Critical))) } if s.High > 0 { - parts = append(parts, cli.WarningStyle.Render(fmt.Sprintf("%d high", s.High))) + parts = append(parts, cli.WarningStyle.Render(core.Sprintf("%d high", s.High))) } if s.Medium > 0 { - parts = append(parts, cli.ValueStyle.Render(fmt.Sprintf("%d medium", s.Medium))) + parts = append(parts, cli.ValueStyle.Render(core.Sprintf("%d medium", s.Medium))) } if s.Low > 0 { - parts = append(parts, cli.DimStyle.Render(fmt.Sprintf("%d low", s.Low))) + parts = append(parts, cli.DimStyle.Render(core.Sprintf("%d low", s.Low))) } if s.Unknown > 0 { - parts = append(parts, cli.DimStyle.Render(fmt.Sprintf("%d unknown", s.Unknown))) + parts = append(parts, cli.DimStyle.Render(core.Sprintf("%d unknown", s.Unknown))) } if len(parts) == 0 { return cli.SuccessStyle.Render("No alerts") } - return strings.Join(parts, " | ") + return core.Join(" | ", parts...) } diff --git a/cmd/security/github.go b/cmd/security/github.go new file mode 100644 index 0000000..9825a92 --- /dev/null +++ b/cmd/security/github.go @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package security + +import ( + "net/http" + + "dappco.re/go/core" + "forge.lthn.ai/core/cli/pkg/cli" +) + +const githubAPIBase = "https://api.github.com" + +func printJSON(value any) error { + output := core.JSONMarshal(value) + if !output.OK { + return cli.Wrap(output.Value.(error), "marshal JSON output") + } + + cli.Text(string(output.Value.([]byte))) + return nil +} + +func githubToken() string { + if token := core.Env("GH_TOKEN"); token != "" { + return token + } + return core.Env("GITHUB_TOKEN") +} + +// checkGH verifies GitHub API authentication is configured. +func checkGH() error { + if githubToken() == "" { + return core.E("security.checkGH", "missing GitHub token (set GH_TOKEN or GITHUB_TOKEN)", nil) + } + return nil +} + +// runGHAPI fetches all paginated GitHub API results for an array endpoint. +func runGHAPI(endpoint string) ([]byte, error) { + pages, err := runGHAPIPages(endpoint) + if err != nil { + return nil, err + } + return []byte(combineGitHubPages(pages)), nil +} + +func runGHAPIPages(endpoint string) ([]string, error) { + nextURL := core.Concat(githubAPIBase, "/", endpoint) + pages := make([]string, 0, 1) + + for nextURL != "" { + status, body, headers, err := executeGitHubRequest(http.MethodGet, nextURL, "") + if err != nil { + return nil, err + } + + switch status { + case http.StatusOK: + case http.StatusNotFound: + return nil, nil + case http.StatusForbidden: + return nil, core.E("security.runGHAPI", "access denied (check token permissions)", nil) + default: + msg := core.Sprintf("GitHub API GET %d", status) + if trimmed := core.Trim(body); trimmed != "" { + msg = core.Concat(msg, ": ", trimmed) + } + return nil, core.E("security.runGHAPI", msg, nil) + } + + pages = append(pages, body) + nextURL = nextGitHubPage(headers.Get("Link")) + } + + return pages, nil +} + +func createGitHubIssue(repo, title, body string, labels []string) (string, error) { + payload := core.JSONMarshal(map[string]any{ + "title": title, + "body": body, + "labels": labels, + }) + if !payload.OK { + return "", core.E("security.createGitHubIssue", "marshal issue request", payload.Value.(error)) + } + + status, responseBody, _, err := executeGitHubRequest( + http.MethodPost, + core.Concat(githubAPIBase, "/repos/", repo, "/issues"), + string(payload.Value.([]byte)), + ) + if err != nil { + return "", err + } + if status < http.StatusOK || status >= http.StatusMultipleChoices { + msg := core.Sprintf("GitHub issue create %d", status) + if trimmed := core.Trim(responseBody); trimmed != "" { + msg = core.Concat(msg, ": ", trimmed) + } + return "", core.E("security.createGitHubIssue", msg, nil) + } + + var response struct { + HTMLURL string `json:"html_url"` + } + if decoded := core.JSONUnmarshalString(responseBody, &response); !decoded.OK { + return "", core.E("security.createGitHubIssue", "decode issue response", decoded.Value.(error)) + } + if response.HTMLURL == "" { + return "", core.E("security.createGitHubIssue", "issue response missing html_url", nil) + } + + return response.HTMLURL, nil +} + +func executeGitHubRequest(method, targetURL, body string) (int, string, http.Header, error) { + req, err := http.NewRequest(method, targetURL, core.NewReader(body)) + if err != nil { + return 0, "", nil, core.E("security.executeGitHubRequest", "build request", err) + } + + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "core-ai-security") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + if token := githubToken(); token != "" { + req.Header.Set("Authorization", core.Concat("Bearer ", token)) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, "", nil, core.E("security.executeGitHubRequest", "send request", err) + } + + payload := core.ReadAll(resp.Body) + if !payload.OK { + return 0, "", nil, core.E("security.executeGitHubRequest", "read response body", payload.Value.(error)) + } + + return resp.StatusCode, payload.Value.(string), resp.Header, nil +} + +func combineGitHubPages(pages []string) string { + parts := make([]string, 0, len(pages)) + for _, page := range pages { + trimmed := core.Trim(page) + if trimmed == "" || trimmed == "[]" { + continue + } + trimmed = core.TrimPrefix(trimmed, "[") + trimmed = core.TrimSuffix(trimmed, "]") + trimmed = core.Trim(trimmed) + if trimmed != "" { + parts = append(parts, trimmed) + } + } + if len(parts) == 0 { + return "[]" + } + return core.Concat("[", core.Join(",", parts...), "]") +} + +func nextGitHubPage(linkHeader string) string { + for _, part := range core.Split(linkHeader, ",") { + if !core.Contains(part, `rel="next"`) { + continue + } + urlPart := core.Trim(core.SplitN(part, ";", 2)[0]) + urlPart = core.TrimPrefix(urlPart, "<") + return core.TrimSuffix(urlPart, ">") + } + return "" +} diff --git a/go.mod b/go.mod index 12b86e9..a5eb5ac 100644 --- a/go.mod +++ b/go.mod @@ -3,16 +3,16 @@ module dappco.re/go/core/ai go 1.26.0 require ( + dappco.re/go/core v0.8.0-alpha.1 dappco.re/go/core/i18n v0.2.0 dappco.re/go/core/io v0.2.0 - dappco.re/go/core/log v0.1.0 forge.lthn.ai/core/cli v0.3.7 forge.lthn.ai/core/go-rag v0.1.11 forge.lthn.ai/core/go-scm v0.2.0 ) require ( - dappco.re/go/core v0.8.0-alpha.1 // indirect + dappco.re/go/core/log v0.1.0 // indirect forge.lthn.ai/core/go v0.3.3 // indirect forge.lthn.ai/core/go-i18n v0.1.7 // indirect forge.lthn.ai/core/go-inference v0.1.7 // indirect -- 2.45.3 From a8cee4c6c791f0987030ea5f4398ca2c7df087c3 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 27 Mar 2026 02:58:56 +0000 Subject: [PATCH 3/6] chore(verification): record clean audit pass Co-Authored-By: Virgil -- 2.45.3 From cb5342fb1a43940dcbc4df91ed1d501ec8f9d261 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 27 Mar 2026 04:29:11 +0000 Subject: [PATCH 4/6] fix(core): close remaining v0.8.0 compliance gaps Co-Authored-By: Virgil --- ai/metrics.go | 5 +- ai/metrics_test.go | 17 ++++ cmd/security/cmd_alerts.go | 40 ++++++-- cmd/security/cmd_jobs.go | 2 +- cmd/security/cmd_secrets.go | 1 + cmd/security/cmd_security.go | 52 +++++++--- cmd/security/cmd_security_test.go | 48 ++++++++++ go.sum | 152 ++++++++++++++++++++++++++++++ 8 files changed, 294 insertions(+), 23 deletions(-) create mode 100644 cmd/security/cmd_security_test.go diff --git a/ai/metrics.go b/ai/metrics.go index 6564a11..6b58b6c 100644 --- a/ai/metrics.go +++ b/ai/metrics.go @@ -192,7 +192,10 @@ func sortedMap(m map[string]int) []map[string]any { } slices.SortFunc(entries, func(a, b entry) int { - return cmp.Compare(b.count, a.count) + if diff := cmp.Compare(b.count, a.count); diff != 0 { + return diff + } + return cmp.Compare(a.key, b.key) }) result := make([]map[string]any, len(entries)) diff --git a/ai/metrics_test.go b/ai/metrics_test.go index f34fd6b..c8ac81c 100644 --- a/ai/metrics_test.go +++ b/ai/metrics_test.go @@ -258,4 +258,21 @@ func TestMetrics_SortedMap_Good(t *testing.T) { t.Errorf("expected third key 'a', got %v", result[2]["key"]) } }) + + t.Run("TieBreaksByKey", func(t *testing.T) { + m := map[string]int{"b": 1, "c": 1, "a": 1} + result := sortedMap(m) + if len(result) != 3 { + t.Fatalf("expected 3 entries, got %d", len(result)) + } + if result[0]["key"] != "a" { + t.Errorf("expected first key 'a', got %v", result[0]["key"]) + } + if result[1]["key"] != "b" { + t.Errorf("expected second key 'b', got %v", result[1]["key"]) + } + if result[2]["key"] != "c" { + t.Errorf("expected third key 'c', got %v", result[2]["key"]) + } + }) } diff --git a/cmd/security/cmd_alerts.go b/cmd/security/cmd_alerts.go index 9c984ab..5cda7bb 100644 --- a/cmd/security/cmd_alerts.go +++ b/cmd/security/cmd_alerts.go @@ -66,10 +66,14 @@ func runAlerts() error { for _, repo := range repoList { repoFullName := core.Concat(reg.Org, "/", repo.Name) + fetchErrors := 0 // Fetch Dependabot alerts depAlerts, err := fetchDependabotAlerts(repoFullName) - if err == nil { + if err != nil { + cli.Print("%s %s: %v\n", cli.WarningStyle.Render(">>"), repoFullName, err) + fetchErrors++ + } else { for _, alert := range depAlerts { if alert.State != "open" { continue @@ -93,7 +97,10 @@ func runAlerts() error { // Fetch code scanning alerts codeAlerts, err := fetchCodeScanningAlerts(repoFullName) - if err == nil { + if err != nil { + cli.Print("%s %s: %v\n", cli.WarningStyle.Render(">>"), repoFullName, err) + fetchErrors++ + } else { for _, alert := range codeAlerts { if alert.State != "open" { continue @@ -117,7 +124,10 @@ func runAlerts() error { // Fetch secret scanning alerts secretAlerts, err := fetchSecretScanningAlerts(repoFullName) - if err == nil { + if err != nil { + cli.Print("%s %s: %v\n", cli.WarningStyle.Render(">>"), repoFullName, err) + fetchErrors++ + } else { for _, alert := range secretAlerts { if alert.State != "open" { continue @@ -135,6 +145,10 @@ func runAlerts() error { }) } } + + if fetchErrors == 3 { + cli.Print("%s %s: all alert sources failed\n", cli.WarningStyle.Render(">>"), repoFullName) + } } if securityJSON { @@ -185,10 +199,14 @@ func runAlertsForTarget(target string) error { var allAlerts []AlertOutput summary := &AlertSummary{} + fetchErrors := 0 // Fetch Dependabot alerts depAlerts, err := fetchDependabotAlerts(fullName) - if err == nil { + if err != nil { + cli.Print("%s %s: %v\n", cli.WarningStyle.Render(">>"), fullName, err) + fetchErrors++ + } else { for _, alert := range depAlerts { if alert.State != "open" { continue @@ -212,7 +230,10 @@ func runAlertsForTarget(target string) error { // Fetch code scanning alerts codeAlerts, err := fetchCodeScanningAlerts(fullName) - if err == nil { + if err != nil { + cli.Print("%s %s: %v\n", cli.WarningStyle.Render(">>"), fullName, err) + fetchErrors++ + } else { for _, alert := range codeAlerts { if alert.State != "open" { continue @@ -236,7 +257,10 @@ func runAlertsForTarget(target string) error { // Fetch secret scanning alerts secretAlerts, err := fetchSecretScanningAlerts(fullName) - if err == nil { + if err != nil { + cli.Print("%s %s: %v\n", cli.WarningStyle.Render(">>"), fullName, err) + fetchErrors++ + } else { for _, alert := range secretAlerts { if alert.State != "open" { continue @@ -255,6 +279,10 @@ func runAlertsForTarget(target string) error { } } + if fetchErrors == 3 { + return cli.Err("failed to fetch alerts for %s", fullName) + } + if securityJSON { return printJSON(allAlerts) } diff --git a/cmd/security/cmd_jobs.go b/cmd/security/cmd_jobs.go index 953f99a..1ab54ac 100644 --- a/cmd/security/cmd_jobs.go +++ b/cmd/security/cmd_jobs.go @@ -192,7 +192,7 @@ func buildJobIssueBody(target string, summary *AlertSummary, findings []string) sb := core.NewBuilder() sb.WriteString(core.Sprintf("## Security Scan: %s\n\n", target)) - sb.WriteString(core.Sprintf("**Summary:** %s\n\n", summary.String())) + sb.WriteString(core.Sprintf("**Summary:** %s\n\n", summary.PlainString())) sb.WriteString("### Findings\n\n") if len(findings) > 50 { diff --git a/cmd/security/cmd_secrets.go b/cmd/security/cmd_secrets.go index e061dd5..5561a0f 100644 --- a/cmd/security/cmd_secrets.go +++ b/cmd/security/cmd_secrets.go @@ -66,6 +66,7 @@ func runSecrets() error { alerts, err := fetchSecretScanningAlerts(repoFullName) if err != nil { + cli.Print("%s %s: %v\n", cli.WarningStyle.Render(">>"), repoFullName, err) continue } diff --git a/cmd/security/cmd_security.go b/cmd/security/cmd_security.go index 362c57a..d7af4aa 100644 --- a/cmd/security/cmd_security.go +++ b/cmd/security/cmd_security.go @@ -226,24 +226,46 @@ func (s *AlertSummary) Add(severity string) { // // text := summary.String() func (s *AlertSummary) String() string { + return s.renderSummary(func(level, text string) string { + switch level { + case "critical": + return cli.ErrorStyle.Render(text) + case "high": + return cli.WarningStyle.Render(text) + case "medium": + return cli.ValueStyle.Render(text) + case "none": + return cli.SuccessStyle.Render(text) + default: + return cli.DimStyle.Render(text) + } + }) +} + +// PlainString renders an unstyled summary suitable for logs and issue bodies. +func (s *AlertSummary) PlainString() string { + return s.renderSummary(func(_ string, text string) string { + return text + }) +} + +func (s *AlertSummary) renderSummary(render func(level, text string) string) string { parts := []string{} - if s.Critical > 0 { - parts = append(parts, cli.ErrorStyle.Render(core.Sprintf("%d critical", s.Critical))) - } - if s.High > 0 { - parts = append(parts, cli.WarningStyle.Render(core.Sprintf("%d high", s.High))) - } - if s.Medium > 0 { - parts = append(parts, cli.ValueStyle.Render(core.Sprintf("%d medium", s.Medium))) - } - if s.Low > 0 { - parts = append(parts, cli.DimStyle.Render(core.Sprintf("%d low", s.Low))) - } - if s.Unknown > 0 { - parts = append(parts, cli.DimStyle.Render(core.Sprintf("%d unknown", s.Unknown))) + + appendPart := func(level string, count int) { + if count > 0 { + parts = append(parts, render(level, core.Sprintf("%d %s", count, level))) + } } + + appendPart("critical", s.Critical) + appendPart("high", s.High) + appendPart("medium", s.Medium) + appendPart("low", s.Low) + appendPart("unknown", s.Unknown) + if len(parts) == 0 { - return cli.SuccessStyle.Render("No alerts") + return render("none", "No alerts") } return core.Join(" | ", parts...) } diff --git a/cmd/security/cmd_security_test.go b/cmd/security/cmd_security_test.go new file mode 100644 index 0000000..d44401a --- /dev/null +++ b/cmd/security/cmd_security_test.go @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package security + +import ( + "strings" + "testing" +) + +func TestAlertSummary_PlainString_Good(t *testing.T) { + t.Run("RendersWithoutANSI", func(t *testing.T) { + summary := &AlertSummary{ + Critical: 1, + High: 2, + Medium: 3, + Low: 4, + Unknown: 5, + } + + got := summary.PlainString() + want := "1 critical | 2 high | 3 medium | 4 low | 5 unknown" + if got != want { + t.Fatalf("PlainString() = %q, want %q", got, want) + } + if strings.Contains(got, "\x1b[") { + t.Fatalf("PlainString() unexpectedly contains ANSI escapes: %q", got) + } + }) + + t.Run("RendersEmpty", func(t *testing.T) { + summary := &AlertSummary{} + if got := summary.PlainString(); got != "No alerts" { + t.Fatalf("PlainString() = %q, want %q", got, "No alerts") + } + }) +} + +func TestSecurity_BuildJobIssueBody_Good(t *testing.T) { + summary := &AlertSummary{Critical: 1, High: 2} + body := buildJobIssueBody("owner/repo", summary, []string{"- [HIGH] Finding"}) + + if strings.Contains(body, "\x1b[") { + t.Fatalf("issue body unexpectedly contains ANSI escapes: %q", body) + } + if !strings.Contains(body, "**Summary:** 1 critical | 2 high") { + t.Fatalf("issue body missing plain summary: %q", body) + } +} diff --git a/go.sum b/go.sum index 71a6b5e..2f5f2cf 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= +codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0/go.mod h1:ZglEEDj+qkxYUb+SQIeqGtFxQrbaMYqIOgahNKb7uxs= dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= dappco.re/go/core/i18n v0.2.0 h1:NHzk6RCU93/qVRA3f2jvMr9P1R6FYheR/sHL+TnvKbI= @@ -6,10 +10,15 @@ dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4= dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E= dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc= dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +forge.lthn.ai/Snider/Borg v0.3.1/go.mod h1:Z7DJD0yHXsxSyM7Mjl6/g4gH1NBsIz44Bf5AFlV76Wg= +forge.lthn.ai/core/api v0.1.0/go.mod h1:c86Lk9AmaS0xbiRCEG/+du8s9KyYNHnp8RED35gR/Fo= forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg= forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs= +forge.lthn.ai/core/config v0.1.0/go.mod h1:8HYA29drAWlX+bO4VI1JhmKUgGU66E2Xge8D3tKd3Dg= forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0= forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ= +forge.lthn.ai/core/go-crypt v0.1.6/go.mod h1:4VZAGqxlbadhSB66sJkdj54/HSJ+bSxVgwWK5kMMYDo= forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA= forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8= forge.lthn.ai/core/go-inference v0.1.7 h1:9Dy6v03jX5ZRH3n5iTzlYyGtucuBIgSe+S7GWvBzx9Q= @@ -22,12 +31,37 @@ forge.lthn.ai/core/go-rag v0.1.11 h1:KXTOtnOdrx8YKmvnj0EOi2EI/+cKjE8w2PpJCQIrSd8 forge.lthn.ai/core/go-rag v0.1.11/go.mod h1:vIlOKVD1SdqqjkJ2XQyXPuKPtiajz/STPLCaDpqOzk8= forge.lthn.ai/core/go-scm v0.2.0 h1:TvDyCzw0HWzXjmqe6uPc46nPaRzc7MPGswmwZt0CmXo= forge.lthn.ai/core/go-scm v0.2.0/go.mod h1:Q/PV2FbqDlWnAOsXAd1pgSiHOlRCPW4HcPmOt8Z9H+E= +forge.lthn.ai/core/go-ws v0.1.0/go.mod h1:wBQLXDUod6FqESh1CM4OnAjyP3cmWg8Vd5M43RIdTwA= +github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= +github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I= +github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= +github.com/apache/arrow/go/arrow v0.0.0-20211112161151-bc219186db40/go.mod h1:Q7yQnSMnLvcXlZ8RV+jwz/6y1rQTqbX6C82SndT52Zs= +github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21/go.mod h1:UUxgWxofmOdAMuqEsSppbDtGKLfR04HGsD0HXzvhI1k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12/go.mod h1:v2pNpJbRNl4vEUWEh5ytQok0zACAKfdmKS51Hotc3pQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20/go.mod h1:4TLZCmVJDM3FOu5P5TJP0zOlu9zWgDWU7aUxWbr+rcw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1/go.mod h1:qXVal5H0ChqXP63t6jze5LmFalc7+ZE7wOdLtZ0LCP0= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -40,75 +74,177 @@ github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/chewxy/hm v1.0.0/go.mod h1:qg9YI4q6Fkj/whwHR1D+bOGeF7SniIP40VweVepLjg0= +github.com/chewxy/math32 v1.11.0/go.mod h1:dOB2rcuFrCn6UHrze36WSLVPKtzPMRAQvBvUwkSsLqs= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/d4l3k/go-bfloat16 v0.0.0-20211005043715-690c3bdd05f1/go.mod h1:uw2gLcxEuYUlAd/EXyjc/v55nd3+47YAgWbSXVxPrNI= 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/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M= github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc= github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nlpodyssey/gopickle v0.3.0/go.mod h1:f070HJ/yR+eLi5WmM1OXJEGaTpuJEUiib19olXgYha0= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/ollama/ollama v0.18.1 h1:7K6anW64C2keASpToYfuOa00LuP8aCmofLKcT2c1mlY= github.com/ollama/ollama v0.18.1/go.mod h1:tCX4IMV8DHjl3zY0THxuEkpWDZSOchJpzTuLACpMwFw= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pdevine/tensor v0.0.0-20240510204454-f88f4562727c/go.mod h1:PSojXDXF7TbgQiD6kkd98IHOS0QqTyUEaWRiS8+BLu8= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= 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/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/qdrant/go-client v1.17.1 h1:7QmPwDddrHL3hC4NfycwtQlraVKRLcRi++BX6TTm+3g= github.com/qdrant/go-client v1.17.1/go.mod h1:n1h6GhkdAzcohoXt/5Z19I2yxbCkMA6Jejob3S6NZT8= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/tkrajina/go-reflector v0.5.5/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/tkrajina/typescriptify-golang-structs v0.2.0/go.mod h1:sjU00nti/PMEOZb07KljFlR+lJ+RotsC0GBQMv9EKls= +github.com/tree-sitter/go-tree-sitter v0.25.0/go.mod h1:r77ig7BikoZhHrrsjAnv8RqGti5rtSyvDHPzgTPsUuU= +github.com/tree-sitter/tree-sitter-cpp v0.23.4/go.mod h1:doqNW64BriC7WBCQ1klf0KmJpdEvfxyXtoEybnBo6v8= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/xtgo/set v1.0.0/go.mod h1:d3NHzGzSa0NmB2NhFyECA+QdRp29oEn2xbT+TpeFoM8= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= @@ -118,12 +254,18 @@ go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= +golang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= @@ -132,8 +274,12 @@ golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts= google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= @@ -145,3 +291,9 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorgonia.org/vecf32 v0.9.0/go.mod h1:NCc+5D2oxddRL11hd+pCB1PEyXWOyiQxfZ/1wwhOXCA= +gorgonia.org/vecf64 v0.9.0/go.mod h1:hp7IOWCnRiVQKON73kkC/AUMtEXyf9kGlVrtPQ9ccVA= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= -- 2.45.3 From a619f0e1b4abdef4d268db475cea23e1d523bce7 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 27 Mar 2026 19:12:43 +0000 Subject: [PATCH 5/6] docs(specs): populate package API docs --- specs/ai.md | 57 ++++++++++++++++++++ specs/cmd/embed-bench.md | 20 +++++++ specs/cmd/lab.md | 15 ++++++ specs/cmd/metrics.md | 17 ++++++ specs/cmd/rag.md | 21 ++++++++ specs/cmd/security.md | 111 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 241 insertions(+) create mode 100644 specs/ai.md create mode 100644 specs/cmd/embed-bench.md create mode 100644 specs/cmd/lab.md create mode 100644 specs/cmd/metrics.md create mode 100644 specs/cmd/rag.md create mode 100644 specs/cmd/security.md diff --git a/specs/ai.md b/specs/ai.md new file mode 100644 index 0000000..afa7eaa --- /dev/null +++ b/specs/ai.md @@ -0,0 +1,57 @@ +# ai + +**Import:** `dappco.re/go/core/ai/ai` +**Files:** 3 + +Package ai provides the unified AI package for the core CLI. + +It composes functionality from pkg/rag (vector search) and pkg/agentic +(task management) into a single public API surface. New AI features +should be added here; existing packages remain importable but pkg/ai +is the canonical entry point. + +Sub-packages composed: + - pkg/rag: Qdrant vector database + Ollama embeddings + - pkg/agentic: Task queue client and context building + +## Types + +### Event +- **File:** metrics.go +- **Purpose:** Event represents a recorded AI/security metric event. +- **Fields:** + - `Type string` — Type. + - `Timestamp time.Time` — Timestamp. + - `AgentID string` — Agent ID. + - `Repo string` — Repo. + - `Duration time.Duration` — Duration. + - `Data map[string]any` — Data. + +### TaskInfo +- **File:** rag.go +- **Purpose:** TaskInfo carries the minimal task data needed for RAG queries, avoiding a direct dependency on pkg/agentic (which imports pkg/ai). +- **Fields:** + - `Title string` — Title. + - `Description string` — Description. + +## Functions + +### QueryRAGForTask +- **File:** rag.go +- **Signature:** `func QueryRAGForTask(task TaskInfo) (string, error)` +- **Purpose:** QueryRAGForTask queries Qdrant for documentation relevant to a task. + +### ReadEvents +- **File:** metrics.go +- **Signature:** `func ReadEvents(since time.Time) ([]Event, error)` +- **Purpose:** ReadEvents reads events from JSONL files within the given time range. + +### Record +- **File:** metrics.go +- **Signature:** `func Record(event Event) (err error)` +- **Purpose:** Record appends an event to the daily JSONL file at ~/.core/ai/metrics/YYYY-MM-DD.jsonl. + +### Summary +- **File:** metrics.go +- **Signature:** `func Summary(events []Event) map[string]any` +- **Purpose:** Summary aggregates events into counts by type, repo, and agent. diff --git a/specs/cmd/embed-bench.md b/specs/cmd/embed-bench.md new file mode 100644 index 0000000..ad1b13c --- /dev/null +++ b/specs/cmd/embed-bench.md @@ -0,0 +1,20 @@ +# main + +**Import:** `dappco.re/go/core/ai/cmd/embed-bench` +**Files:** 1 + +embed-bench compares embedding models for OpenBrain by testing how well +they separate semantically related vs unrelated agent memory pairs. + +Usage: + + go run ./cmd/embed-bench + go run ./cmd/embed-bench -ollama http://localhost:11434 + +## Types + +None. + +## Functions + +None. diff --git a/specs/cmd/lab.md b/specs/cmd/lab.md new file mode 100644 index 0000000..c6f8f63 --- /dev/null +++ b/specs/cmd/lab.md @@ -0,0 +1,15 @@ +# lab + +**Import:** `dappco.re/go/core/ai/cmd/lab` +**Files:** 1 + +## Types + +None. + +## Functions + +### AddLabCommands +- **File:** cmd_lab.go +- **Signature:** `func AddLabCommands(root *cli.Command)` +- **Purpose:** AddLabCommands registers the 'lab' command and subcommands. diff --git a/specs/cmd/metrics.md b/specs/cmd/metrics.md new file mode 100644 index 0000000..6ed0ec7 --- /dev/null +++ b/specs/cmd/metrics.md @@ -0,0 +1,17 @@ +# metrics + +**Import:** `dappco.re/go/core/ai/cmd/metrics` +**Files:** 1 + +Package metrics implements the metrics viewing command. + +## Types + +None. + +## Functions + +### AddMetricsCommand +- **File:** cmd.go +- **Signature:** `func AddMetricsCommand(parent *cli.Command)` +- **Purpose:** AddMetricsCommand adds the 'metrics' command to the parent. diff --git a/specs/cmd/rag.md b/specs/cmd/rag.md new file mode 100644 index 0000000..99dfa1e --- /dev/null +++ b/specs/cmd/rag.md @@ -0,0 +1,21 @@ +# rag + +**Import:** `dappco.re/go/core/ai/cmd/rag` +**Files:** 1 + +Package rag re-exports go-rag's CLI commands for use in the core CLI. + +## Variables + +### AddRAGSubcommands +- **File:** cmd.go +- **Signature:** `var AddRAGSubcommands = ragcmd.AddRAGSubcommands` +- **Purpose:** AddRAGSubcommands registers RAG commands as subcommands of parent. + +## Types + +None. + +## Functions + +None. diff --git a/specs/cmd/security.md b/specs/cmd/security.md new file mode 100644 index 0000000..c96b68e --- /dev/null +++ b/specs/cmd/security.md @@ -0,0 +1,111 @@ +# security + +**Import:** `dappco.re/go/core/ai/cmd/security` +**Files:** 8 + +## Types + +### AlertOutput +- **File:** cmd_alerts.go +- **Purpose:** AlertOutput represents a unified alert for output. +- **Fields:** + - `Repo string` — Repo. + - `Severity string` — Severity. + - `ID string` — ID. + - `Package string` — Package. + - `Version string` — Version. + - `Location string` — Location. + - `Type string` — Type. + - `Message string` — Message. + +### AlertSummary +- **File:** cmd_security.go +- **Purpose:** AlertSummary holds aggregated alert counts. +- **Fields:** + - `Critical int` — Critical. + - `High int` — High. + - `Medium int` — Medium. + - `Low int` — Low. + - `Unknown int` — Unknown. + - `Total int` — Total. +- **Methods:** + - `func (s *AlertSummary) Add(severity string)` (cmd_security.go) — Add increments summary counters for the provided severity. + - `func (s *AlertSummary) PlainString() string` (cmd_security.go) — PlainString renders an unstyled summary suitable for logs and issue bodies. + - `func (s *AlertSummary) String() string` (cmd_security.go) — String renders a human-readable summary of alert counts. + +### CodeScanningAlert +- **File:** cmd_security.go +- **Purpose:** CodeScanningAlert represents a code scanning alert. +- **Fields:** + - `Number int` — Number. + - `State string` — State. + - `DismissedReason string` — Dismissed Reason. + - `Rule struct{...}` — Rule. + - `Tool struct{...}` — Tool. + - `MostRecentInstance struct{...}` — Most Recent Instance. + +### DepAlert +- **File:** cmd_deps.go +- **Purpose:** DepAlert represents a dependency vulnerability for output. +- **Fields:** + - `Repo string` — Repo. + - `Severity string` — Severity. + - `CVE string` — CVE. + - `Package string` — Package. + - `Ecosystem string` — Ecosystem. + - `Vulnerable string` — Vulnerable. + - `PatchedVersion string` — Patched Version. + - `Manifest string` — Manifest. + - `Summary string` — Summary. + +### DependabotAlert +- **File:** cmd_security.go +- **Purpose:** DependabotAlert represents a Dependabot vulnerability alert. +- **Fields:** + - `Number int` — Number. + - `State string` — State. + - `Advisory struct{...}` — Advisory. + - `Dependency struct{...}` — Dependency. + - `SecurityVulnerability struct{...}` — Security Vulnerability. + +### ScanAlert +- **File:** cmd_scan.go +- **Purpose:** ScanAlert represents a code scanning alert for output. +- **Fields:** + - `Repo string` — Repo. + - `Severity string` — Severity. + - `RuleID string` — Rule ID. + - `Tool string` — Tool. + - `Path string` — Path. + - `Line int` — Line. + - `Description string` — Description. + - `Message string` — Message. + +### SecretAlert +- **File:** cmd_secrets.go +- **Purpose:** SecretAlert represents a secret scanning alert for output. +- **Fields:** + - `Repo string` — Repo. + - `Number int` — Number. + - `SecretType string` — Secret Type. + - `State string` — State. + - `Resolution string` — Resolution. + - `PushProtection bool` — Push Protection. + +### SecretScanningAlert +- **File:** cmd_security.go +- **Purpose:** SecretScanningAlert represents a secret scanning alert. +- **Fields:** + - `Number int` — Number. + - `State string` — State. + - `SecretType string` — Secret Type. + - `Secret string` — Secret. + - `PushProtection bool` — Push Protection. + - `Resolution string` — Resolution. + +## Functions + +### AddSecurityCommands +- **File:** cmd_security.go +- **Signature:** `func AddSecurityCommands(root *cli.Command)` +- **Purpose:** AddSecurityCommands adds the 'security' command to the root. -- 2.45.3 From e2bc9ac2bde6817ad07ab3e8c0f59c1ddd5205e5 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 27 Mar 2026 21:34:49 +0000 Subject: [PATCH 6/6] docs(specs): add RFC package docs --- specs/ai/RFC.md | 55 ++++++++++++++++++ specs/embed-bench/RFC.md | 18 ++++++ specs/lab/RFC.md | 17 ++++++ specs/metrics/RFC.md | 17 ++++++ specs/rag/RFC.md | 21 +++++++ specs/security/RFC.md | 122 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 250 insertions(+) create mode 100644 specs/ai/RFC.md create mode 100644 specs/embed-bench/RFC.md create mode 100644 specs/lab/RFC.md create mode 100644 specs/metrics/RFC.md create mode 100644 specs/rag/RFC.md create mode 100644 specs/security/RFC.md diff --git a/specs/ai/RFC.md b/specs/ai/RFC.md new file mode 100644 index 0000000..eef4667 --- /dev/null +++ b/specs/ai/RFC.md @@ -0,0 +1,55 @@ +# ai + +**Import:** `dappco.re/go/core/ai/ai` +**Files:** 3 + +Package ai provides the unified AI package for the core CLI. + +It composes functionality from pkg/rag (vector search) and pkg/agentic +(task management) into a single public API surface. New AI features +should be added here; existing packages remain importable but pkg/ai +is the canonical entry point. + +## Types + +### Event +- **Kind:** `struct` +- **File:** `metrics.go` +- **Purpose:** Event represents a recorded AI/security metric event. +- **Fields:** + - `Type string` + - `Timestamp time.Time` + - `AgentID string` + - `Repo string` + - `Duration time.Duration` + - `Data map[string]any` + +### TaskInfo +- **Kind:** `struct` +- **File:** `rag.go` +- **Purpose:** TaskInfo carries the minimal task data needed for RAG queries, avoiding a direct dependency on pkg/agentic (which imports pkg/ai). +- **Fields:** + - `Title string` + - `Description string` + +## Functions + +### QueryRAGForTask +- **File:** `rag.go` +- **Signature:** `func QueryRAGForTask(task TaskInfo) (string, error)` +- **Purpose:** QueryRAGForTask concatenates the task title and description, truncates the query to 500 runes, queries the `hostuk-docs` collection with `Limit: 3` and `Threshold: 0.5`, and returns `rag.FormatResultsContext(results)`. + +### ReadEvents +- **File:** `metrics.go` +- **Signature:** `func ReadEvents(since time.Time) ([]Event, error)` +- **Purpose:** ReadEvents reads daily JSONL metric files from the day containing `since` through `time.Now()`, returning only events whose `Timestamp` is not before `since`. + +### Record +- **File:** `metrics.go` +- **Signature:** `func Record(event Event) (err error)` +- **Purpose:** Record fills a zero timestamp with `time.Now()`, ensures the metrics directory exists, and appends one JSON line to `~/.core/ai/metrics/YYYY-MM-DD.jsonl`. + +### Summary +- **File:** `metrics.go` +- **Signature:** `func Summary(events []Event) map[string]any` +- **Purpose:** Summary returns aggregate counts under the keys `total`, `by_type`, `by_repo`, and `by_agent`; grouped entries are sorted by count descending and then key ascending. diff --git a/specs/embed-bench/RFC.md b/specs/embed-bench/RFC.md new file mode 100644 index 0000000..b5f544f --- /dev/null +++ b/specs/embed-bench/RFC.md @@ -0,0 +1,18 @@ +# main + +**Import:** `dappco.re/go/core/ai/cmd/embed-bench` +**Files:** 1 + +Package main benchmarks Ollama embedding models against grouped memory +and query examples. + +It compares how well candidate embedding models separate semantically +related agent-memory pairs from unrelated ones. + +## Types + +None. + +## Functions + +None. diff --git a/specs/lab/RFC.md b/specs/lab/RFC.md new file mode 100644 index 0000000..3c79978 --- /dev/null +++ b/specs/lab/RFC.md @@ -0,0 +1,17 @@ +# lab + +**Import:** `dappco.re/go/core/ai/cmd/lab` +**Files:** 1 + +`cmd_lab.go` is guarded by `//go:build ignore`. + +## Types + +None. + +## Functions + +### AddLabCommands +- **File:** `cmd_lab.go` +- **Signature:** `func AddLabCommands(root *cli.Command)` +- **Purpose:** AddLabCommands adds the `lab` root command and registers its `serve` subcommand on `root`. diff --git a/specs/metrics/RFC.md b/specs/metrics/RFC.md new file mode 100644 index 0000000..265f785 --- /dev/null +++ b/specs/metrics/RFC.md @@ -0,0 +1,17 @@ +# metrics + +**Import:** `dappco.re/go/core/ai/cmd/metrics` +**Files:** 1 + +Package metrics implements the metrics viewing command. + +## Types + +None. + +## Functions + +### AddMetricsCommand +- **File:** `cmd.go` +- **Signature:** `func AddMetricsCommand(parent *cli.Command)` +- **Purpose:** AddMetricsCommand initializes the `metrics` command flags and adds the command to `parent`. diff --git a/specs/rag/RFC.md b/specs/rag/RFC.md new file mode 100644 index 0000000..2c3a03a --- /dev/null +++ b/specs/rag/RFC.md @@ -0,0 +1,21 @@ +# rag + +**Import:** `dappco.re/go/core/ai/cmd/rag` +**Files:** 1 + +Package rag re-exports go-rag's CLI commands for use in the core CLI. + +## Variables + +### AddRAGSubcommands +- **File:** `cmd.go` +- **Declaration:** `var AddRAGSubcommands = ragcmd.AddRAGSubcommands` +- **Purpose:** AddRAGSubcommands re-exports `ragcmd.AddRAGSubcommands`. + +## Types + +None. + +## Functions + +None. diff --git a/specs/security/RFC.md b/specs/security/RFC.md new file mode 100644 index 0000000..0d4e57d --- /dev/null +++ b/specs/security/RFC.md @@ -0,0 +1,122 @@ +# security + +**Import:** `dappco.re/go/core/ai/cmd/security` +**Files:** 8 + +The package exports the `security` command registration entry point and +the alert/output structs used by the security subcommands. + +## Types + +### AlertOutput +- **Kind:** `struct` +- **File:** `cmd_alerts.go` +- **Purpose:** AlertOutput represents a unified alert for output. +- **Fields:** + - `Repo string` + - `Severity string` + - `ID string` + - `Package string` + - `Version string` + - `Location string` + - `Type string` + - `Message string` + +### AlertSummary +- **Kind:** `struct` +- **File:** `cmd_security.go` +- **Purpose:** AlertSummary holds aggregated alert counts. +- **Fields:** + - `Critical int` + - `High int` + - `Medium int` + - `Low int` + - `Unknown int` + - `Total int` +- **Methods:** + - `func (s *AlertSummary) Add(severity string)` - increments the summary counters for the provided severity. + - `func (s *AlertSummary) String() string` - renders a styled summary string. + - `func (s *AlertSummary) PlainString() string` - renders an unstyled summary string for logs and issue bodies. + +### CodeScanningAlert +- **Kind:** `struct` +- **File:** `cmd_security.go` +- **Purpose:** CodeScanningAlert represents a code scanning alert. +- **Fields:** + - `Number int` + - `State string` + - `DismissedReason string` + - `Rule struct{ ID string; Severity string; Description string; Tags []string }` + - `Tool struct{ Name string; Version string }` + - `MostRecentInstance struct{ Location struct{ Path string; StartLine int; EndLine int }; Message struct{ Text string } }` + +### DepAlert +- **Kind:** `struct` +- **File:** `cmd_deps.go` +- **Purpose:** DepAlert represents a dependency vulnerability for output. +- **Fields:** + - `Repo string` + - `Severity string` + - `CVE string` + - `Package string` + - `Ecosystem string` + - `Vulnerable string` + - `PatchedVersion string` + - `Manifest string` + - `Summary string` + +### DependabotAlert +- **Kind:** `struct` +- **File:** `cmd_security.go` +- **Purpose:** DependabotAlert represents a Dependabot vulnerability alert. +- **Fields:** + - `Number int` + - `State string` + - `Advisory struct{ Severity string; CVEID string; Summary string; Description string }` + - `Dependency struct{ Package struct{ Name string; Ecosystem string }; ManifestPath string }` + - `SecurityVulnerability struct{ Package struct{ Name string; Ecosystem string }; FirstPatchedVersion struct{ Identifier string }; VulnerableVersionRange string }` + +### ScanAlert +- **Kind:** `struct` +- **File:** `cmd_scan.go` +- **Purpose:** ScanAlert represents a code scanning alert for output. +- **Fields:** + - `Repo string` + - `Severity string` + - `RuleID string` + - `Tool string` + - `Path string` + - `Line int` + - `Description string` + - `Message string` + +### SecretAlert +- **Kind:** `struct` +- **File:** `cmd_secrets.go` +- **Purpose:** SecretAlert represents a secret scanning alert for output. +- **Fields:** + - `Repo string` + - `Number int` + - `SecretType string` + - `State string` + - `Resolution string` + - `PushProtection bool` + +### SecretScanningAlert +- **Kind:** `struct` +- **File:** `cmd_security.go` +- **Purpose:** SecretScanningAlert represents a secret scanning alert. +- **Fields:** + - `Number int` + - `State string` + - `SecretType string` + - `Secret string` + - `PushProtection bool` + - `Resolution string` + +## Functions + +### AddSecurityCommands +- **File:** `cmd_security.go` +- **Signature:** `func AddSecurityCommands(root *cli.Command)` +- **Purpose:** AddSecurityCommands creates the `security` command and registers the `alerts`, `deps`, `scan`, `secrets`, and `jobs` subcommands on `root`. -- 2.45.3