refactor(ax): tighten remaining AX naming and examples
All checks were successful
Security Scan / security (push) Successful in 10s
Test / test (push) Successful in 1m12s

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-31 07:44:10 +00:00
parent eaf26d3f93
commit bc3b36c3da
14 changed files with 452 additions and 479 deletions

View file

@ -16,7 +16,7 @@ import (
var metricsWriteMutex sync.Mutex
// Event is a recorded AI or security metric entry.
// Event records an AI or security metric entry.
//
// ai.Record(ai.Event{Type: "security.scan", Repo: "core-php", AgentID: "codex-1"})
type Event struct {
@ -39,14 +39,14 @@ func metricsDir() (string, error) {
return filepath.Join(home, ".core", "ai", "metrics"), nil
}
// metricsFilePath returns the JSONL path for a given date.
// metricsFilePath joins the daily JSONL filename onto dir.
//
// ai.metricsFilePath("/home/user/.core/ai/metrics", time.Now()) // → "/home/user/.core/ai/metrics/2026-03-31.jsonl"
func metricsFilePath(dir string, t time.Time) string {
return filepath.Join(dir, t.Format("2006-01-02")+".jsonl")
}
// Record appends an event to the daily JSONL file at ~/.core/ai/metrics/YYYY-MM-DD.jsonl.
// Record appends an event to ~/.core/ai/metrics/YYYY-MM-DD.jsonl.
//
// ai.Record(ai.Event{Type: "security.scan", Repo: "go-ai", AgentID: "codex-1"})
func Record(event Event) (err error) {
@ -68,13 +68,13 @@ func Record(event Event) (err error) {
path := metricsFilePath(dir, event.Timestamp)
w, err := coreio.Local.Append(path)
writer, err := coreio.Local.Append(path)
if err != nil {
return coreerr.E("ai.Record", "open metrics file for append", err)
}
defer func() {
if cerr := w.Close(); cerr != nil && err == nil {
err = coreerr.E("ai.Record", "close metrics file", cerr)
if closeErr := writer.Close(); closeErr != nil && err == nil {
err = coreerr.E("ai.Record", "close metrics file", closeErr)
}
}()
@ -83,14 +83,14 @@ func Record(event Event) (err error) {
return coreerr.E("ai.Record", "marshal event", err)
}
if _, err := w.Write(append(data, '\n')); err != nil {
if _, err := writer.Write(append(data, '\n')); err != nil {
return coreerr.E("ai.Record", "write event", err)
}
return nil
}
// ReadEvents reads all events recorded on or after since.
// ReadEvents returns events recorded on or after since.
//
// events, _ := ai.ReadEvents(time.Now().Add(-7 * 24 * time.Hour))
func ReadEvents(since time.Time) ([]Event, error) {
@ -102,8 +102,8 @@ func ReadEvents(since time.Time) ([]Event, error) {
var events []Event
now := time.Now()
for d := time.Date(since.Year(), since.Month(), since.Day(), 0, 0, 0, 0, time.Local); !d.After(now); d = d.AddDate(0, 0, 1) {
path := metricsFilePath(dir, d)
for currentDay := time.Date(since.Year(), since.Month(), since.Day(), 0, 0, 0, 0, time.Local); !currentDay.After(now); currentDay = currentDay.AddDate(0, 0, 1) {
path := metricsFilePath(dir, currentDay)
dayEvents, err := readMetricsFile(path, since)
if err != nil {
@ -115,7 +115,7 @@ func ReadEvents(since time.Time) ([]Event, error) {
return events, nil
}
// readMetricsFile reads events from a single JSONL file, skipping lines before since.
// readMetricsFile returns events from one JSONL file on or after since.
//
// events, _ := readMetricsFile("/home/user/.core/ai/metrics/2026-03-31.jsonl", time.Now().Add(-24*time.Hour))
func readMetricsFile(path string, since time.Time) ([]Event, error) {
@ -123,21 +123,21 @@ func readMetricsFile(path string, since time.Time) ([]Event, error) {
return nil, nil
}
r, err := coreio.Local.ReadStream(path)
reader, err := coreio.Local.ReadStream(path)
if err != nil {
return nil, coreerr.E("ai.readMetricsFile", "open metrics file", err)
}
defer func() { _ = r.Close() }()
defer func() { _ = reader.Close() }()
var events []Event
scanner := bufio.NewScanner(r)
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
var ev Event
if err := json.Unmarshal(scanner.Bytes(), &ev); err != nil {
var event Event
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
continue
}
if !ev.Timestamp.Before(since) {
events = append(events, ev)
if !event.Timestamp.Before(since) {
events = append(events, event)
}
}
if err := scanner.Err(); err != nil {
@ -146,11 +146,11 @@ func readMetricsFile(path string, since time.Time) ([]Event, error) {
return events, nil
}
// Summary aggregates events into counts by type, repo, and agent.
// Summary groups events by type, repo, and agent.
//
// summary := ai.Summary(events)
// summary["total"] // int — total event count
// summary["by_type"] // []map[string]any — sorted by count descending
// summary["total"] // int — total event count
// summary["by_type"] // []map[string]any — sorted by count descending
func Summary(events []Event) map[string]any {
byType := make(map[string]int)
byRepo := make(map[string]int)
@ -174,26 +174,26 @@ func Summary(events []Event) map[string]any {
}
}
// sortedMap converts a string→count map to a slice sorted by count descending.
// sortedMap converts counts into rows sorted by count descending.
//
// sortedMap(map[string]int{"build": 5, "test": 2}) // → [{key: "build", count: 5}, ...]
func sortedMap(m map[string]int) []map[string]any {
type entry struct {
func sortedMap(countByName map[string]int) []map[string]any {
type countEntry struct {
key string
count int
}
entries := make([]entry, 0, len(m))
for k, v := range m {
entries = append(entries, entry{k, v})
countEntries := make([]countEntry, 0, len(countByName))
for name, count := range countByName {
countEntries = append(countEntries, countEntry{name, count})
}
slices.SortFunc(entries, func(a, b entry) int {
return cmp.Compare(b.count, a.count)
slices.SortFunc(countEntries, func(left, right countEntry) int {
return cmp.Compare(right.count, left.count)
})
result := make([]map[string]any, len(entries))
for i, entry := range entries {
result[i] = map[string]any{"key": entry.key, "count": entry.count}
rows := make([]map[string]any, len(countEntries))
for rowIndex, countEntry := range countEntries {
rows[rowIndex] = map[string]any{"key": countEntry.key, "count": countEntry.count}
}
return result
return rows
}

View file

@ -10,8 +10,6 @@ import (
coreio "dappco.re/go/core/io"
)
// --- Helpers ---
// setupBenchMetricsDir overrides HOME to a temp dir and returns the metrics path.
//
// metricsPath := setupBenchMetricsDir(b) // → "/tmp/.../home/.core/ai/metrics"
@ -36,22 +34,20 @@ func setupBenchMetricsDir(b *testing.B) string {
func seedEvents(b *testing.B, count int) {
b.Helper()
now := time.Now()
for i := range count {
ev := Event{
Type: fmt.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),
Data: map[string]any{"i": i, "tool": "bench_tool"},
for eventIndex := range count {
event := Event{
Type: fmt.Sprintf("type-%d", eventIndex%10),
Timestamp: now.Add(-time.Duration(eventIndex) * time.Millisecond),
AgentID: fmt.Sprintf("agent-%d", eventIndex%5),
Repo: fmt.Sprintf("repo-%d", eventIndex%3),
Data: map[string]any{"index": eventIndex, "tool": "bench_tool"},
}
if err := Record(ev); err != nil {
b.Fatalf("Failed to record event %d: %v", i, err)
if err := Record(event); err != nil {
b.Fatalf("Failed to record event %d: %v", eventIndex, err)
}
}
}
// --- Benchmarks ---
// BenchmarkMetricsRecord benchmarks writing individual metric events.
func BenchmarkMetricsRecord(b *testing.B) {
setupBenchMetricsDir(b)
@ -59,16 +55,16 @@ func BenchmarkMetricsRecord(b *testing.B) {
now := time.Now()
b.ResetTimer()
for i := range b.N {
ev := Event{
for sampleIndex := range b.N {
event := Event{
Type: "bench_record",
Timestamp: now,
AgentID: "bench-agent",
Repo: "bench-repo",
Data: map[string]any{"i": i},
Data: map[string]any{"index": sampleIndex},
}
if err := Record(ev); err != nil {
b.Fatalf("Record failed at iteration %d: %v", i, err)
if err := Record(event); err != nil {
b.Fatalf("Record failed at iteration %d: %v", sampleIndex, err)
}
}
}
@ -81,19 +77,19 @@ func BenchmarkMetricsRecord_Parallel(b *testing.B) {
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
sampleIndex := 0
for pb.Next() {
ev := Event{
event := Event{
Type: "bench_parallel",
Timestamp: now,
AgentID: "bench-agent",
Repo: "bench-repo",
Data: map[string]any{"i": i},
Data: map[string]any{"index": sampleIndex},
}
if err := Record(ev); err != nil {
if err := Record(event); err != nil {
b.Fatalf("Parallel Record failed: %v", err)
}
i++
sampleIndex++
}
})
}
@ -163,14 +159,14 @@ func BenchmarkMetricsRecordAndQuery(b *testing.B) {
now := time.Now()
// Write 10K events
for i := range 10_000 {
ev := Event{
Type: fmt.Sprintf("type-%d", i%10),
for eventIndex := range 10_000 {
event := Event{
Type: fmt.Sprintf("type-%d", eventIndex%10),
Timestamp: now,
AgentID: "bench",
Repo: "bench-repo",
}
if err := Record(ev); err != nil {
if err := Record(event); err != nil {
b.Fatalf("Record failed: %v", err)
}
}
@ -187,52 +183,45 @@ func BenchmarkMetricsRecordAndQuery(b *testing.B) {
}
}
// --- Unit tests for metrics at scale ---
// TestMetricsBench_RecordAndRead_10K_Good writes 10K events and reads them back.
func TestMetricsBench_RecordAndRead_10K_Good(t *testing.T) {
withTempHome(t)
now := time.Now()
const n = 10_000
const eventCount = 10_000
// Write events
for i := range n {
ev := Event{
Type: fmt.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),
Data: map[string]any{"index": i},
for eventIndex := range eventCount {
event := Event{
Type: fmt.Sprintf("type-%d", eventIndex%10),
Timestamp: now.Add(-time.Duration(eventIndex) * time.Millisecond),
AgentID: fmt.Sprintf("agent-%d", eventIndex%5),
Repo: fmt.Sprintf("repo-%d", eventIndex%3),
Data: map[string]any{"index": eventIndex},
}
if err := Record(ev); err != nil {
t.Fatalf("Record failed at %d: %v", i, err)
if err := Record(event); err != nil {
t.Fatalf("Record failed at %d: %v", eventIndex, err)
}
}
// Read back
since := now.Add(-24 * time.Hour)
events, err := ReadEvents(since)
if err != nil {
t.Fatalf("ReadEvents failed: %v", err)
}
if len(events) != n {
t.Errorf("Expected %d events, got %d", n, len(events))
if len(events) != eventCount {
t.Errorf("Expected %d events, got %d", eventCount, len(events))
}
// Summarise
summary := Summary(events)
total, ok := summary["total"].(int)
if !ok || total != n {
t.Errorf("Expected total %d, got %v", n, summary["total"])
if !ok || total != eventCount {
t.Errorf("Expected total %d, got %v", eventCount, summary["total"])
}
// Verify aggregation counts
byType, ok := summary["by_type"].([]map[string]any)
if !ok || len(byType) == 0 {
t.Fatal("Expected non-empty by_type")
}
// Each of 10 types should have n/10 = 1000 events
for _, entry := range byType {
count, _ := entry["count"].(int)
if count != 1000 {

View file

@ -22,18 +22,16 @@ func withTempHome(t *testing.T) {
t.Cleanup(func() { os.Setenv("HOME", origHome) })
}
// --- Record ---
func TestMetrics_Record_Good(t *testing.T) {
withTempHome(t)
ev := Event{
event := Event{
Type: "test_event",
AgentID: "agent-1",
Repo: "repo-1",
Data: map[string]any{"key": "value"},
}
if err := Record(ev); err != nil {
if err := Record(event); err != nil {
t.Fatalf("Record: %v", err)
}
@ -56,8 +54,8 @@ func TestMetrics_Record_Good_AutoTimestamp(t *testing.T) {
withTempHome(t)
before := time.Now()
ev := Event{Type: "auto_ts"}
if err := Record(ev); err != nil {
event := Event{Type: "auto_ts"}
if err := Record(event); err != nil {
t.Fatalf("Record: %v", err)
}
after := time.Now()
@ -79,8 +77,8 @@ func TestMetrics_Record_Good_PresetTimestamp(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 {
event := Event{Type: "preset_ts", Timestamp: fixed}
if err := Record(event); err != nil {
t.Fatalf("Record: %v", err)
}
@ -96,8 +94,6 @@ func TestMetrics_Record_Good_PresetTimestamp(t *testing.T) {
}
}
// --- ReadEvents ---
func TestMetrics_ReadEvents_Good_Empty(t *testing.T) {
withTempHome(t)
@ -116,19 +112,16 @@ func TestMetrics_ReadEvents_Good_MultiDay(t *testing.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 {
yesterdayEvent := Event{Type: "yesterday", Timestamp: yesterday}
if err := Record(yesterdayEvent); err != nil {
t.Fatalf("Record yesterday: %v", err)
}
// Write event for today
ev2 := Event{Type: "today", Timestamp: now}
if err := Record(ev2); err != nil {
todayEvent := Event{Type: "today", Timestamp: now}
if err := Record(todayEvent); 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)
@ -142,17 +135,15 @@ func TestMetrics_ReadEvents_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 {
oldEvent := Event{Type: "old", Timestamp: now.Add(-2 * time.Hour)}
if err := Record(oldEvent); err != nil {
t.Fatalf("Record old: %v", err)
}
recent := Event{Type: "recent", Timestamp: now}
if err := Record(recent); err != nil {
recentEvent := Event{Type: "recent", Timestamp: now}
if err := Record(recentEvent); 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)
@ -165,8 +156,6 @@ func TestMetrics_ReadEvents_Good_FiltersBySince(t *testing.T) {
}
}
// --- readMetricsFile ---
func TestMetrics_ReadMetricsFile_Good_MalformedLines(t *testing.T) {
withTempHome(t)
@ -175,7 +164,6 @@ func TestMetrics_ReadMetricsFile_Good_MalformedLines(t *testing.T) {
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"}
not-json
@ -205,8 +193,6 @@ func TestMetrics_ReadMetricsFile_Good_NonExistent(t *testing.T) {
}
}
// --- metricsFilePath ---
func TestMetrics_MetricsFilePath_Good(t *testing.T) {
ts := time.Date(2026, 3, 17, 14, 30, 0, 0, time.UTC)
got := metricsFilePath("/base", ts)
@ -216,13 +202,11 @@ func TestMetrics_MetricsFilePath_Good(t *testing.T) {
}
}
// --- Summary ---
func TestMetrics_Summary_Good_Empty(t *testing.T) {
s := Summary(nil)
total, ok := s["total"].(int)
summary := Summary(nil)
total, ok := summary["total"].(int)
if !ok || total != 0 {
t.Errorf("expected total 0, got %v", s["total"])
t.Errorf("expected total 0, got %v", summary["total"])
}
}
@ -233,25 +217,22 @@ func TestMetrics_Summary_Good(t *testing.T) {
{Type: "test", Repo: "core-api", AgentID: "agent-1"},
}
s := Summary(events)
summary := Summary(events)
total, _ := s["total"].(int)
total, _ := summary["total"].(int)
if total != 3 {
t.Errorf("expected total 3, got %d", total)
}
byType, _ := s["by_type"].([]map[string]any)
byType, _ := summary["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 TestMetrics_SortedMap_Good_Empty(t *testing.T) {
result := sortedMap(map[string]int{})
if len(result) != 0 {
@ -260,12 +241,11 @@ func TestMetrics_SortedMap_Good_Empty(t *testing.T) {
}
func TestMetrics_SortedMap_Good_Ordering(t *testing.T) {
m := map[string]int{"a": 1, "b": 3, "c": 2}
result := sortedMap(m)
counts := map[string]int{"a": 1, "b": 3, "c": 2}
result := sortedMap(counts)
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"])
}

View file

@ -8,7 +8,7 @@ import (
"forge.lthn.ai/core/go-rag"
)
// TaskInfo carries the task data used to build a RAG query.
// TaskInfo is the input for QueryRAGForTask.
//
// ai.QueryRAGForTask(ai.TaskInfo{Title: "Fix auth", Description: "JWT expiry not checked"})
type TaskInfo struct {
@ -20,22 +20,22 @@ type TaskInfo struct {
//
// ctx, _ := ai.QueryRAGForTask(ai.TaskInfo{Title: "Fix auth bug", Description: "JWT expiry not checked"})
func QueryRAGForTask(task TaskInfo) (string, error) {
query := task.Title + " " + task.Description
queryText := task.Title + " " + task.Description
runes := []rune(query)
runes := []rune(queryText)
if len(runes) > 500 {
query = string(runes[:500])
queryText = string(runes[:500])
}
qdrantConfig := rag.DefaultQdrantConfig()
qdrantClient, err := rag.NewQdrantClient(qdrantConfig)
qdrantConfiguration := rag.DefaultQdrantConfig()
qdrantClient, err := rag.NewQdrantClient(qdrantConfiguration)
if err != nil {
return "", coreerr.E("ai.QueryRAGForTask", "rag qdrant client", err)
}
defer func() { _ = qdrantClient.Close() }()
ollamaConfig := rag.DefaultOllamaConfig()
ollamaClient, err := rag.NewOllamaClient(ollamaConfig)
ollamaConfiguration := rag.DefaultOllamaConfig()
ollamaClient, err := rag.NewOllamaClient(ollamaConfiguration)
if err != nil {
return "", coreerr.E("ai.QueryRAGForTask", "rag ollama client", err)
}
@ -43,13 +43,13 @@ func QueryRAGForTask(task TaskInfo) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
queryConfig := rag.QueryConfig{
searchConfiguration := rag.QueryConfig{
Collection: "hostuk-docs",
Limit: 3,
Threshold: 0.5,
}
results, err := rag.Query(ctx, qdrantClient, ollamaClient, query, queryConfig)
results, err := rag.Query(ctx, qdrantClient, ollamaClient, queryText, searchConfiguration)
if err != nil {
return "", coreerr.E("ai.QueryRAGForTask", "rag query", err)
}

View file

@ -27,14 +27,11 @@ import (
var ollamaURL = flag.String("ollama", "http://localhost:11434", "Ollama base URL")
// models to benchmark
var models = []string{
"nomic-embed-text",
"embeddinggemma",
}
// Test corpus: real-ish agent memories grouped by topic.
// Memories within a group should be similar; across groups should be distant.
var memoryGroups = []struct {
topic string
memories []string
@ -73,7 +70,6 @@ var memoryGroups = []struct {
},
}
// Queries to test recall quality — each has a target topic it should match best.
var queries = []struct {
query string
targetTopic string
@ -98,46 +94,43 @@ func main() {
fmt.Printf("\n## Model: %s\n", model)
fmt.Println(strings.Repeat("-", 40))
// Check model is available
if !modelAvailable(model) {
fmt.Printf(" SKIPPED — model not pulled (run: ollama pull %s)\n", model)
continue
}
// 1. Embed all memories
allMemories := []string{}
allTopics := []string{}
memoryTexts := []string{}
memoryTopics := []string{}
for _, group := range memoryGroups {
for _, mem := range group.memories {
allMemories = append(allMemories, mem)
allTopics = append(allTopics, group.topic)
for _, memoryText := range group.memories {
memoryTexts = append(memoryTexts, memoryText)
memoryTopics = append(memoryTopics, group.topic)
}
}
fmt.Printf(" Embedding %d memories...\n", len(allMemories))
fmt.Printf(" Embedding %d memories...\n", len(memoryTexts))
start := time.Now()
memVectors := make([][]float64, len(allMemories))
for i, mem := range allMemories {
vec, err := embed(model, mem)
memoryVectors := make([][]float64, len(memoryTexts))
for memoryIndex, memoryText := range memoryTexts {
vector, err := embed(model, memoryText)
if err != nil {
fmt.Printf(" ERROR embedding memory %d: %v\n", i, err)
fmt.Printf(" ERROR embedding memory %d: %v\n", memoryIndex, err)
break
}
memVectors[i] = vec
memoryVectors[memoryIndex] = vector
}
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]))
fmt.Printf(" Embedded in %v (%.0fms/memory)\n", embedTime, float64(embedTime.Milliseconds())/float64(len(memoryTexts)))
fmt.Printf(" Vector dimension: %d\n", len(memoryVectors[0]))
// 2. Intra-group vs inter-group similarity
var intraSims, interSims []float64
for i := 0; i < len(allMemories); i++ {
for j := i + 1; j < len(allMemories); j++ {
sim := cosine(memVectors[i], memVectors[j])
if allTopics[i] == allTopics[j] {
intraSims = append(intraSims, sim)
for leftIndex := 0; leftIndex < len(memoryTexts); leftIndex++ {
for rightIndex := leftIndex + 1; rightIndex < len(memoryTexts); rightIndex++ {
similarity := cosine(memoryVectors[leftIndex], memoryVectors[rightIndex])
if memoryTopics[leftIndex] == memoryTopics[rightIndex] {
intraSims = append(intraSims, similarity)
} else {
interSims = append(interSims, sim)
interSims = append(interSims, similarity)
}
}
}
@ -151,28 +144,26 @@ func main() {
fmt.Printf(" Inter-group similarity (diff topic): %.4f\n", interAvg)
fmt.Printf(" Separation gap: %.4f %s\n", separation, qualityLabel(separation))
// 3. Query recall accuracy
fmt.Printf("\n Query recall (top-1 accuracy):\n")
correct := 0
for _, queryCase := range queries {
queryVec, err := embed(model, queryCase.query)
queryVector, err := embed(model, queryCase.query)
if err != nil {
fmt.Printf(" ERROR: %v\n", err)
continue
}
// Find best match
bestIdx := 0
bestSim := -1.0
for i, mv := range memVectors {
sim := cosine(queryVec, mv)
if sim > bestSim {
bestSim = sim
bestIdx = i
bestIndex := 0
bestSimilarity := -1.0
for memoryIndex, memoryVector := range memoryVectors {
similarity := cosine(queryVector, memoryVector)
if similarity > bestSimilarity {
bestSimilarity = similarity
bestIndex = memoryIndex
}
}
matchTopic := allTopics[bestIdx]
matchTopic := memoryTopics[bestIndex]
hit := matchTopic == queryCase.targetTopic
if hit {
correct++
@ -181,29 +172,30 @@ func main() {
if !hit {
marker = "✗"
}
fmt.Printf(" %s %.4f %q → %s (want: %s)\n", marker, bestSim, truncate(queryCase.query, 40), matchTopic, queryCase.targetTopic)
fmt.Printf(" %s %.4f %q → %s (want: %s)\n", marker, bestSimilarity, truncate(queryCase.query, 40), matchTopic, queryCase.targetTopic)
}
accuracy := float64(correct) / float64(len(queries)) * 100
fmt.Printf("\n Top-1 accuracy: %.0f%% (%d/%d)\n", accuracy, correct, len(queries))
// 4. Top-3 recall
correct3 := 0
for _, queryCase := range queries {
queryVec, _ := embed(model, queryCase.query)
queryVector, _ := embed(model, queryCase.query)
type scored struct {
idx int
sim float64
type scoredResult struct {
index int
sim float64
}
var ranked []scored
for i, mv := range memVectors {
ranked = append(ranked, scored{i, cosine(queryVec, mv)})
var rankedResults []scoredResult
for memoryIndex, memoryVector := range memoryVectors {
rankedResults = append(rankedResults, scoredResult{memoryIndex, cosine(queryVector, memoryVector)})
}
sort.Slice(ranked, func(a, b int) bool { return ranked[a].sim > ranked[b].sim })
sort.Slice(rankedResults, func(leftIndex, rightIndex int) bool {
return rankedResults[leftIndex].sim > rankedResults[rightIndex].sim
})
for _, rankedResult := range ranked[:3] {
if allTopics[rankedResult.idx] == queryCase.targetTopic {
for _, rankedResult := range rankedResults[:3] {
if memoryTopics[rankedResult.index] == queryCase.targetTopic {
correct3++
break
}
@ -217,7 +209,6 @@ func main() {
fmt.Println("Done.")
}
// httpClient trusts self-signed certs for .lan domains behind Traefik.
var httpClient = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // .lan only
@ -272,7 +263,6 @@ func modelAvailable(model string) bool {
}
json.NewDecoder(resp.Body).Decode(&result)
for _, registeredModel := range result.Models {
// Match "nomic-embed-text:latest" against "nomic-embed-text"
if registeredModel.Name == model || strings.HasPrefix(registeredModel.Name, model+":") {
return true
}
@ -284,32 +274,32 @@ func modelAvailable(model string) bool {
//
// cosine([]float64{1, 0}, []float64{1, 0}) // → 1.0
// cosine([]float64{1, 0}, []float64{0, 1}) // → 0.0
func cosine(a, b []float64) float64 {
var dot, normA, normB float64
for i := range a {
dot += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
func cosine(firstVector, secondVector []float64) float64 {
var dotProduct, firstMagnitudeSquared, secondMagnitudeSquared float64
for index := range firstVector {
dotProduct += firstVector[index] * secondVector[index]
firstMagnitudeSquared += firstVector[index] * firstVector[index]
secondMagnitudeSquared += secondVector[index] * secondVector[index]
}
denom := math.Sqrt(normA) * math.Sqrt(normB)
if denom == 0 {
denominator := math.Sqrt(firstMagnitudeSquared) * math.Sqrt(secondMagnitudeSquared)
if denominator == 0 {
return 0
}
return dot / denom
return dotProduct / denominator
}
// avg returns the arithmetic mean of a float64 slice, or 0 for an empty slice.
//
// avg([]float64{0.8, 0.6, 0.9}) // → 0.7666...
func avg(vals []float64) float64 {
if len(vals) == 0 {
func avg(values []float64) float64 {
if len(values) == 0 {
return 0
}
sum := 0.0
for _, value := range vals {
sum += value
total := 0.0
for _, value := range values {
total += value
}
return sum / float64(len(vals))
return total / float64(len(values))
}
// qualityLabel maps a cosine separation gap to a human-readable quality band.
@ -332,14 +322,13 @@ func qualityLabel(gap float64) string {
// truncate shortens s to at most n characters, appending "..." if truncated.
//
// truncate("How does the emotional scoring work?", 20) // → "How does the emot..."
func truncate(s string, n int) string {
if len(s) <= n {
return s
func truncate(text string, limit int) string {
if len(text) <= limit {
return text
}
return s[:n-3] + "..."
return text[:limit-3] + "..."
}
func init() {
// Ensure stderr doesn't buffer
os.Stderr.Sync()
}

View file

@ -21,17 +21,17 @@ func init() {
cli.RegisterCommands(AddLabCommands)
}
var labCmd = &cli.Command{
var labCommand = &cli.Command{
Use: "lab",
Short: "Homelab monitoring dashboard",
Long: "Lab dashboard with real-time monitoring of machines, training runs, models, and services.",
}
var (
labBind string
labBindAddress string
)
var serveCmd = &cli.Command{
var serveCommand = &cli.Command{
Use: "serve",
Short: "Start the lab dashboard web server",
Long: "Starts the lab dashboard HTTP server with live-updating collectors for system stats, Docker, Forgejo, HuggingFace, InfluxDB, and more.",
@ -39,99 +39,99 @@ var serveCmd = &cli.Command{
}
func init() {
serveCmd.Flags().StringVar(&labBind, "bind", ":8080", "HTTP listen address")
serveCommand.Flags().StringVar(&labBindAddress, "bind", ":8080", "HTTP listen address")
}
// AddLabCommands registers the lab command tree with the root command.
//
// lab.AddLabCommands(rootCmd) // → core lab serve --bind :8080
func AddLabCommands(root *cli.Command) {
labCmd.AddCommand(serveCmd)
root.AddCommand(labCmd)
labCommand.AddCommand(serveCommand)
root.AddCommand(labCommand)
}
func runServe(cmd *cli.Command, args []string) error {
cfg := lab.LoadConfig()
cfg.Addr = labBind
func runServe(_ *cli.Command, _ []string) error {
configuration := lab.LoadConfig()
configuration.Addr = labBindAddress
store := lab.NewStore()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
reg := collector.NewRegistry(logger)
reg.Register(collector.NewSystem(cfg, store), 60*time.Second)
reg.Register(collector.NewPrometheus(cfg.PrometheusURL, store),
time.Duration(cfg.PrometheusInterval)*time.Second)
reg.Register(collector.NewHuggingFace(cfg.HFAuthor, store),
time.Duration(cfg.HFInterval)*time.Second)
reg.Register(collector.NewDocker(store),
time.Duration(cfg.DockerInterval)*time.Second)
registry := collector.NewRegistry(logger)
registry.Register(collector.NewSystem(configuration, store), 60*time.Second)
registry.Register(collector.NewPrometheus(configuration.PrometheusURL, store),
time.Duration(configuration.PrometheusInterval)*time.Second)
registry.Register(collector.NewHuggingFace(configuration.HFAuthor, store),
time.Duration(configuration.HFInterval)*time.Second)
registry.Register(collector.NewDocker(store),
time.Duration(configuration.DockerInterval)*time.Second)
if cfg.ForgeToken != "" {
reg.Register(collector.NewForgejo(cfg.ForgeURL, cfg.ForgeToken, store),
time.Duration(cfg.ForgeInterval)*time.Second)
if configuration.ForgeToken != "" {
registry.Register(collector.NewForgejo(configuration.ForgeURL, configuration.ForgeToken, store),
time.Duration(configuration.ForgeInterval)*time.Second)
}
reg.Register(collector.NewTraining(cfg, store),
time.Duration(cfg.TrainingInterval)*time.Second)
reg.Register(collector.NewServices(store), 60*time.Second)
registry.Register(collector.NewTraining(configuration, store),
time.Duration(configuration.TrainingInterval)*time.Second)
registry.Register(collector.NewServices(store), 60*time.Second)
if cfg.InfluxToken != "" {
reg.Register(collector.NewInfluxDB(cfg, store),
time.Duration(cfg.InfluxInterval)*time.Second)
if configuration.InfluxToken != "" {
registry.Register(collector.NewInfluxDB(configuration, store),
time.Duration(configuration.InfluxInterval)*time.Second)
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
shutdownContext, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
reg.Start(ctx)
defer reg.Stop()
registry.Start(shutdownContext)
defer registry.Stop()
web := handler.NewWebHandler(store)
api := handler.NewAPIHandler(store)
webHandler := handler.NewWebHandler(store)
apiHandler := handler.NewAPIHandler(store)
mux := http.NewServeMux()
serveMux := http.NewServeMux()
mux.HandleFunc("GET /", web.Dashboard)
mux.HandleFunc("GET /models", web.Models)
mux.HandleFunc("GET /training", web.Training)
mux.HandleFunc("GET /dataset", web.Dataset)
mux.HandleFunc("GET /golden-set", func(w http.ResponseWriter, r *http.Request) {
serveMux.HandleFunc("GET /", webHandler.Dashboard)
serveMux.HandleFunc("GET /models", webHandler.Models)
serveMux.HandleFunc("GET /training", webHandler.Training)
serveMux.HandleFunc("GET /dataset", webHandler.Dataset)
serveMux.HandleFunc("GET /golden-set", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/dataset", http.StatusMovedPermanently)
})
mux.HandleFunc("GET /runs", func(w http.ResponseWriter, r *http.Request) {
serveMux.HandleFunc("GET /runs", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/training", http.StatusMovedPermanently)
})
mux.HandleFunc("GET /agents", web.Agents)
mux.HandleFunc("GET /services", web.Services)
serveMux.HandleFunc("GET /agents", webHandler.Agents)
serveMux.HandleFunc("GET /services", webHandler.Services)
mux.HandleFunc("GET /events", web.Events)
serveMux.HandleFunc("GET /events", webHandler.Events)
mux.HandleFunc("GET /api/status", api.Status)
mux.HandleFunc("GET /api/models", api.Models)
mux.HandleFunc("GET /api/training", api.Training)
mux.HandleFunc("GET /api/dataset", api.GoldenSet)
mux.HandleFunc("GET /api/golden-set", api.GoldenSet)
mux.HandleFunc("GET /api/runs", api.Runs)
mux.HandleFunc("GET /api/agents", api.Agents)
mux.HandleFunc("GET /api/services", api.Services)
mux.HandleFunc("GET /health", api.Health)
serveMux.HandleFunc("GET /api/status", apiHandler.Status)
serveMux.HandleFunc("GET /api/models", apiHandler.Models)
serveMux.HandleFunc("GET /api/training", apiHandler.Training)
serveMux.HandleFunc("GET /api/dataset", apiHandler.GoldenSet)
serveMux.HandleFunc("GET /api/golden-set", apiHandler.GoldenSet)
serveMux.HandleFunc("GET /api/runs", apiHandler.Runs)
serveMux.HandleFunc("GET /api/agents", apiHandler.Agents)
serveMux.HandleFunc("GET /api/services", apiHandler.Services)
serveMux.HandleFunc("GET /health", apiHandler.Health)
srv := &http.Server{
Addr: cfg.Addr,
Handler: mux,
server := &http.Server{
Addr: configuration.Addr,
Handler: serveMux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
go func() {
<-ctx.Done()
<-shutdownContext.Done()
logger.Info("shutting down")
shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutCancel()
srv.Shutdown(shutCtx)
shutdownDeadline, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
server.Shutdown(shutdownDeadline)
}()
logger.Info("lab dashboard starting", "addr", cfg.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Info("lab dashboard starting", "addr", configuration.Addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil

View file

@ -4,12 +4,14 @@ package metrics
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"forge.lthn.ai/core/cli/pkg/cli"
"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 (
@ -21,7 +23,7 @@ var metricsCmd = &cli.Command{
Use: "metrics",
Short: i18n.T("cmd.ai.metrics.short"),
Long: i18n.T("cmd.ai.metrics.long"),
RunE: func(cmd *cli.Command, args []string) error {
RunE: func(_ *cli.Command, _ []string) error {
return runMetrics()
},
}
@ -40,54 +42,54 @@ func AddMetricsCommand(parent *cli.Command) {
}
func runMetrics() error {
since, err := parseDuration(metricsSince)
sinceDuration, err := parseDuration(metricsSince)
if err != nil {
return cli.Err("invalid --since value %q: %v", metricsSince, err)
}
sinceTime := time.Now().Add(-since)
sinceTime := time.Now().Add(-sinceDuration)
events, err := ai.ReadEvents(sinceTime)
if err != nil {
return cli.WrapVerb(err, "read", "metrics")
}
if metricsJSON {
summary := ai.Summary(events)
output, err := json.MarshalIndent(summary, "", " ")
eventSummary := ai.Summary(events)
jsonOutput, err := json.MarshalIndent(eventSummary, "", " ")
if err != nil {
return cli.Wrap(err, "marshal JSON output")
}
cli.Text(string(output))
cli.Text(string(jsonOutput))
return nil
}
summary := ai.Summary(events)
eventSummary := ai.Summary(events)
cli.Blank()
cli.Print("%s %s\n", cli.DimStyle.Render("Period:"), metricsSince)
total, _ := summary["total"].(int)
cli.Print("%s %d\n", cli.DimStyle.Render("Total events:"), total)
eventCount, _ := eventSummary["total"].(int)
cli.Print("%s %d\n", cli.DimStyle.Render("Total events:"), eventCount)
cli.Blank()
if byType, ok := summary["by_type"].([]map[string]any); ok && len(byType) > 0 {
if typeCounts, ok := eventSummary["by_type"].([]map[string]any); ok && len(typeCounts) > 0 {
cli.Print("%s\n", cli.DimStyle.Render("By type:"))
for _, entry := range byType {
for _, entry := range typeCounts {
cli.Print(" %-30s %v\n", entry["key"], entry["count"])
}
cli.Blank()
}
if byRepo, ok := summary["by_repo"].([]map[string]any); ok && len(byRepo) > 0 {
if repoCounts, ok := eventSummary["by_repo"].([]map[string]any); ok && len(repoCounts) > 0 {
cli.Print("%s\n", cli.DimStyle.Render("By repo:"))
for _, entry := range byRepo {
for _, entry := range repoCounts {
cli.Print(" %-30s %v\n", entry["key"], entry["count"])
}
cli.Blank()
}
if byAgent, ok := summary["by_agent"].([]map[string]any); ok && len(byAgent) > 0 {
if agentCounts, ok := eventSummary["by_agent"].([]map[string]any); ok && len(agentCounts) > 0 {
cli.Print("%s\n", cli.DimStyle.Render("By contributor:"))
for _, entry := range byAgent {
for _, entry := range agentCounts {
cli.Print(" %-30s %v\n", entry["key"], entry["count"])
}
cli.Blank()
@ -105,31 +107,31 @@ func runMetrics() error {
// parseDuration("7d") // → 7 * 24 * time.Hour
// parseDuration("24h") // → 24 * time.Hour
// parseDuration("30m") // → 30 * time.Minute
func parseDuration(s string) (time.Duration, error) {
if len(s) < 2 {
return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("invalid duration: %s", s), nil)
func parseDuration(durationText string) (time.Duration, error) {
if len(durationText) < 2 {
return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("invalid duration: %s", durationText), nil)
}
unitChar := s[len(s)-1]
numericPart := s[:len(s)-1]
unitSuffix := durationText[len(durationText)-1]
countText := strings.TrimSpace(durationText[:len(durationText)-1])
var count int
if _, err := fmt.Sscanf(numericPart, "%d", &count); err != nil {
return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("invalid duration: %s", s), nil)
quantity, err := strconv.Atoi(countText)
if err != nil {
return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("invalid duration: %s", durationText), nil)
}
if count <= 0 {
return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("duration must be positive: %s", s), nil)
if quantity <= 0 {
return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("duration must be positive: %s", durationText), nil)
}
switch unitChar {
switch unitSuffix {
case 'd':
return time.Duration(count) * 24 * time.Hour, nil
return time.Duration(quantity) * 24 * time.Hour, nil
case 'h':
return time.Duration(count) * time.Hour, nil
return time.Duration(quantity) * time.Hour, nil
case 'm':
return time.Duration(count) * time.Minute, nil
return time.Duration(quantity) * time.Minute, nil
default:
return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("unknown unit %c in duration: %s", unitChar, s), nil)
return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("unknown unit %c in duration: %s", unitSuffix, durationText), nil)
}
}

View file

@ -53,15 +53,15 @@ func TestCmd_ParseDuration_Ugly(t *testing.T) {
input string
wantErr bool
}{
// Leading whitespace — Sscanf skips it, so " 7d" parses as 7d (valid).
// Leading whitespace is trimmed from the count, so " 7d" still parses.
{" 7d", false},
// Trailing whitespace after unit — unit is last char, not whitespace, so this is invalid unit ' '.
// Trailing whitespace changes the unit suffix to space, so this is invalid.
{"7d ", true},
// Very large count that still parses — 9999d is valid Go duration arithmetic.
{"9999d", false},
// Mixed case unit — 'D' is not 'd', so unknown unit.
{"7D", true},
// Float value — Sscanf %d won't accept "7.5", so invalid numeric.
// Float value — strconv.Atoi rejects "7.5" as non-integer text.
{"7.5d", true},
// Just the unit char, no number — too short (len < 2 for "d"; "hd" has no valid number).
{"hd", true},

View file

@ -4,30 +4,32 @@ import (
"encoding/json"
"fmt"
"forge.lthn.ai/core/cli/pkg/cli"
"dappco.re/go/core/i18n"
"forge.lthn.ai/core/cli/pkg/cli"
)
func addAlertsCommand(parent *cli.Command) {
cmd := &cli.Command{
command := &cli.Command{
Use: "alerts",
Short: i18n.T("cmd.security.alerts.short"),
Long: i18n.T("cmd.security.alerts.long"),
RunE: func(c *cli.Command, args []string) error {
RunE: func(_ *cli.Command, _ []string) error {
return runAlerts()
},
}
cmd.Flags().StringVar(&securityRegistryPath, "registry", "", i18n.T("common.flag.registry"))
cmd.Flags().StringVar(&securityRepo, "repo", "", i18n.T("cmd.security.flag.repo"))
cmd.Flags().StringVar(&securitySeverity, "severity", "", i18n.T("cmd.security.flag.severity"))
cmd.Flags().BoolVar(&securityJSON, "json", false, i18n.T("common.flag.json"))
cmd.Flags().StringVar(&securityTarget, "target", "", i18n.T("cmd.security.flag.target"))
command.Flags().StringVar(&securityRegistryPath, "registry", "", i18n.T("common.flag.registry"))
command.Flags().StringVar(&securityRepo, "repo", "", i18n.T("cmd.security.flag.repo"))
command.Flags().StringVar(&securitySeverity, "severity", "", i18n.T("cmd.security.flag.severity"))
command.Flags().BoolVar(&securityJSON, "json", false, i18n.T("common.flag.json"))
command.Flags().StringVar(&securityTarget, "target", "", i18n.T("cmd.security.flag.target"))
parent.AddCommand(cmd)
parent.AddCommand(command)
}
// AlertOutput is the unified alert shape written to JSON or the terminal.
// AlertOutput is the unified alert row used by the security commands.
//
// AlertOutput{Repo: "core-php", Severity: "high", Type: "dependabot"}
type AlertOutput struct {
Repo string `json:"repo"`
Severity string `json:"severity"`
@ -48,12 +50,12 @@ func runAlerts() error {
return runAlertsForTarget(securityTarget)
}
reg, err := loadRegistry(securityRegistryPath)
registry, err := loadRegistry(securityRegistryPath)
if err != nil {
return err
}
repoList := getReposToCheck(reg, securityRepo)
repoList := getReposToCheck(registry, securityRepo)
if len(repoList) == 0 {
return cli.Err("repo not found: %s", securityRepo)
}
@ -62,11 +64,11 @@ func runAlerts() error {
summary := &AlertSummary{}
for _, repo := range repoList {
repoFullName := fmt.Sprintf("%s/%s", reg.Org, repo.Name)
repoFullName := fmt.Sprintf("%s/%s", registry.Org, repo.Name)
depAlerts, err := fetchDependabotAlerts(repoFullName)
dependabotAlerts, err := fetchDependabotAlerts(repoFullName)
if err == nil {
for _, alert := range depAlerts {
for _, alert := range dependabotAlerts {
if alert.State != "open" {
continue
}
@ -87,9 +89,9 @@ func runAlerts() error {
}
}
codeAlerts, err := fetchCodeScanningAlerts(repoFullName)
codeScanningAlerts, err := fetchCodeScanningAlerts(repoFullName)
if err == nil {
for _, alert := range codeAlerts {
for _, alert := range codeScanningAlerts {
if alert.State != "open" {
continue
}
@ -110,9 +112,9 @@ func runAlerts() error {
}
}
secretAlerts, err := fetchSecretScanningAlerts(repoFullName)
secretScanningAlerts, err := fetchSecretScanningAlerts(repoFullName)
if err == nil {
for _, alert := range secretAlerts {
for _, alert := range secretScanningAlerts {
if alert.State != "open" {
continue
}
@ -149,7 +151,7 @@ func runAlerts() error {
}
for _, alert := range allAlerts {
sevStyle := severityStyle(alert.Severity)
severityRenderer := severityStyle(alert.Severity)
location := alert.Package
if location == "" {
@ -161,7 +163,7 @@ func runAlerts() error {
cli.Print("%-20s %s %-16s %-40s %s\n",
cli.ValueStyle.Render(alert.Repo),
sevStyle.Render(fmt.Sprintf("%-8s", alert.Severity)),
severityRenderer.Render(fmt.Sprintf("%-8s", alert.Severity)),
alert.ID,
location,
cli.DimStyle.Render(alert.Type),
@ -184,9 +186,9 @@ func runAlertsForTarget(target string) error {
var allAlerts []AlertOutput
summary := &AlertSummary{}
depAlerts, err := fetchDependabotAlerts(fullName)
dependabotAlerts, err := fetchDependabotAlerts(fullName)
if err == nil {
for _, alert := range depAlerts {
for _, alert := range dependabotAlerts {
if alert.State != "open" {
continue
}
@ -207,9 +209,9 @@ func runAlertsForTarget(target string) error {
}
}
codeAlerts, err := fetchCodeScanningAlerts(fullName)
codeScanningAlerts, err := fetchCodeScanningAlerts(fullName)
if err == nil {
for _, alert := range codeAlerts {
for _, alert := range codeScanningAlerts {
if alert.State != "open" {
continue
}
@ -230,9 +232,9 @@ func runAlertsForTarget(target string) error {
}
}
secretAlerts, err := fetchSecretScanningAlerts(fullName)
secretScanningAlerts, err := fetchSecretScanningAlerts(fullName)
if err == nil {
for _, alert := range secretAlerts {
for _, alert := range secretScanningAlerts {
if alert.State != "open" {
continue
}
@ -268,7 +270,7 @@ func runAlertsForTarget(target string) error {
}
for _, alert := range allAlerts {
sevStyle := severityStyle(alert.Severity)
severityRenderer := severityStyle(alert.Severity)
location := alert.Package
if location == "" {
location = alert.Location
@ -278,7 +280,7 @@ func runAlertsForTarget(target string) error {
}
cli.Print("%-20s %s %-16s %-40s %s\n",
cli.ValueStyle.Render(alert.Repo),
sevStyle.Render(fmt.Sprintf("%-8s", alert.Severity)),
severityRenderer.Render(fmt.Sprintf("%-8s", alert.Severity)),
alert.ID,
location,
cli.DimStyle.Render(alert.Type),

View file

@ -4,30 +4,32 @@ import (
"encoding/json"
"fmt"
"forge.lthn.ai/core/cli/pkg/cli"
"dappco.re/go/core/i18n"
"forge.lthn.ai/core/cli/pkg/cli"
)
func addDepsCommand(parent *cli.Command) {
cmd := &cli.Command{
command := &cli.Command{
Use: "deps",
Short: i18n.T("cmd.security.deps.short"),
Long: i18n.T("cmd.security.deps.long"),
RunE: func(c *cli.Command, args []string) error {
RunE: func(_ *cli.Command, _ []string) error {
return runDeps()
},
}
cmd.Flags().StringVar(&securityRegistryPath, "registry", "", i18n.T("common.flag.registry"))
cmd.Flags().StringVar(&securityRepo, "repo", "", i18n.T("cmd.security.flag.repo"))
cmd.Flags().StringVar(&securitySeverity, "severity", "", i18n.T("cmd.security.flag.severity"))
cmd.Flags().BoolVar(&securityJSON, "json", false, i18n.T("common.flag.json"))
cmd.Flags().StringVar(&securityTarget, "target", "", i18n.T("cmd.security.flag.target"))
command.Flags().StringVar(&securityRegistryPath, "registry", "", i18n.T("common.flag.registry"))
command.Flags().StringVar(&securityRepo, "repo", "", i18n.T("cmd.security.flag.repo"))
command.Flags().StringVar(&securitySeverity, "severity", "", i18n.T("cmd.security.flag.severity"))
command.Flags().BoolVar(&securityJSON, "json", false, i18n.T("common.flag.json"))
command.Flags().StringVar(&securityTarget, "target", "", i18n.T("cmd.security.flag.target"))
parent.AddCommand(cmd)
parent.AddCommand(command)
}
// DepAlert is a Dependabot dependency vulnerability entry written to JSON or the terminal.
// DepAlert is the Dependabot row used by `core security deps`.
//
// DepAlert{Repo: "core-php", CVE: "CVE-2026-1234", Vulnerable: ">=1.2 <1.3"}
type DepAlert struct {
Repo string `json:"repo"`
Severity string `json:"severity"`
@ -49,12 +51,12 @@ func runDeps() error {
return runDepsForTarget(securityTarget)
}
reg, err := loadRegistry(securityRegistryPath)
registry, err := loadRegistry(securityRegistryPath)
if err != nil {
return err
}
repoList := getReposToCheck(reg, securityRepo)
repoList := getReposToCheck(registry, securityRepo)
if len(repoList) == 0 {
return cli.Err("repo not found: %s", securityRepo)
}
@ -63,15 +65,15 @@ func runDeps() error {
summary := &AlertSummary{}
for _, repo := range repoList {
repoFullName := fmt.Sprintf("%s/%s", reg.Org, repo.Name)
repoFullName := fmt.Sprintf("%s/%s", registry.Org, repo.Name)
alerts, err := fetchDependabotAlerts(repoFullName)
dependabotAlerts, err := fetchDependabotAlerts(repoFullName)
if err != nil {
cli.Print("%s %s: %v\n", cli.WarningStyle.Render(">>"), repoFullName, err)
continue
}
for _, alert := range alerts {
for _, alert := range dependabotAlerts {
if alert.State != "open" {
continue
}
@ -83,7 +85,7 @@ func runDeps() error {
summary.Add(severity)
depAlert := DepAlert{
dependabotAlert := DepAlert{
Repo: repo.Name,
Severity: severity,
CVE: alert.Advisory.CVEID,
@ -94,7 +96,7 @@ func runDeps() error {
Manifest: alert.Dependency.ManifestPath,
Summary: alert.Advisory.Summary,
}
allAlerts = append(allAlerts, depAlert)
allAlerts = append(allAlerts, dependabotAlert)
}
}
@ -116,7 +118,7 @@ func runDeps() error {
}
for _, alert := range allAlerts {
sevStyle := severityStyle(alert.Severity)
severityRenderer := severityStyle(alert.Severity)
upgrade := alert.Vulnerable
if alert.PatchedVersion != "" {
@ -125,7 +127,7 @@ func runDeps() error {
cli.Print("%-16s %s %-16s %-30s %s\n",
cli.ValueStyle.Render(alert.Repo),
sevStyle.Render(fmt.Sprintf("%-8s", alert.Severity)),
severityRenderer.Render(fmt.Sprintf("%-8s", alert.Severity)),
alert.CVE,
alert.Package,
upgrade,
@ -148,12 +150,12 @@ func runDepsForTarget(target string) error {
var allAlerts []DepAlert
summary := &AlertSummary{}
alerts, err := fetchDependabotAlerts(fullName)
dependabotAlerts, err := fetchDependabotAlerts(fullName)
if err != nil {
return cli.Wrap(err, "fetch dependabot alerts for "+fullName)
}
for _, alert := range alerts {
for _, alert := range dependabotAlerts {
if alert.State != "open" {
continue
}
@ -189,14 +191,14 @@ func runDepsForTarget(target string) error {
cli.Blank()
for _, alert := range allAlerts {
sevStyle := severityStyle(alert.Severity)
severityRenderer := severityStyle(alert.Severity)
upgrade := alert.Vulnerable
if alert.PatchedVersion != "" {
upgrade = fmt.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)),
severityRenderer.Render(fmt.Sprintf("%-8s", alert.Severity)),
alert.CVE,
alert.Package,
upgrade,

View file

@ -6,10 +6,10 @@ import (
"strings"
"time"
"forge.lthn.ai/core/cli/pkg/cli"
"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 (
@ -20,21 +20,21 @@ var (
)
func addJobsCommand(parent *cli.Command) {
cmd := &cli.Command{
command := &cli.Command{
Use: "jobs",
Short: i18n.T("cmd.security.jobs.short"),
Long: i18n.T("cmd.security.jobs.long"),
RunE: func(c *cli.Command, args []string) error {
RunE: func(_ *cli.Command, _ []string) error {
return runJobs()
},
}
cmd.Flags().StringSliceVar(&jobsTargets, "targets", nil, i18n.T("cmd.security.jobs.flag.targets"))
cmd.Flags().StringVar(&jobsIssueRepo, "issue-repo", "host-uk/core", i18n.T("cmd.security.jobs.flag.issue_repo"))
cmd.Flags().BoolVar(&jobsDryRun, "dry-run", false, i18n.T("cmd.security.jobs.flag.dry_run"))
cmd.Flags().IntVar(&jobsCopies, "copies", 1, i18n.T("cmd.security.jobs.flag.copies"))
command.Flags().StringSliceVar(&jobsTargets, "targets", nil, i18n.T("cmd.security.jobs.flag.targets"))
command.Flags().StringVar(&jobsIssueRepo, "issue-repo", "host-uk/core", i18n.T("cmd.security.jobs.flag.issue_repo"))
command.Flags().BoolVar(&jobsDryRun, "dry-run", false, i18n.T("cmd.security.jobs.flag.dry_run"))
command.Flags().IntVar(&jobsCopies, "copies", 1, i18n.T("cmd.security.jobs.flag.copies"))
parent.AddCommand(cmd)
parent.AddCommand(command)
}
func runJobs() error {
@ -70,8 +70,8 @@ func runJobs() error {
//
// createJobForTarget("wailsapp/wails")
func createJobForTarget(target string) error {
parts := strings.SplitN(target, "/", 2)
if len(parts) != 2 {
targetParts := strings.SplitN(target, "/", 2)
if len(targetParts) != 2 || targetParts[0] == "" || targetParts[1] == "" {
return coreerr.E("security.createJobForTarget", "invalid target format: use owner/repo", nil)
}
@ -79,13 +79,13 @@ func createJobForTarget(target string) error {
var findings []string
var fetchErrors int
codeAlerts, err := fetchCodeScanningAlerts(target)
codeScanningAlerts, err := fetchCodeScanningAlerts(target)
if err != nil {
cli.Print("%s %s: failed to fetch code scanning alerts: %v\n", cli.WarningStyle.Render(">>"), target, err)
fetchErrors++
}
if err == nil {
for _, alert := range codeAlerts {
for _, alert := range codeScanningAlerts {
if alert.State != "open" {
continue
}
@ -100,13 +100,13 @@ func createJobForTarget(target string) error {
}
}
depAlerts, err := fetchDependabotAlerts(target)
dependabotAlerts, err := fetchDependabotAlerts(target)
if err != nil {
cli.Print("%s %s: failed to fetch dependabot alerts: %v\n", cli.WarningStyle.Render(">>"), target, err)
fetchErrors++
}
if err == nil {
for _, alert := range depAlerts {
for _, alert := range dependabotAlerts {
if alert.State != "open" {
continue
}
@ -117,13 +117,13 @@ func createJobForTarget(target string) error {
}
}
secretAlerts, err := fetchSecretScanningAlerts(target)
secretScanningAlerts, err := fetchSecretScanningAlerts(target)
if err != nil {
cli.Print("%s %s: failed to fetch secret scanning alerts: %v\n", cli.WarningStyle.Render(">>"), target, err)
fetchErrors++
}
if err == nil {
for _, alert := range secretAlerts {
for _, alert := range secretScanningAlerts {
if alert.State != "open" {
continue
}
@ -142,12 +142,12 @@ func createJobForTarget(target string) error {
}
title := fmt.Sprintf("Security scan: %s", target)
body := buildJobIssueBody(target, summary, findings)
issueBody := buildJobIssueBody(target, summary, findings)
for i := range jobsCopies {
for copyIndex := range jobsCopies {
issueTitle := title
if jobsCopies > 1 {
issueTitle = fmt.Sprintf("%s (#%d)", title, i+1)
issueTitle = fmt.Sprintf("%s (#%d)", title, copyIndex+1)
}
if jobsDryRun {
@ -159,14 +159,14 @@ func createJobForTarget(target string) error {
continue
}
cmd := exec.Command("gh", "issue", "create",
issueCommand := exec.Command("gh", "issue", "create",
"--repo", jobsIssueRepo,
"--title", issueTitle,
"--body", body,
"--body", issueBody,
"--label", "type:security-scan,repo:"+target,
)
output, err := cmd.CombinedOutput()
output, err := issueCommand.CombinedOutput()
if err != nil {
return cli.Wrap(err, fmt.Sprintf("create issue for %s: %s", target, string(output)))
}
@ -195,34 +195,34 @@ func createJobForTarget(target string) error {
//
// body := buildJobIssueBody("wailsapp/wails", summary, findings)
func buildJobIssueBody(target string, summary *AlertSummary, findings []string) string {
var builder strings.Builder
var issueBodyBuilder strings.Builder
fmt.Fprintf(&builder, "## Security Scan: %s\n\n", target)
fmt.Fprintf(&builder, "**Summary:** %s\n\n", summary.String())
fmt.Fprintf(&issueBodyBuilder, "## Security Scan: %s\n\n", target)
fmt.Fprintf(&issueBodyBuilder, "**Summary:** %s\n\n", summary.String())
builder.WriteString("### Findings\n\n")
issueBodyBuilder.WriteString("### Findings\n\n")
if len(findings) > 50 {
for _, finding := range findings[:50] {
builder.WriteString(finding + "\n")
issueBodyBuilder.WriteString(finding + "\n")
}
fmt.Fprintf(&builder, "\n... and %d more\n", len(findings)-50)
fmt.Fprintf(&issueBodyBuilder, "\n... and %d more\n", len(findings)-50)
} else {
for _, finding := range findings {
builder.WriteString(finding + "\n")
issueBodyBuilder.WriteString(finding + "\n")
}
}
builder.WriteString("\n### Checklist\n\n")
builder.WriteString("- [ ] Review findings above\n")
builder.WriteString("- [ ] Triage by severity (critical/high first)\n")
builder.WriteString("- [ ] Create PRs for fixes\n")
builder.WriteString("- [ ] Verify fixes resolve alerts\n")
issueBodyBuilder.WriteString("\n### Checklist\n\n")
issueBodyBuilder.WriteString("- [ ] Review findings above\n")
issueBodyBuilder.WriteString("- [ ] Triage by severity (critical/high first)\n")
issueBodyBuilder.WriteString("- [ ] Create PRs for fixes\n")
issueBodyBuilder.WriteString("- [ ] Verify fixes resolve alerts\n")
builder.WriteString("\n### Instructions\n\n")
builder.WriteString("1. Claim this issue by assigning yourself\n")
fmt.Fprintf(&builder, "2. Run `core security alerts --target %s` for the latest findings\n", target)
builder.WriteString("3. Work through the checklist above\n")
builder.WriteString("4. Close this issue when all findings are addressed\n")
issueBodyBuilder.WriteString("\n### Instructions\n\n")
issueBodyBuilder.WriteString("1. Claim this issue by assigning yourself\n")
fmt.Fprintf(&issueBodyBuilder, "2. Run `core security alerts --target %s` for the latest findings\n", target)
issueBodyBuilder.WriteString("3. Work through the checklist above\n")
issueBodyBuilder.WriteString("4. Close this issue when all findings are addressed\n")
return builder.String()
return issueBodyBuilder.String()
}

View file

@ -6,8 +6,8 @@ import (
"time"
"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 (
@ -15,26 +15,28 @@ var (
)
func addScanCommand(parent *cli.Command) {
cmd := &cli.Command{
command := &cli.Command{
Use: "scan",
Short: i18n.T("cmd.security.scan.short"),
Long: i18n.T("cmd.security.scan.long"),
RunE: func(c *cli.Command, args []string) error {
RunE: func(_ *cli.Command, _ []string) error {
return runScan()
},
}
cmd.Flags().StringVar(&securityRegistryPath, "registry", "", i18n.T("common.flag.registry"))
cmd.Flags().StringVar(&securityRepo, "repo", "", i18n.T("cmd.security.flag.repo"))
cmd.Flags().StringVar(&securitySeverity, "severity", "", i18n.T("cmd.security.flag.severity"))
cmd.Flags().StringVar(&scanTool, "tool", "", i18n.T("cmd.security.scan.flag.tool"))
cmd.Flags().BoolVar(&securityJSON, "json", false, i18n.T("common.flag.json"))
cmd.Flags().StringVar(&securityTarget, "target", "", i18n.T("cmd.security.flag.target"))
command.Flags().StringVar(&securityRegistryPath, "registry", "", i18n.T("common.flag.registry"))
command.Flags().StringVar(&securityRepo, "repo", "", i18n.T("cmd.security.flag.repo"))
command.Flags().StringVar(&securitySeverity, "severity", "", i18n.T("cmd.security.flag.severity"))
command.Flags().StringVar(&scanTool, "tool", "", i18n.T("cmd.security.scan.flag.tool"))
command.Flags().BoolVar(&securityJSON, "json", false, i18n.T("common.flag.json"))
command.Flags().StringVar(&securityTarget, "target", "", i18n.T("cmd.security.flag.target"))
parent.AddCommand(cmd)
parent.AddCommand(command)
}
// ScanAlert is a code scanning alert entry written to JSON or the terminal.
// ScanAlert is the code-scanning row used by `core security scan`.
//
// ScanAlert{Repo: "core-php", RuleID: "go/unused", Path: "main.go", Line: 42}
type ScanAlert struct {
Repo string `json:"repo"`
Severity string `json:"severity"`
@ -55,12 +57,12 @@ func runScan() error {
return runScanForTarget(securityTarget)
}
reg, err := loadRegistry(securityRegistryPath)
registry, err := loadRegistry(securityRegistryPath)
if err != nil {
return err
}
repoList := getReposToCheck(reg, securityRepo)
repoList := getReposToCheck(registry, securityRepo)
if len(repoList) == 0 {
return cli.Err("repo not found: %s", securityRepo)
}
@ -69,15 +71,15 @@ func runScan() error {
summary := &AlertSummary{}
for _, repo := range repoList {
repoFullName := fmt.Sprintf("%s/%s", reg.Org, repo.Name)
repoFullName := fmt.Sprintf("%s/%s", registry.Org, repo.Name)
alerts, err := fetchCodeScanningAlerts(repoFullName)
codeScanningAlerts, err := fetchCodeScanningAlerts(repoFullName)
if err != nil {
cli.Print("%s %s: %v\n", cli.WarningStyle.Render(">>"), repoFullName, err)
continue
}
for _, alert := range alerts {
for _, alert := range codeScanningAlerts {
if alert.State != "open" {
continue
}
@ -141,13 +143,13 @@ func runScan() error {
}
for _, alert := range allAlerts {
sevStyle := severityStyle(alert.Severity)
severityRenderer := severityStyle(alert.Severity)
location := fmt.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)),
severityRenderer.Render(fmt.Sprintf("%-8s", alert.Severity)),
alert.RuleID,
location,
cli.DimStyle.Render(alert.Tool),
@ -170,12 +172,12 @@ func runScanForTarget(target string) error {
var allAlerts []ScanAlert
summary := &AlertSummary{}
alerts, err := fetchCodeScanningAlerts(fullName)
codeScanningAlerts, err := fetchCodeScanningAlerts(fullName)
if err != nil {
return cli.Wrap(err, "fetch code-scanning alerts for "+fullName)
}
for _, alert := range alerts {
for _, alert := range codeScanningAlerts {
if alert.State != "open" {
continue
}
@ -234,11 +236,11 @@ func runScanForTarget(target string) error {
}
for _, alert := range allAlerts {
sevStyle := severityStyle(alert.Severity)
severityRenderer := severityStyle(alert.Severity)
location := fmt.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)),
severityRenderer.Render(fmt.Sprintf("%-8s", alert.Severity)),
alert.RuleID,
location,
cli.DimStyle.Render(alert.Tool),

View file

@ -4,29 +4,31 @@ import (
"encoding/json"
"fmt"
"forge.lthn.ai/core/cli/pkg/cli"
"dappco.re/go/core/i18n"
"forge.lthn.ai/core/cli/pkg/cli"
)
func addSecretsCommand(parent *cli.Command) {
cmd := &cli.Command{
command := &cli.Command{
Use: "secrets",
Short: i18n.T("cmd.security.secrets.short"),
Long: i18n.T("cmd.security.secrets.long"),
RunE: func(c *cli.Command, args []string) error {
RunE: func(_ *cli.Command, _ []string) error {
return runSecrets()
},
}
cmd.Flags().StringVar(&securityRegistryPath, "registry", "", i18n.T("common.flag.registry"))
cmd.Flags().StringVar(&securityRepo, "repo", "", i18n.T("cmd.security.flag.repo"))
cmd.Flags().BoolVar(&securityJSON, "json", false, i18n.T("common.flag.json"))
cmd.Flags().StringVar(&securityTarget, "target", "", i18n.T("cmd.security.flag.target"))
command.Flags().StringVar(&securityRegistryPath, "registry", "", i18n.T("common.flag.registry"))
command.Flags().StringVar(&securityRepo, "repo", "", i18n.T("cmd.security.flag.repo"))
command.Flags().BoolVar(&securityJSON, "json", false, i18n.T("common.flag.json"))
command.Flags().StringVar(&securityTarget, "target", "", i18n.T("cmd.security.flag.target"))
parent.AddCommand(cmd)
parent.AddCommand(command)
}
// SecretAlert is a secret scanning alert entry written to JSON or the terminal.
// SecretAlert is the secret-scanning row used by `core security secrets`.
//
// SecretAlert{Repo: "core-php", Number: 42, SecretType: "github_token"}
type SecretAlert struct {
Repo string `json:"repo"`
Number int `json:"number"`
@ -45,12 +47,12 @@ func runSecrets() error {
return runSecretsForTarget(securityTarget)
}
reg, err := loadRegistry(securityRegistryPath)
registry, err := loadRegistry(securityRegistryPath)
if err != nil {
return err
}
repoList := getReposToCheck(reg, securityRepo)
repoList := getReposToCheck(registry, securityRepo)
if len(repoList) == 0 {
return cli.Err("repo not found: %s", securityRepo)
}
@ -59,19 +61,18 @@ func runSecrets() error {
openCount := 0
for _, repo := range repoList {
repoFullName := fmt.Sprintf("%s/%s", reg.Org, repo.Name)
repoFullName := fmt.Sprintf("%s/%s", registry.Org, repo.Name)
alerts, err := fetchSecretScanningAlerts(repoFullName)
secretScanningAlerts, err := fetchSecretScanningAlerts(repoFullName)
if err != nil {
continue
}
for _, alert := range alerts {
for _, alert := range secretScanningAlerts {
if alert.State != "open" {
continue
}
openCount++
secretAlert := SecretAlert{
Repo: repo.Name,
Number: alert.Number,
@ -135,12 +136,12 @@ func runSecretsForTarget(target string) error {
var allAlerts []SecretAlert
openCount := 0
alerts, err := fetchSecretScanningAlerts(fullName)
secretScanningAlerts, err := fetchSecretScanningAlerts(fullName)
if err != nil {
return cli.Wrap(err, "fetch secret-scanning alerts for "+fullName)
}
for _, alert := range alerts {
for _, alert := range secretScanningAlerts {
if alert.State != "open" {
continue
}

View file

@ -6,11 +6,11 @@ import (
"slices"
"strings"
"forge.lthn.ai/core/cli/pkg/cli"
"dappco.re/go/core/i18n"
"dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
"dappco.re/go/core/scm/repos"
"forge.lthn.ai/core/cli/pkg/cli"
)
var (
@ -25,22 +25,24 @@ var (
//
// security.AddSecurityCommands(rootCmd) // → core security alerts|deps|scan|secrets|jobs
func AddSecurityCommands(root *cli.Command) {
secCmd := &cli.Command{
command := &cli.Command{
Use: "security",
Short: i18n.T("cmd.security.short"),
Long: i18n.T("cmd.security.long"),
}
addAlertsCommand(secCmd)
addDepsCommand(secCmd)
addScanCommand(secCmd)
addSecretsCommand(secCmd)
addJobsCommand(secCmd)
addAlertsCommand(command)
addDepsCommand(command)
addScanCommand(command)
addSecretsCommand(command)
addJobsCommand(command)
root.AddCommand(secCmd)
root.AddCommand(command)
}
// DependabotAlert is a GitHub Dependabot vulnerability alert (from repos/{org}/{repo}/dependabot/alerts).
// DependabotAlert maps one response item from GitHub's dependabot alerts API.
//
// alerts, _ := fetchDependabotAlerts("host-uk/core-php")
type DependabotAlert struct {
Number int `json:"number"`
State string `json:"state"`
@ -69,7 +71,9 @@ type DependabotAlert struct {
} `json:"security_vulnerability"`
}
// CodeScanningAlert is a GitHub code scanning alert (from repos/{org}/{repo}/code-scanning/alerts).
// CodeScanningAlert maps one response item from GitHub's code-scanning alerts API.
//
// alerts, _ := fetchCodeScanningAlerts("host-uk/core-php")
type CodeScanningAlert struct {
Number int `json:"number"`
State string `json:"state"`
@ -96,7 +100,9 @@ type CodeScanningAlert struct {
} `json:"most_recent_instance"`
}
// SecretScanningAlert is a GitHub secret scanning alert (from repos/{org}/{repo}/secret-scanning/alerts).
// SecretScanningAlert maps one response item from GitHub's secret-scanning alerts API.
//
// alerts, _ := fetchSecretScanningAlerts("host-uk/core-php")
type SecretScanningAlert struct {
Number int `json:"number"`
State string `json:"state"`
@ -112,22 +118,22 @@ type SecretScanningAlert struct {
// loadRegistry("/path/to/repos.yaml")
func loadRegistry(registryPath string) (*repos.Registry, error) {
if registryPath != "" {
reg, err := repos.LoadRegistry(io.Local, registryPath)
registry, err := repos.LoadRegistry(io.Local, registryPath)
if err != nil {
return nil, cli.Wrap(err, "load registry")
}
return reg, nil
return registry, nil
}
path, err := repos.FindRegistry(io.Local)
if err != nil {
return nil, cli.Wrap(err, "find registry")
}
reg, err := repos.LoadRegistry(io.Local, path)
registry, err := repos.LoadRegistry(io.Local, path)
if err != nil {
return nil, cli.Wrap(err, "load registry")
}
return reg, nil
return registry, nil
}
// checkGH returns an error if the gh CLI is not on PATH.
@ -144,8 +150,8 @@ func checkGH() error {
//
// runGHAPI("repos/host-uk/core-php/dependabot/alerts?state=open")
func runGHAPI(endpoint string) ([]byte, error) {
cmd := exec.Command("gh", "api", endpoint, "--paginate")
output, err := cmd.Output()
ghCommand := exec.Command("gh", "api", endpoint, "--paginate")
output, err := ghCommand.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
stderr := string(exitErr.Stderr)
@ -196,34 +202,34 @@ func filterBySeverity(severity, filter string) bool {
// getReposToCheck returns a single repo when repoFilter is set, or all repos in the registry.
//
// getReposToCheck(reg, "core-php") // → [core-php]
// getReposToCheck(reg, "") // → all repos
func getReposToCheck(reg *repos.Registry, repoFilter string) []*repos.Repo {
// getReposToCheck(registry, "core-php") // → [core-php]
// getReposToCheck(registry, "") // → all repos
func getReposToCheck(registry *repos.Registry, repoFilter string) []*repos.Repo {
if repoFilter != "" {
if repo, ok := reg.Get(repoFilter); ok {
return []*repos.Repo{repo}
if matchedRepo, ok := registry.Get(repoFilter); ok {
return []*repos.Repo{matchedRepo}
}
return nil
}
return reg.List()
return registry.List()
}
// buildTargetRepo parses an owner/repo target string into a Repo and its full name.
//
// buildTargetRepo("wailsapp/wails") // → &Repo{Name:"wails"}, "wailsapp/wails"
func buildTargetRepo(target string) (*repos.Repo, string) {
parts := strings.SplitN(target, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
targetParts := strings.SplitN(target, "/", 2)
if len(targetParts) != 2 || targetParts[0] == "" || targetParts[1] == "" {
return nil, ""
}
return &repos.Repo{Name: parts[1]}, target
return &repos.Repo{Name: targetParts[1]}, target
}
// AlertSummary tracks alert counts by severity across a scan run.
//
// s := &AlertSummary{}
// s.Add("critical")
// s.String() // → "1 critical"
// summary := &AlertSummary{}
// summary.Add("critical")
// summary.String() // → "1 critical"
type AlertSummary struct {
Critical int
High int
@ -235,45 +241,45 @@ type AlertSummary struct {
// Add increments the counter for the given severity level.
//
// s.Add("critical") // s.Critical == 1, s.Total == 1
func (s *AlertSummary) Add(severity string) {
s.Total++
// summary.Add("critical") // summary.Critical == 1, summary.Total == 1
func (summary *AlertSummary) Add(severity string) {
summary.Total++
switch strings.ToLower(severity) {
case "critical":
s.Critical++
summary.Critical++
case "high":
s.High++
summary.High++
case "medium":
s.Medium++
summary.Medium++
case "low":
s.Low++
summary.Low++
default:
s.Unknown++
summary.Unknown++
}
}
// String renders a styled, human-readable summary of alert counts.
//
// (&AlertSummary{Critical: 1, High: 2}).String() // → "1 critical | 2 high"
func (s *AlertSummary) String() string {
parts := []string{}
if s.Critical > 0 {
parts = append(parts, cli.ErrorStyle.Render(fmt.Sprintf("%d critical", s.Critical)))
func (summary *AlertSummary) String() string {
segments := []string{}
if summary.Critical > 0 {
segments = append(segments, cli.ErrorStyle.Render(fmt.Sprintf("%d critical", summary.Critical)))
}
if s.High > 0 {
parts = append(parts, cli.WarningStyle.Render(fmt.Sprintf("%d high", s.High)))
if summary.High > 0 {
segments = append(segments, cli.WarningStyle.Render(fmt.Sprintf("%d high", summary.High)))
}
if s.Medium > 0 {
parts = append(parts, cli.ValueStyle.Render(fmt.Sprintf("%d medium", s.Medium)))
if summary.Medium > 0 {
segments = append(segments, cli.ValueStyle.Render(fmt.Sprintf("%d medium", summary.Medium)))
}
if s.Low > 0 {
parts = append(parts, cli.DimStyle.Render(fmt.Sprintf("%d low", s.Low)))
if summary.Low > 0 {
segments = append(segments, cli.DimStyle.Render(fmt.Sprintf("%d low", summary.Low)))
}
if s.Unknown > 0 {
parts = append(parts, cli.DimStyle.Render(fmt.Sprintf("%d unknown", s.Unknown)))
if summary.Unknown > 0 {
segments = append(segments, cli.DimStyle.Render(fmt.Sprintf("%d unknown", summary.Unknown)))
}
if len(parts) == 0 {
if len(segments) == 0 {
return cli.SuccessStyle.Render("No alerts")
}
return strings.Join(parts, " | ")
return strings.Join(segments, " | ")
}