diff --git a/ai/metrics.go b/ai/metrics.go index 7c067e2..569a16e 100644 --- a/ai/metrics.go +++ b/ai/metrics.go @@ -5,11 +5,11 @@ import ( "cmp" "encoding/json" "os" - "path/filepath" "slices" "sync" "time" + core "dappco.re/go/core" coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" ) @@ -36,14 +36,14 @@ func metricsDir() (string, error) { if err != nil { return "", coreerr.E("ai.metricsDir", "get home directory", err) } - return filepath.Join(home, ".core", "ai", "metrics"), nil + return core.Path(home, ".core", "ai", "metrics"), nil } // 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") + return core.Path(dir, t.Format("2006-01-02")+".jsonl") } // Record appends an event to ~/.core/ai/metrics/YYYY-MM-DD.jsonl. diff --git a/ai/metrics_bench_test.go b/ai/metrics_bench_test.go index 7bffef6..3758474 100644 --- a/ai/metrics_bench_test.go +++ b/ai/metrics_bench_test.go @@ -1,12 +1,11 @@ package ai import ( - "fmt" "os" - "path/filepath" "testing" "time" + core "dappco.re/go/core" coreio "dappco.re/go/core/io" ) @@ -17,7 +16,7 @@ func setupBenchMetricsDir(b *testing.B) string { b.Helper() origHome := os.Getenv("HOME") tmpHome := b.TempDir() - metricsPath := filepath.Join(tmpHome, ".core", "ai", "metrics") + metricsPath := core.Path(tmpHome, ".core", "ai", "metrics") if err := coreio.Local.EnsureDir(metricsPath); err != nil { b.Fatalf("Failed to create metrics dir: %v", err) } @@ -36,10 +35,10 @@ func seedEvents(b *testing.B, count int) { now := time.Now() for eventIndex := range count { event := Event{ - Type: fmt.Sprintf("type-%d", eventIndex%10), + Type: core.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), + AgentID: core.Sprintf("agent-%d", eventIndex%5), + Repo: core.Sprintf("repo-%d", eventIndex%3), Data: map[string]any{"index": eventIndex, "tool": "bench_tool"}, } if err := Record(event); err != nil { @@ -161,7 +160,7 @@ func BenchmarkMetricsRecordAndQuery(b *testing.B) { // Write 10K events for eventIndex := range 10_000 { event := Event{ - Type: fmt.Sprintf("type-%d", eventIndex%10), + Type: core.Sprintf("type-%d", eventIndex%10), Timestamp: now, AgentID: "bench", Repo: "bench-repo", @@ -192,10 +191,10 @@ func TestMetricsBench_RecordAndRead_10K_Good(t *testing.T) { for eventIndex := range eventCount { event := Event{ - Type: fmt.Sprintf("type-%d", eventIndex%10), + Type: core.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), + AgentID: core.Sprintf("agent-%d", eventIndex%5), + Repo: core.Sprintf("repo-%d", eventIndex%3), Data: map[string]any{"index": eventIndex}, } if err := Record(event); err != nil { diff --git a/ai/metrics_test.go b/ai/metrics_test.go index 56d2d5d..30448e0 100644 --- a/ai/metrics_test.go +++ b/ai/metrics_test.go @@ -2,10 +2,10 @@ package ai import ( "os" - "path/filepath" "testing" "time" + core "dappco.re/go/core" coreio "dappco.re/go/core/io" ) @@ -14,7 +14,7 @@ func withTempHome(t *testing.T) { t.Helper() origHome := os.Getenv("HOME") tmpHome := t.TempDir() - metricsPath := filepath.Join(tmpHome, ".core", "ai", "metrics") + metricsPath := core.Path(tmpHome, ".core", "ai", "metrics") if err := coreio.Local.EnsureDir(metricsPath); err != nil { t.Fatalf("create metrics dir: %v", err) } diff --git a/cmd/metrics/cmd.go b/cmd/metrics/cmd.go index b344562..efdf16f 100644 --- a/cmd/metrics/cmd.go +++ b/cmd/metrics/cmd.go @@ -3,11 +3,10 @@ package metrics import ( "encoding/json" - "fmt" "strconv" - "strings" "time" + core "dappco.re/go/core" "dappco.re/go/core/ai/ai" "dappco.re/go/core/i18n" coreerr "dappco.re/go/core/log" @@ -116,19 +115,19 @@ func runMetrics() error { // parseDuration("30m") // → 30 * time.Minute func parseDuration(durationText string) (time.Duration, error) { if len(durationText) < 2 { - return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("invalid duration: %s", durationText), nil) + return 0, coreerr.E("metrics.parseDuration", core.Sprintf("invalid duration: %s", durationText), nil) } unitSuffix := durationText[len(durationText)-1] - countText := strings.TrimSpace(durationText[:len(durationText)-1]) + countText := core.Trim(durationText[:len(durationText)-1]) quantity, err := strconv.Atoi(countText) if err != nil { - return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("invalid duration: %s", durationText), nil) + return 0, coreerr.E("metrics.parseDuration", core.Sprintf("invalid duration: %s", durationText), nil) } if quantity <= 0 { - return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("duration must be positive: %s", durationText), nil) + return 0, coreerr.E("metrics.parseDuration", core.Sprintf("duration must be positive: %s", durationText), nil) } switch unitSuffix { @@ -139,6 +138,6 @@ func parseDuration(durationText string) (time.Duration, error) { case 'm': return time.Duration(quantity) * time.Minute, nil default: - return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("unknown unit %c in duration: %s", unitSuffix, durationText), nil) + return 0, coreerr.E("metrics.parseDuration", core.Sprintf("unknown unit %c in duration: %s", unitSuffix, durationText), nil) } } diff --git a/cmd/security/cmd_alerts.go b/cmd/security/cmd_alerts.go index c3e5b7d..dac409d 100644 --- a/cmd/security/cmd_alerts.go +++ b/cmd/security/cmd_alerts.go @@ -2,8 +2,8 @@ package security import ( "encoding/json" - "fmt" + core "dappco.re/go/core" "dappco.re/go/core/i18n" "forge.lthn.ai/core/cli/pkg/cli" ) @@ -71,7 +71,7 @@ func runAlerts() error { summary := &AlertSummary{} for _, repo := range repoList { - repoFullName := fmt.Sprintf("%s/%s", registry.Org, repo.Name) + repoFullName := core.Sprintf("%s/%s", registry.Org, repo.Name) dependabotAlerts, err := fetchDependabotAlerts(repoFullName) if err == nil { @@ -107,7 +107,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, @@ -132,7 +132,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, }) @@ -165,12 +165,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), - severityRenderer.Render(fmt.Sprintf("%-8s", alert.Severity)), + severityRenderer.Render(core.Sprintf("%-8s", alert.Severity)), alert.ID, location, cli.DimStyle.Render(alert.Type), @@ -227,7 +227,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, @@ -252,7 +252,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, }) @@ -283,11 +283,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), - severityRenderer.Render(fmt.Sprintf("%-8s", alert.Severity)), + severityRenderer.Render(core.Sprintf("%-8s", alert.Severity)), alert.ID, location, cli.DimStyle.Render(alert.Type), @@ -302,15 +302,15 @@ func runAlertsForTarget(target string) error { // // alerts, _ := fetchDependabotAlerts("host-uk/core-php") func fetchDependabotAlerts(repoFullName string) ([]DependabotAlert, error) { - endpoint := fmt.Sprintf("repos/%s/dependabot/alerts?state=open", repoFullName) + endpoint := core.Sprintf("repos/%s/dependabot/alerts?state=open", repoFullName) 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)) + return nil, cli.Wrap(err, core.Sprintf("parse dependabot alerts for %s", repoFullName)) } return alerts, nil } @@ -319,15 +319,15 @@ func fetchDependabotAlerts(repoFullName string) ([]DependabotAlert, error) { // // alerts, _ := fetchCodeScanningAlerts("host-uk/core-php") func fetchCodeScanningAlerts(repoFullName string) ([]CodeScanningAlert, error) { - endpoint := fmt.Sprintf("repos/%s/code-scanning/alerts?state=open", repoFullName) + endpoint := core.Sprintf("repos/%s/code-scanning/alerts?state=open", repoFullName) 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)) + return nil, cli.Wrap(err, core.Sprintf("parse code-scanning alerts for %s", repoFullName)) } return alerts, nil } @@ -336,15 +336,15 @@ func fetchCodeScanningAlerts(repoFullName string) ([]CodeScanningAlert, error) { // // alerts, _ := fetchSecretScanningAlerts("host-uk/core-php") func fetchSecretScanningAlerts(repoFullName string) ([]SecretScanningAlert, error) { - endpoint := fmt.Sprintf("repos/%s/secret-scanning/alerts?state=open", repoFullName) + endpoint := core.Sprintf("repos/%s/secret-scanning/alerts?state=open", repoFullName) 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)) + return nil, cli.Wrap(err, 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 37f8911..317a9aa 100644 --- a/cmd/security/cmd_deps.go +++ b/cmd/security/cmd_deps.go @@ -2,8 +2,8 @@ package security import ( "encoding/json" - "fmt" + core "dappco.re/go/core" "dappco.re/go/core/i18n" "forge.lthn.ai/core/cli/pkg/cli" ) @@ -72,7 +72,7 @@ func runDeps() error { summary := &AlertSummary{} for _, repo := range repoList { - repoFullName := fmt.Sprintf("%s/%s", registry.Org, repo.Name) + repoFullName := core.Sprintf("%s/%s", registry.Org, repo.Name) dependabotAlerts, err := fetchDependabotAlerts(repoFullName) if err != nil { @@ -129,12 +129,12 @@ func runDeps() error { 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), - severityRenderer.Render(fmt.Sprintf("%-8s", alert.Severity)), + severityRenderer.Render(core.Sprintf("%-8s", alert.Severity)), alert.CVE, alert.Package, upgrade, @@ -201,11 +201,11 @@ func runDepsForTarget(target string) error { severityRenderer := 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), - severityRenderer.Render(fmt.Sprintf("%-8s", alert.Severity)), + severityRenderer.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 c839190..7b6b071 100644 --- a/cmd/security/cmd_jobs.go +++ b/cmd/security/cmd_jobs.go @@ -1,11 +1,10 @@ package security import ( - "fmt" "os/exec" - "strings" "time" + core "dappco.re/go/core" "dappco.re/go/core/ai/ai" "dappco.re/go/core/i18n" coreerr "dappco.re/go/core/log" @@ -77,7 +76,7 @@ func runJobs() error { // // createJobForTarget("wailsapp/wails") func createJobForTarget(target string) error { - targetParts := strings.SplitN(target, "/", 2) + targetParts := core.SplitN(target, "/", 2) if len(targetParts) != 2 || targetParts[0] == "" || targetParts[1] == "" { return coreerr.E("security.createJobForTarget", "invalid target format: use owner/repo", nil) } @@ -101,8 +100,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)) } } @@ -118,8 +117,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)) } } @@ -135,12 +134,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 coreerr.E("security.createJobForTarget", core.Sprintf("failed to fetch any alerts for %s", target), nil) } if summary.Total == 0 { @@ -148,13 +147,13 @@ func createJobForTarget(target string) error { return nil } - title := fmt.Sprintf("Security scan: %s", target) + title := core.Sprintf("Security scan: %s", target) issueBody := buildJobIssueBody(target, summary, findings) for copyIndex := range jobsCopies { issueTitle := title if jobsCopies > 1 { - issueTitle = fmt.Sprintf("%s (#%d)", title, copyIndex+1) + issueTitle = core.Sprintf("%s (#%d)", title, copyIndex+1) } if jobsDryRun { @@ -175,10 +174,10 @@ func createJobForTarget(target string) error { output, err := issueCommand.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: %s", target, string(output))) } - issueURL := strings.TrimSpace(string(output)) + issueURL := core.Trim(string(output)) cli.Print("%s %s: %s\n", cli.SuccessStyle.Render(">>"), issueTitle, issueURL) _ = ai.Record(ai.Event{ @@ -202,17 +201,17 @@ func createJobForTarget(target string) error { // // body := buildJobIssueBody("wailsapp/wails", summary, findings) func buildJobIssueBody(target string, summary *AlertSummary, findings []string) string { - var issueBodyBuilder strings.Builder + issueBodyBuilder := core.NewBuilder() - fmt.Fprintf(&issueBodyBuilder, "## Security Scan: %s\n\n", target) - fmt.Fprintf(&issueBodyBuilder, "**Summary:** %s\n\n", summary.String()) + issueBodyBuilder.WriteString(core.Sprintf("## Security Scan: %s\n\n", target)) + issueBodyBuilder.WriteString(core.Sprintf("**Summary:** %s\n\n", summary.String())) issueBodyBuilder.WriteString("### Findings\n\n") if len(findings) > 50 { for _, finding := range findings[:50] { issueBodyBuilder.WriteString(finding + "\n") } - fmt.Fprintf(&issueBodyBuilder, "\n... and %d more\n", len(findings)-50) + issueBodyBuilder.WriteString(core.Sprintf("\n... and %d more\n", len(findings)-50)) } else { for _, finding := range findings { issueBodyBuilder.WriteString(finding + "\n") @@ -227,7 +226,7 @@ func buildJobIssueBody(target string, summary *AlertSummary, findings []string) 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(core.Sprintf("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") diff --git a/cmd/security/cmd_scan.go b/cmd/security/cmd_scan.go index bc81dd1..e5e4e9f 100644 --- a/cmd/security/cmd_scan.go +++ b/cmd/security/cmd_scan.go @@ -2,9 +2,9 @@ package security import ( "encoding/json" - "fmt" "time" + core "dappco.re/go/core" "dappco.re/go/core/ai/ai" "dappco.re/go/core/i18n" "forge.lthn.ai/core/cli/pkg/cli" @@ -78,7 +78,7 @@ func runScan() error { summary := &AlertSummary{} for _, repo := range repoList { - repoFullName := fmt.Sprintf("%s/%s", registry.Org, repo.Name) + repoFullName := core.Sprintf("%s/%s", registry.Org, repo.Name) codeScanningAlerts, err := fetchCodeScanningAlerts(repoFullName) if err != nil { @@ -152,11 +152,11 @@ func runScan() error { for _, alert := range allAlerts { severityRenderer := 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), - severityRenderer.Render(fmt.Sprintf("%-8s", alert.Severity)), + severityRenderer.Render(core.Sprintf("%-8s", alert.Severity)), alert.RuleID, location, cli.DimStyle.Render(alert.Tool), @@ -244,10 +244,10 @@ func runScanForTarget(target string) error { for _, alert := range allAlerts { severityRenderer := 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), - severityRenderer.Render(fmt.Sprintf("%-8s", alert.Severity)), + severityRenderer.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 ddac4c5..eed2167 100644 --- a/cmd/security/cmd_secrets.go +++ b/cmd/security/cmd_secrets.go @@ -2,8 +2,8 @@ package security import ( "encoding/json" - "fmt" + core "dappco.re/go/core" "dappco.re/go/core/i18n" "forge.lthn.ai/core/cli/pkg/cli" ) @@ -68,7 +68,7 @@ func runSecrets() error { openCount := 0 for _, repo := range repoList { - repoFullName := fmt.Sprintf("%s/%s", registry.Org, repo.Name) + repoFullName := core.Sprintf("%s/%s", registry.Org, repo.Name) secretScanningAlerts, err := fetchSecretScanningAlerts(repoFullName) if err != nil { @@ -103,7 +103,7 @@ func runSecrets() error { 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")) } @@ -174,7 +174,7 @@ func runSecretsForTarget(target string) error { 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("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")) } diff --git a/cmd/security/cmd_security.go b/cmd/security/cmd_security.go index 0fa5f21..8d22ace 100644 --- a/cmd/security/cmd_security.go +++ b/cmd/security/cmd_security.go @@ -1,11 +1,10 @@ package security import ( - "fmt" "os/exec" "slices" - "strings" + core "dappco.re/go/core" "dappco.re/go/core/i18n" "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" @@ -155,10 +154,10 @@ func runGHAPI(endpoint string) ([]byte, error) { if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { stderr := string(exitErr.Stderr) - if strings.Contains(stderr, "404") || strings.Contains(stderr, "Not Found") { + if core.Contains(stderr, "404") || core.Contains(stderr, "Not Found") { return []byte("[]"), nil } - if strings.Contains(stderr, "403") { + if core.Contains(stderr, "403") { return nil, coreerr.E("security.runGHAPI", "access denied (check token permissions)", nil) } } @@ -172,7 +171,7 @@ func runGHAPI(endpoint string) ([]byte, error) { // severityStyle("critical") // → cli.ErrorStyle // severityStyle("high") // → cli.WarningStyle func severityStyle(severity string) *cli.AnsiStyle { - switch strings.ToLower(severity) { + switch core.Lower(severity) { case "critical": return cli.ErrorStyle case "high": @@ -194,9 +193,9 @@ func filterBySeverity(severity, filter string) bool { return true } - severityLower := strings.ToLower(severity) - return slices.ContainsFunc(slices.Collect(strings.SplitSeq(strings.ToLower(filter), ",")), func(filterEntry string) bool { - return strings.TrimSpace(filterEntry) == severityLower + severityLower := core.Lower(severity) + return slices.ContainsFunc(core.Split(core.Lower(filter), ","), func(filterEntry string) bool { + return core.Trim(filterEntry) == severityLower }) } @@ -218,7 +217,7 @@ func getReposToCheck(registry *repos.Registry, repoFilter string) []*repos.Repo // // buildTargetRepo("wailsapp/wails") // → &Repo{Name:"wails"}, "wailsapp/wails" func buildTargetRepo(target string) (*repos.Repo, string) { - targetParts := strings.SplitN(target, "/", 2) + targetParts := core.SplitN(target, "/", 2) if len(targetParts) != 2 || targetParts[0] == "" || targetParts[1] == "" { return nil, "" } @@ -244,7 +243,7 @@ type AlertSummary struct { // summary.Add("critical") // summary.Critical == 1, summary.Total == 1 func (summary *AlertSummary) Add(severity string) { summary.Total++ - switch strings.ToLower(severity) { + switch core.Lower(severity) { case "critical": summary.Critical++ case "high": @@ -264,22 +263,22 @@ func (summary *AlertSummary) Add(severity string) { func (summary *AlertSummary) String() string { segments := []string{} if summary.Critical > 0 { - segments = append(segments, cli.ErrorStyle.Render(fmt.Sprintf("%d critical", summary.Critical))) + segments = append(segments, cli.ErrorStyle.Render(core.Sprintf("%d critical", summary.Critical))) } if summary.High > 0 { - segments = append(segments, cli.WarningStyle.Render(fmt.Sprintf("%d high", summary.High))) + segments = append(segments, cli.WarningStyle.Render(core.Sprintf("%d high", summary.High))) } if summary.Medium > 0 { - segments = append(segments, cli.ValueStyle.Render(fmt.Sprintf("%d medium", summary.Medium))) + segments = append(segments, cli.ValueStyle.Render(core.Sprintf("%d medium", summary.Medium))) } if summary.Low > 0 { - segments = append(segments, cli.DimStyle.Render(fmt.Sprintf("%d low", summary.Low))) + segments = append(segments, cli.DimStyle.Render(core.Sprintf("%d low", summary.Low))) } if summary.Unknown > 0 { - segments = append(segments, cli.DimStyle.Render(fmt.Sprintf("%d unknown", summary.Unknown))) + segments = append(segments, cli.DimStyle.Render(core.Sprintf("%d unknown", summary.Unknown))) } if len(segments) == 0 { return cli.SuccessStyle.Render("No alerts") } - return strings.Join(segments, " | ") + return core.Join(" | ", segments...) } diff --git a/cmd/security/cmd_security_test.go b/cmd/security/cmd_security_test.go new file mode 100644 index 0000000..9f34dfd --- /dev/null +++ b/cmd/security/cmd_security_test.go @@ -0,0 +1,273 @@ +package security + +import ( + "testing" + + core "dappco.re/go/core" +) + +// TestAlertSummary_Add_Good verifies that known severity levels increment the correct counter. +func TestAlertSummary_Add_Good(t *testing.T) { + summary := &AlertSummary{} + + summary.Add("critical") + summary.Add("high") + summary.Add("medium") + summary.Add("low") + + if summary.Critical != 1 { + t.Errorf("Critical: want 1, got %d", summary.Critical) + } + if summary.High != 1 { + t.Errorf("High: want 1, got %d", summary.High) + } + if summary.Medium != 1 { + t.Errorf("Medium: want 1, got %d", summary.Medium) + } + if summary.Low != 1 { + t.Errorf("Low: want 1, got %d", summary.Low) + } + if summary.Total != 4 { + t.Errorf("Total: want 4, got %d", summary.Total) + } +} + +// TestAlertSummary_Add_Bad verifies that an unrecognised severity goes to the Unknown bucket. +func TestAlertSummary_Add_Bad(t *testing.T) { + summary := &AlertSummary{} + + summary.Add("informational") + summary.Add("WEIRD") + + if summary.Unknown != 2 { + t.Errorf("Unknown: want 2, got %d", summary.Unknown) + } + if summary.Total != 2 { + t.Errorf("Total: want 2, got %d", summary.Total) + } + if summary.Critical != 0 || summary.High != 0 { + t.Errorf("Critical/High should remain 0 for unknown severities") + } +} + +// TestAlertSummary_Add_Ugly verifies case-insensitive matching — "CRITICAL" and "Critical" both hit Critical. +func TestAlertSummary_Add_Ugly(t *testing.T) { + summary := &AlertSummary{} + + summary.Add("CRITICAL") + summary.Add("Critical") + summary.Add("HIGH") + summary.Add("Medium") + summary.Add("LOW") + + if summary.Critical != 2 { + t.Errorf("Critical: want 2, got %d", summary.Critical) + } + if summary.High != 1 { + t.Errorf("High: want 1, got %d", summary.High) + } + if summary.Medium != 1 { + t.Errorf("Medium: want 1, got %d", summary.Medium) + } + if summary.Low != 1 { + t.Errorf("Low: want 1, got %d", summary.Low) + } + if summary.Total != 5 { + t.Errorf("Total: want 5, got %d", summary.Total) + } +} + +// TestAlertSummaryString_Good verifies that String() renders all non-zero counts. +func TestAlertSummaryString_Good(t *testing.T) { + summary := &AlertSummary{Critical: 1, High: 2, Total: 3} + result := summary.String() + + if !core.Contains(result, "1 critical") { + t.Errorf("expected '1 critical' in %q", result) + } + if !core.Contains(result, "2 high") { + t.Errorf("expected '2 high' in %q", result) + } + if !core.Contains(result, "|") { + t.Errorf("expected separator '|' between segments in %q", result) + } +} + +// TestAlertSummaryString_Bad verifies that an empty AlertSummary returns the "No alerts" message. +func TestAlertSummaryString_Bad(t *testing.T) { + summary := &AlertSummary{} + result := summary.String() + + if !core.Contains(result, "No alerts") { + t.Errorf("expected 'No alerts' for empty summary, got %q", result) + } +} + +// TestAlertSummaryString_Ugly verifies that zero-count severity levels are omitted from the string. +func TestAlertSummaryString_Ugly(t *testing.T) { + // Only medium is set — critical/high/low/unknown should not appear. + summary := &AlertSummary{Medium: 3, Total: 3} + result := summary.String() + + if core.Contains(result, "critical") { + t.Errorf("'critical' should be absent when count is 0, got %q", result) + } + if core.Contains(result, "high") { + t.Errorf("'high' should be absent when count is 0, got %q", result) + } + if !core.Contains(result, "3 medium") { + t.Errorf("expected '3 medium' in %q", result) + } +} + +// TestFilterBySeverity_Good verifies that matching entries pass the filter. +func TestFilterBySeverity_Good(t *testing.T) { + cases := []struct { + severity string + filter string + }{ + {"critical", "critical,high"}, + {"high", "critical,high"}, + {"medium", "medium"}, + {"low", "low,medium,high"}, + // Empty filter passes everything. + {"critical", ""}, + {"anything", ""}, + } + + for _, tc := range cases { + if !filterBySeverity(tc.severity, tc.filter) { + t.Errorf("filterBySeverity(%q, %q): want true, got false", tc.severity, tc.filter) + } + } +} + +// TestFilterBySeverity_Bad verifies that non-matching severities are excluded. +func TestFilterBySeverity_Bad(t *testing.T) { + cases := []struct { + severity string + filter string + }{ + {"low", "critical,high"}, + {"medium", "critical"}, + {"informational", "critical,high,medium,low"}, + } + + for _, tc := range cases { + if filterBySeverity(tc.severity, tc.filter) { + t.Errorf("filterBySeverity(%q, %q): want false, got true", tc.severity, tc.filter) + } + } +} + +// TestFilterBySeverity_Ugly verifies case-insensitive matching and whitespace tolerance. +func TestFilterBySeverity_Ugly(t *testing.T) { + // Severity and filter are uppercased — must still match. + if !filterBySeverity("CRITICAL", "CRITICAL,HIGH") { + t.Error("filterBySeverity should be case-insensitive for severity") + } + if !filterBySeverity("critical", "CRITICAL") { + t.Error("filterBySeverity should be case-insensitive for filter") + } + // Filter entries may have leading/trailing whitespace. + if !filterBySeverity("high", " high , critical ") { + t.Error("filterBySeverity should trim whitespace from filter entries") + } +} + +// TestBuildTargetRepo_Good verifies that a valid owner/repo string parses correctly. +func TestBuildTargetRepo_Good(t *testing.T) { + repo, fullName := buildTargetRepo("wailsapp/wails") + if repo == nil { + t.Fatal("expected non-nil repo") + } + if repo.Name != "wails" { + t.Errorf("Name: want 'wails', got %q", repo.Name) + } + if fullName != "wailsapp/wails" { + t.Errorf("fullName: want 'wailsapp/wails', got %q", fullName) + } +} + +// TestBuildTargetRepo_Bad verifies that malformed targets return nil. +func TestBuildTargetRepo_Bad(t *testing.T) { + badTargets := []string{ + "", // empty + "noslash", // missing separator + "/repo", // empty owner + "owner/", // empty repo + "//double", // double slash + } + + for _, target := range badTargets { + repo, fullName := buildTargetRepo(target) + if repo != nil || fullName != "" { + t.Errorf("buildTargetRepo(%q): expected (nil, ''), got (%v, %q)", target, repo, fullName) + } + } +} + +// TestBuildTargetRepo_Ugly verifies targets with extra path segments — only the first two parts matter. +func TestBuildTargetRepo_Ugly(t *testing.T) { + // SplitN(n=2) means "owner/repo/extra" → ["owner", "repo/extra"] + // repo name becomes "repo/extra" which is non-empty, so it parses. + repo, fullName := buildTargetRepo("owner/repo/extra") + if repo == nil { + t.Fatal("expected non-nil repo for 3-segment path") + } + if repo.Name != "repo/extra" { + t.Errorf("Name: want 'repo/extra', got %q", repo.Name) + } + if fullName != "owner/repo/extra" { + t.Errorf("fullName: want 'owner/repo/extra', got %q", fullName) + } +} + +// TestBuildJobIssueBody_Good verifies the issue body contains all required sections. +func TestBuildJobIssueBody_Good(t *testing.T) { + summary := &AlertSummary{Critical: 1, High: 2, Total: 3} + findings := []string{"- [CRITICAL] tool: description (file.go:10)"} + + body := buildJobIssueBody("wailsapp/wails", summary, findings) + + requiredSections := []string{ + "## Security Scan: wailsapp/wails", + "### Findings", + "### Checklist", + "### Instructions", + "wailsapp/wails", + } + + for _, section := range requiredSections { + if !core.Contains(body, section) { + t.Errorf("body missing section %q", section) + } + } +} + +// TestBuildJobIssueBody_Bad verifies that more than 50 findings are truncated with a count line. +func TestBuildJobIssueBody_Bad(t *testing.T) { + summary := &AlertSummary{Total: 60} + findings := make([]string, 60) + for i := range 60 { + findings[i] = "- [HIGH] finding" + } + + body := buildJobIssueBody("org/repo", summary, findings) + + if !core.Contains(body, "... and 10 more") { + t.Errorf("expected truncation message '... and 10 more' in body, got:\n%s", body) + } +} + +// TestBuildJobIssueBody_Ugly verifies that empty findings still produces a valid body with all sections. +func TestBuildJobIssueBody_Ugly(t *testing.T) { + summary := &AlertSummary{} + body := buildJobIssueBody("org/empty-repo", summary, nil) + + for _, section := range []string{"### Findings", "### Checklist", "### Instructions"} { + if !core.Contains(body, section) { + t.Errorf("empty findings body missing section %q", section) + } + } +}