Compare commits

..

No commits in common. "dev" and "agent/read---spec-code-core-lint-rfc-md-fully" have entirely different histories.

26 changed files with 60 additions and 1051 deletions

BIN
.DS_Store vendored

Binary file not shown.

View file

View file

@ -9,10 +9,10 @@ import (
"sort" "sort"
"strings" "strings"
cataloglint "dappco.re/go/core/lint"
lintpkg "dappco.re/go/core/lint/pkg/lint"
"forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
coreerr "forge.lthn.ai/core/go-log" coreerr "forge.lthn.ai/core/go-log"
cataloglint "forge.lthn.ai/core/lint"
lintpkg "forge.lthn.ai/core/lint/pkg/lint"
) )
func main() { func main() {
@ -265,18 +265,14 @@ func newCheckCommand() *cli.Command {
if language != "" { if language != "" {
rules = catalog.ForLanguage(language) rules = catalog.ForLanguage(language)
if len(rules) == 0 { if len(rules) == 0 {
if _, err := fmt.Fprintf(os.Stderr, "no rules for language %q\n", language); err != nil { fmt.Fprintf(os.Stderr, "no rules for language %q\n", language)
return err
}
return nil return nil
} }
} }
if severity != "" { if severity != "" {
filtered := (&lintpkg.Catalog{Rules: rules}).AtSeverity(severity) filtered := (&lintpkg.Catalog{Rules: rules}).AtSeverity(severity)
if len(filtered) == 0 { if len(filtered) == 0 {
if _, err := fmt.Fprintf(os.Stderr, "no rules at severity %q or above\n", severity); err != nil { fmt.Fprintf(os.Stderr, "no rules at severity %q or above\n", severity)
return err
}
return nil return nil
} }
rules = filtered rules = filtered
@ -327,13 +323,9 @@ func newCheckCommand() *cli.Command {
} }
return lintpkg.WriteReportSARIF(command.OutOrStdout(), report) return lintpkg.WriteReportSARIF(command.OutOrStdout(), report)
default: default:
if err := lintpkg.WriteText(command.OutOrStdout(), findings); err != nil { lintpkg.WriteText(command.OutOrStdout(), findings)
return err
}
if format == "text" && len(findings) > 0 { if format == "text" && len(findings) > 0 {
if err := writeCatalogSummary(command.OutOrStdout(), findings); err != nil { writeCatalogSummary(command.OutOrStdout(), findings)
return err
}
} }
return nil return nil
} }
@ -376,9 +368,7 @@ func newCatalogCommand() *cli.Command {
for _, rule := range rules { for _, rule := range rules {
fmt.Fprintf(command.OutOrStdout(), "%-14s [%-8s] %s\n", rule.ID, rule.Severity, rule.Title) fmt.Fprintf(command.OutOrStdout(), "%-14s [%-8s] %s\n", rule.ID, rule.Severity, rule.Title)
} }
if _, err := fmt.Fprintf(os.Stderr, "\n%d rule(s)\n", len(rules)); err != nil { fmt.Fprintf(os.Stderr, "\n%d rule(s)\n", len(rules))
return err
}
return nil return nil
}) })
cli.StringFlag(listCmd, &listLanguage, "lang", "l", "", "Filter by language") cli.StringFlag(listCmd, &listLanguage, "lang", "l", "", "Filter by language")
@ -415,9 +405,11 @@ func writeReport(writer io.Writer, output string, report lintpkg.Report) error {
case "json": case "json":
return lintpkg.WriteReportJSON(writer, report) return lintpkg.WriteReportJSON(writer, report)
case "text": case "text":
return lintpkg.WriteReportText(writer, report) lintpkg.WriteReportText(writer, report)
return nil
case "github": case "github":
return lintpkg.WriteReportGitHub(writer, report) lintpkg.WriteReportGitHub(writer, report)
return nil
case "sarif": case "sarif":
return lintpkg.WriteReportSARIF(writer, report) return lintpkg.WriteReportSARIF(writer, report)
default: default:
@ -431,11 +423,9 @@ func writeIndentedJSON(writer io.Writer, value any) error {
return encoder.Encode(value) return encoder.Encode(value)
} }
func writeCatalogSummary(writer io.Writer, findings []lintpkg.Finding) error { func writeCatalogSummary(writer io.Writer, findings []lintpkg.Finding) {
summary := lintpkg.Summarise(findings) summary := lintpkg.Summarise(findings)
if _, err := fmt.Fprintf(writer, "\n%d finding(s)", summary.Total); err != nil { fmt.Fprintf(writer, "\n%d finding(s)", summary.Total)
return err
}
orderedSeverities := []string{"critical", "high", "medium", "low", "info", "error", "warning"} orderedSeverities := []string{"critical", "high", "medium", "low", "info", "error", "warning"}
seen := make(map[string]bool, len(summary.BySeverity)) seen := make(map[string]bool, len(summary.BySeverity))
@ -467,12 +457,7 @@ func writeCatalogSummary(writer io.Writer, findings []lintpkg.Finding) error {
} }
if len(parts) > 0 { if len(parts) > 0 {
if _, err := fmt.Fprintf(writer, " (%s)", strings.Join(parts, ", ")); err != nil { fmt.Fprintf(writer, " (%s)", strings.Join(parts, ", "))
return err
} }
} fmt.Fprintln(writer)
if _, err := fmt.Fprintln(writer); err != nil {
return err
}
return nil
} }

View file

@ -10,7 +10,7 @@ import (
"sync" "sync"
"testing" "testing"
lintpkg "dappco.re/go/core/lint/pkg/lint" lintpkg "forge.lthn.ai/core/lint/pkg/lint"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -44,24 +44,9 @@ func Run() {
var report lintpkg.Report var report lintpkg.Report
require.NoError(t, json.Unmarshal([]byte(stdout), &report)) require.NoError(t, json.Unmarshal([]byte(stdout), &report))
require.NotEmpty(t, report.Findings) require.Len(t, report.Findings, 1)
assert.GreaterOrEqual(t, report.Summary.Total, 2) assert.Equal(t, "go-cor-003", report.Findings[0].Code)
assert.Greater(t, report.Summary.Info, 0) assert.Equal(t, 1, report.Summary.Total)
assert.Contains(t, report.Summary.BySeverity, "info")
assert.Contains(t, report.Summary.BySeverity, "warning")
var hasCatalogFinding bool
var hasMissingToolFinding bool
for _, finding := range report.Findings {
switch finding.Code {
case "go-cor-003":
hasCatalogFinding = true
case "missing-tool":
hasMissingToolFinding = true
}
}
assert.True(t, hasCatalogFinding)
assert.True(t, hasMissingToolFinding)
assert.False(t, report.Summary.Passed) assert.False(t, report.Summary.Passed)
} }
@ -89,13 +74,8 @@ func helper() error { return nil }
var report lintpkg.Report var report lintpkg.Report
require.NoError(t, json.Unmarshal([]byte(stdout), &report)) require.NoError(t, json.Unmarshal([]byte(stdout), &report))
require.NotEmpty(t, report.Findings) assert.Empty(t, report.Findings)
assert.Equal(t, 6, report.Summary.Total) assert.Equal(t, 0, report.Summary.Total)
assert.Equal(t, 6, report.Summary.Info)
for _, finding := range report.Findings {
assert.Equal(t, "missing-tool", finding.Code)
assert.Equal(t, "info", finding.Severity)
}
assert.True(t, report.Summary.Passed) assert.True(t, report.Summary.Passed)
} }
@ -135,13 +115,8 @@ schedules:
var report lintpkg.Report var report lintpkg.Report
require.NoError(t, json.Unmarshal([]byte(stdout), &report)) require.NoError(t, json.Unmarshal([]byte(stdout), &report))
require.NotEmpty(t, report.Findings) assert.Empty(t, report.Findings)
assert.Equal(t, 6, report.Summary.Total) assert.Equal(t, 0, report.Summary.Total)
assert.Equal(t, 6, report.Summary.Info)
for _, finding := range report.Findings {
assert.Equal(t, "missing-tool", finding.Code)
assert.Equal(t, "info", finding.Severity)
}
assert.True(t, report.Summary.Passed) assert.True(t, report.Summary.Passed)
} }

View file

@ -21,8 +21,8 @@ import (
"strings" "strings"
"forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"dappco.re/go/core/lint/pkg/detect" "forge.lthn.ai/core/lint/pkg/detect"
"dappco.re/go/core/lint/pkg/php" "forge.lthn.ai/core/lint/pkg/php"
) )
// Severity styles for security output. // Severity styles for security output.

View file

@ -13,7 +13,7 @@ package qa
import ( import (
"forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n" "forge.lthn.ai/core/go-i18n"
"dappco.re/go/core/lint/locales" "forge.lthn.ai/core/lint/locales"
) )
func init() { func init() {

4
go.mod
View file

@ -1,4 +1,4 @@
module dappco.re/go/core/lint module forge.lthn.ai/core/lint
go 1.26.0 go 1.26.0
@ -15,7 +15,7 @@ require (
require ( require (
forge.lthn.ai/core/go v0.3.3 // indirect forge.lthn.ai/core/go v0.3.3 // indirect
forge.lthn.ai/core/go-inference v0.1.6 // indirect forge.lthn.ai/core/go-inference v0.1.7 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect

4
go.sum
View file

@ -4,8 +4,8 @@ forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0=
forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ= forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ=
forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA= forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA=
forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8= forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8=
forge.lthn.ai/core/go-inference v0.1.6 h1:ce42zC0zO8PuISUyAukAN1NACEdWp5wF1mRgnh5+58E= forge.lthn.ai/core/go-inference v0.1.7 h1:9Dy6v03jX5ZRH3n5iTzlYyGtucuBIgSe+S7GWvBzx9Q=
forge.lthn.ai/core/go-inference v0.1.6/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw= forge.lthn.ai/core/go-inference v0.1.7/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
forge.lthn.ai/core/go-io v0.1.7 h1:Tdb6sqh+zz1lsGJaNX9RFWM6MJ/RhSAyxfulLXrJsbk= forge.lthn.ai/core/go-io v0.1.7 h1:Tdb6sqh+zz1lsGJaNX9RFWM6MJ/RhSAyxfulLXrJsbk=
forge.lthn.ai/core/go-io v0.1.7/go.mod h1:8lRLFk4Dnp5cR/Cyzh9WclD5566TbpdRgwcH7UZLWn4= forge.lthn.ai/core/go-io v0.1.7/go.mod h1:8lRLFk4Dnp5cR/Cyzh9WclD5566TbpdRgwcH7UZLWn4=
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=

View file

@ -1,5 +0,0 @@
go 1.26.2
use (
.
)

View file

@ -4,7 +4,7 @@ package lint
import ( import (
"embed" "embed"
lintpkg "dappco.re/go/core/lint/pkg/lint" lintpkg "forge.lthn.ai/core/lint/pkg/lint"
) )
//go:embed catalog/*.yaml //go:embed catalog/*.yaml

View file

@ -5,7 +5,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@ -167,18 +166,6 @@ func (adapter CommandAdapter) Run(ctx context.Context, input RunInput, files []s
if !ok { if !ok {
result.Tool.Status = "skipped" result.Tool.Status = "skipped"
result.Tool.Duration = "0s" result.Tool.Duration = "0s"
missingName := firstNonEmpty(adapter.Command(), adapter.name)
if missingName == "" {
missingName = adapter.name
}
result.Findings = []Finding{{
Tool: adapter.name,
Severity: "info",
Code: "missing-tool",
Message: fmt.Sprintf("%s is not installed", missingName),
Category: adapter.category,
}}
result.Tool.Findings = len(result.Findings)
return result return result
} }
@ -189,8 +176,6 @@ func (adapter CommandAdapter) Run(ctx context.Context, input RunInput, files []s
args := adapter.buildArgs(input.Path, files) args := adapter.buildArgs(input.Path, files)
stdout, stderr, exitCode, runErr := runCommand(runContext, input.Path, binary, args) stdout, stderr, exitCode, runErr := runCommand(runContext, input.Path, binary, args)
stdout = strings.TrimSpace(stdout)
stderr = strings.TrimSpace(stderr)
result.Tool.Duration = time.Since(startedAt).Round(time.Millisecond).String() result.Tool.Duration = time.Since(startedAt).Round(time.Millisecond).String()
@ -199,30 +184,19 @@ func (adapter CommandAdapter) Run(ctx context.Context, input RunInput, files []s
return result return result
} }
if err := runContext.Err(); err != nil { output := strings.TrimSpace(stdout)
if errors.Is(err, context.DeadlineExceeded) { if strings.TrimSpace(stderr) != "" {
result.Tool.Status = "timeout" if output != "" {
output += "\n" + strings.TrimSpace(stderr)
} else { } else {
result.Tool.Status = "canceled" output = strings.TrimSpace(stderr)
} }
return result
} }
if adapter.parseOutput != nil { if adapter.parseOutput != nil && output != "" {
if stdout != "" { result.Findings = adapter.parseOutput(adapter.name, adapter.category, output)
result.Findings = append(result.Findings, adapter.parseOutput(adapter.name, adapter.category, stdout)...)
}
if len(result.Findings) == 0 && stderr != "" {
result.Findings = append(result.Findings, adapter.parseOutput(adapter.name, adapter.category, stderr)...)
}
}
if len(result.Findings) == 0 && (stdout != "" || stderr != "") {
output := stdout
if output != "" && stderr != "" {
output += "\n" + stderr
} else if output == "" {
output = stderr
} }
if len(result.Findings) == 0 && output != "" {
result.Findings = parseTextDiagnostics(adapter.name, adapter.category, output) result.Findings = parseTextDiagnostics(adapter.name, adapter.category, output)
} }
if len(result.Findings) == 0 && runErr != nil { if len(result.Findings) == 0 && runErr != nil {
@ -230,7 +204,7 @@ func (adapter CommandAdapter) Run(ctx context.Context, input RunInput, files []s
Tool: adapter.name, Tool: adapter.name,
Severity: defaultSeverityForCategory(adapter.category), Severity: defaultSeverityForCategory(adapter.category),
Code: "command-failed", Code: "command-failed",
Message: strings.TrimSpace(firstNonEmpty(stdout, stderr, runErr.Error())), Message: strings.TrimSpace(firstNonEmpty(output, runErr.Error())),
Category: adapter.category, Category: adapter.category,
}} }}
} }
@ -313,7 +287,7 @@ func (CatalogAdapter) Category() string { return "correctness" }
func (CatalogAdapter) Fast() bool { return true } func (CatalogAdapter) Fast() bool { return true }
func (CatalogAdapter) Run(ctx context.Context, input RunInput, files []string) AdapterResult { func (CatalogAdapter) Run(_ context.Context, input RunInput, files []string) AdapterResult {
startedAt := time.Now() startedAt := time.Now()
result := AdapterResult{ result := AdapterResult{
Tool: ToolRun{ Tool: ToolRun{
@ -359,9 +333,6 @@ func (CatalogAdapter) Run(ctx context.Context, input RunInput, files []string) A
var findings []Finding var findings []Finding
if len(files) > 0 { if len(files) > 0 {
for _, file := range files { for _, file := range files {
if err := ctx.Err(); err != nil {
break
}
scanPath := file scanPath := file
if !filepath.IsAbs(scanPath) { if !filepath.IsAbs(scanPath) {
scanPath = filepath.Join(input.Path, file) scanPath = filepath.Join(input.Path, file)
@ -373,22 +344,9 @@ func (CatalogAdapter) Run(ctx context.Context, input RunInput, files []string) A
findings = append(findings, fileFindings...) findings = append(findings, fileFindings...)
} }
} else { } else {
if ctx.Err() != nil {
result.Tool.Status = "canceled"
result.Tool.Duration = time.Since(startedAt).Round(time.Millisecond).String()
return result
}
findings, _ = scanner.ScanDir(input.Path) findings, _ = scanner.ScanDir(input.Path)
} }
if err := ctx.Err(); err != nil {
result.Tool.Status = "canceled"
result.Tool.Duration = time.Since(startedAt).Round(time.Millisecond).String()
result.Tool.Findings = len(findings)
result.Findings = findings
return result
}
for index := range findings { for index := range findings {
rule := catalog.ByID(findings[index].RuleID) rule := catalog.ByID(findings[index].RuleID)
findings[index].Tool = "catalog" findings[index].Tool = "catalog"
@ -530,27 +488,8 @@ func parseJSONDiagnostics(tool string, category string, output string) []Finding
break break
} }
if err != nil { if err != nil {
if strings.TrimSpace(output) == "" {
return nil return nil
} }
if len(findings) > 0 {
findings = append(findings, Finding{
Tool: tool,
Severity: "error",
Code: "parse-error",
Message: fmt.Sprintf("failed to parse JSON output: %v", err),
Category: category,
})
return dedupeFindings(findings)
}
return []Finding{{
Tool: tool,
Severity: "error",
Code: "parse-error",
Message: fmt.Sprintf("failed to parse JSON output: %v", err),
Category: category,
}}
}
findings = append(findings, collectJSONDiagnostics(tool, category, value)...) findings = append(findings, collectJSONDiagnostics(tool, category, value)...)
} }

View file

@ -1,372 +0,0 @@
package lint
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAdapter_CommandAdapter_Good(t *testing.T) {
binDir := t.TempDir()
writeScript(t, binDir, "demo-tool", `case "$1" in
--version|-version|version)
echo "demo-tool version 1.2.3" >&2
exit 0
;;
esac
exit 0
`)
prependPath(t, binDir)
adapter := newCommandAdapter(
"demo-tool",
[]string{"demo-tool"},
[]string{"go"},
"security",
"lint.security",
true,
true,
func(_ string, _ []string) []string {
return []string{"scan"}
},
nil,
).(CommandAdapter)
langs := adapter.Languages()
assert.Equal(t, "demo-tool", adapter.Name())
assert.True(t, adapter.Available())
assert.Equal(t, "demo-tool", adapter.Command())
assert.Equal(t, "lint.security", adapter.Entitlement())
assert.True(t, adapter.RequiresEntitlement())
assert.True(t, adapter.MatchesLanguage([]string{"go"}))
assert.True(t, adapter.MatchesLanguage([]string{"security"}))
assert.True(t, adapter.MatchesLanguage(nil))
assert.False(t, adapter.MatchesLanguage([]string{"rust"}))
assert.Equal(t, "security", adapter.Category())
assert.True(t, adapter.Fast())
assert.Equal(t, []string{"go"}, langs)
langs[0] = "mutated"
assert.Equal(t, []string{"go"}, adapter.Languages())
result := adapter.Run(context.Background(), RunInput{Path: t.TempDir()}, nil)
require.Equal(t, "passed", result.Tool.Status)
assert.Equal(t, "demo-tool version 1.2.3", result.Tool.Version)
assert.Equal(t, 0, result.Tool.Findings)
assert.Empty(t, result.Findings)
}
func TestAdapter_CommandAdapter_Bad(t *testing.T) {
adapter := CommandAdapter{
name: "missing-tool",
binaries: []string{"missing-tool"},
languages: []string{"go"},
category: "security",
}
result := adapter.Run(context.Background(), RunInput{Path: t.TempDir()}, nil)
require.Equal(t, "skipped", result.Tool.Status)
assert.Equal(t, "0s", result.Tool.Duration)
require.Len(t, result.Findings, 1)
assert.Equal(t, "info", result.Findings[0].Severity)
assert.Equal(t, "missing-tool", result.Findings[0].Code)
assert.Equal(t, "missing-tool", result.Findings[0].Tool)
assert.Equal(t, "security", result.Findings[0].Category)
assert.Equal(t, 1, result.Tool.Findings)
}
func TestAdapter_CommandAdapter_Ugly(t *testing.T) {
binDir := t.TempDir()
writeScript(t, binDir, "slow-tool", `case "$1" in
--version|-version|version)
echo "slow-tool 9.9.9"
exit 0
;;
esac
sleep 1
exit 0
`)
prependPath(t, binDir)
adapter := newCommandAdapter(
"slow-tool",
[]string{"slow-tool"},
[]string{"go"},
"security",
"",
false,
false,
func(_ string, _ []string) []string {
return []string{"scan"}
},
nil,
).(CommandAdapter)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
result := adapter.Run(ctx, RunInput{Path: t.TempDir()}, nil)
require.Equal(t, "timeout", result.Tool.Status)
assert.Equal(t, "slow-tool 9.9.9", result.Tool.Version)
assert.Empty(t, result.Findings)
}
func TestAdapter_ParseJSONDiagnostics_Good(t *testing.T) {
output := `[
{
"location": {
"path": "internal/foo/bar.go",
"start": {"line": 42, "column": 5}
},
"message": {"text": "Errors unhandled"},
"rule_id": "G104",
"severity": "warn"
},
{
"location": {
"path": "internal/foo/bar.go",
"start": {"line": 42, "column": 5}
},
"message": {"text": "Errors unhandled"},
"rule_id": "G104",
"severity": "warn"
}
]`
findings := parseJSONDiagnostics("gosec", "security", output)
require.Len(t, findings, 1)
assert.Equal(t, "gosec", findings[0].Tool)
assert.Equal(t, "internal/foo/bar.go", findings[0].File)
assert.Equal(t, 42, findings[0].Line)
assert.Equal(t, 5, findings[0].Column)
assert.Equal(t, "G104", findings[0].Code)
assert.Equal(t, "Errors unhandled", findings[0].Message)
assert.Equal(t, "warning", findings[0].Severity)
assert.Equal(t, "security", findings[0].Category)
}
func TestAdapter_ParseJSONDiagnostics_Bad(t *testing.T) {
findings := parseJSONDiagnostics("gosec", "security", "{not json")
require.Len(t, findings, 1)
assert.Equal(t, "error", findings[0].Severity)
assert.Equal(t, "parse-error", findings[0].Code)
assert.Equal(t, "gosec", findings[0].Tool)
assert.Equal(t, "security", findings[0].Category)
assert.Contains(t, findings[0].Message, "failed to parse JSON output")
}
func TestAdapter_ParseJSONDiagnostics_PartialOutput(t *testing.T) {
output := `[
{
"location": {
"path": "internal/foo/bar.go",
"start": {"line": 42, "column": 5}
},
"message": {"text": "Errors unhandled"},
"rule_id": "G104",
"severity": "warn"
}
]
not json`
findings := parseJSONDiagnostics("gosec", "security", output)
require.Len(t, findings, 2)
assert.Equal(t, "G104", findings[0].Code)
assert.Equal(t, "parse-error", findings[1].Code)
}
func TestAdapter_ParseJSONDiagnostics_Ugly(t *testing.T) {
output := `[
{
"Location": {
"Path": "src/main.go",
"Start": {"Line": "7", "Column": "3"}
},
"Message": {"Text": "Potential issue"},
"RuleID": "ABC123"
}
]`
findings := parseJSONDiagnostics("scanner", "security", output)
require.Len(t, findings, 1)
assert.Equal(t, "scanner", findings[0].Tool)
assert.Equal(t, "src/main.go", findings[0].File)
assert.Equal(t, 7, findings[0].Line)
assert.Equal(t, 3, findings[0].Column)
assert.Equal(t, "ABC123", findings[0].Code)
assert.Equal(t, "Potential issue", findings[0].Message)
assert.Equal(t, "error", findings[0].Severity)
assert.Equal(t, "security", findings[0].Category)
}
func TestAdapter_CommandAdapter_JSONStdoutIgnoresStderr(t *testing.T) {
binDir := t.TempDir()
writeScript(t, binDir, "json-tool", `case "$1" in
--version|-version|version)
echo "json-tool 1.0.0"
exit 0
;;
esac
printf '%s\n' '[{"location":{"path":"src/main.go","start":{"line":12,"column":3}},"message":{"text":"boom"},"rule_id":"X1","severity":"warn"}]'
echo "debug noise" >&2
exit 0
`)
prependPath(t, binDir)
adapter := newCommandAdapter(
"json-tool",
[]string{"json-tool"},
[]string{"go"},
"security",
"",
false,
true,
func(_ string, _ []string) []string {
return []string{"scan"}
},
parseJSONDiagnostics,
).(CommandAdapter)
result := adapter.Run(context.Background(), RunInput{Path: t.TempDir()}, nil)
require.Equal(t, "failed", result.Tool.Status)
require.Len(t, result.Findings, 1)
assert.Equal(t, "X1", result.Findings[0].Code)
assert.Equal(t, "src/main.go", result.Findings[0].File)
assert.Equal(t, "boom", result.Findings[0].Message)
assert.Equal(t, "warning", result.Findings[0].Severity)
}
func TestAdapter_ParseTextDiagnostics_Good(t *testing.T) {
output := strings.Join([]string{
"src/main.go:12:3:error: boom",
"src/main.go:13:warning: caution",
}, "\n")
findings := parseTextDiagnostics("eslint", "security", output)
require.Len(t, findings, 2)
assert.Equal(t, 12, findings[0].Line)
assert.Equal(t, 3, findings[0].Column)
assert.Equal(t, "error", findings[0].Severity)
assert.Equal(t, 13, findings[1].Line)
assert.Equal(t, 0, findings[1].Column)
assert.Equal(t, "warning", findings[1].Severity)
}
func TestAdapter_ParseTextDiagnostics_Bad(t *testing.T) {
assert.Empty(t, parseTextDiagnostics("eslint", "security", ""))
}
func TestAdapter_ParseTextDiagnostics_Ugly(t *testing.T) {
findings := parseTextDiagnostics("eslint", "security", "not parseable")
require.Len(t, findings, 1)
assert.Equal(t, "diagnostic", findings[0].Code)
assert.Equal(t, "not parseable", findings[0].Message)
assert.Equal(t, "error", findings[0].Severity)
}
func TestAdapter_ParseGovulncheckDiagnostics_Good(t *testing.T) {
output := `{"config":{"go_version":"go1.26","module_path":"example.com/app"}}
{"osv":{"id":"GO-2024-1234","summary":"Buffer overflow in foo","aliases":["CVE-2024-1234"],"affected":[{"ranges":[{"events":[{"fixed":"1.2.3"}]}]}]}}
{"finding":{"osv":"GO-2024-1234","trace":[{"package":"example.com/foo","function":"Bar"}]}}
`
findings := parseGovulncheckDiagnostics("govulncheck", "security", output)
require.Len(t, findings, 1)
assert.Equal(t, "govulncheck", findings[0].Tool)
assert.Equal(t, "example.com/foo", findings[0].File)
assert.Equal(t, "GO-2024-1234", findings[0].Code)
assert.Equal(t, "Buffer overflow in foo", findings[0].Message)
assert.Equal(t, "error", findings[0].Severity)
assert.Equal(t, "security", findings[0].Category)
}
func TestAdapter_ParseGovulncheckDiagnostics_Bad(t *testing.T) {
assert.Empty(t, parseGovulncheckDiagnostics("govulncheck", "security", "not json"))
}
func TestAdapter_CatalogAdapter_Good(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "input.go"), []byte(`package sample
type service struct{}
func (service) Process(string) error { return nil }
func Run() {
svc := service{}
_ = svc.Process("data")
}
`), 0o644))
adapter := CatalogAdapter{}
assert.Equal(t, "catalog", adapter.Name())
assert.True(t, adapter.Available())
assert.Equal(t, []string{"go"}, adapter.Languages())
assert.Equal(t, "catalog", adapter.Command())
assert.Empty(t, adapter.Entitlement())
assert.False(t, adapter.RequiresEntitlement())
assert.True(t, adapter.MatchesLanguage(nil))
assert.True(t, adapter.MatchesLanguage([]string{"go"}))
assert.False(t, adapter.MatchesLanguage([]string{"rust"}))
assert.Equal(t, "correctness", adapter.Category())
assert.True(t, adapter.Fast())
result := adapter.Run(context.Background(), RunInput{Path: dir}, []string{"input.go"})
require.Equal(t, "failed", result.Tool.Status)
require.Len(t, result.Findings, 1)
assert.Equal(t, "catalog", result.Findings[0].Tool)
assert.Equal(t, "go-cor-003", result.Findings[0].Code)
assert.Equal(t, "correctness", result.Findings[0].Category)
assert.Equal(t, "warning", result.Findings[0].Severity)
assert.Equal(t, "Silent error swallowing with blank identifier", result.Findings[0].Title)
assert.Equal(t, result.Findings[0].Title, result.Findings[0].Message)
filtered := adapter.Run(context.Background(), RunInput{Path: dir, Category: "security"}, []string{"input.go"})
require.Equal(t, "passed", filtered.Tool.Status)
assert.Empty(t, filtered.Findings)
}
func TestAdapter_CatalogAdapter_Bad(t *testing.T) {
adapter := CatalogAdapter{}
assert.False(t, adapter.MatchesLanguage([]string{"php"}))
assert.True(t, adapter.MatchesLanguage([]string{}))
}
func TestAdapter_CatalogAdapter_Ugly(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "input.go"), []byte("package sample\n"), 0o644))
adapter := CatalogAdapter{}
ctx, cancel := context.WithCancel(context.Background())
cancel()
result := adapter.Run(ctx, RunInput{Path: dir}, []string{"input.go"})
require.Equal(t, "canceled", result.Tool.Status)
assert.Empty(t, result.Findings)
}
func writeScript(t *testing.T, dir, name, body string) string {
t.Helper()
path := filepath.Join(dir, name)
content := "#!/bin/sh\n" + body
require.NoError(t, os.WriteFile(path, []byte(content), 0o755))
return path
}
func prependPath(t *testing.T, dir string) {
t.Helper()
oldPath := os.Getenv("PATH")
if oldPath == "" {
t.Setenv("PATH", dir)
return
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+oldPath)
}

View file

@ -1,139 +0,0 @@
package lint
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
func TestConfig_DefaultConfig_Good(t *testing.T) {
cfg := DefaultConfig()
assert.Equal(t, "json", cfg.Output)
assert.Equal(t, "error", cfg.FailOn)
assert.Equal(t, []string{"."}, cfg.Paths)
assert.Equal(t, []string{"vendor/", "node_modules/", ".core/"}, cfg.Exclude)
assert.Contains(t, cfg.Lint.Go, "golangci-lint")
assert.Contains(t, cfg.Lint.Security, "semgrep")
}
func TestConfig_DefaultConfig_Bad(t *testing.T) {
cfg := DefaultConfig()
cfg.Lint.Go[0] = "mutated"
cfg.Paths[0] = "mutated"
fresh := DefaultConfig()
assert.Equal(t, "golangci-lint", fresh.Lint.Go[0])
assert.Equal(t, ".", fresh.Paths[0])
}
func TestConfig_DefaultConfig_Ugly(t *testing.T) {
cfg := DefaultConfig()
cfg.Lint.Go = append(cfg.Lint.Go, "extra-tool")
cfg.Exclude = append(cfg.Exclude, "build/")
fresh := DefaultConfig()
assert.NotContains(t, fresh.Lint.Go, "extra-tool")
assert.NotContains(t, fresh.Exclude, "build/")
}
func TestConfig_DefaultConfigYAML_Good(t *testing.T) {
raw, err := DefaultConfigYAML()
require.NoError(t, err)
assert.Contains(t, raw, "output: json")
assert.Contains(t, raw, "fail_on: error")
var cfg LintConfig
require.NoError(t, yaml.Unmarshal([]byte(raw), &cfg))
assert.Equal(t, DefaultConfig().Output, cfg.Output)
assert.Equal(t, DefaultConfig().FailOn, cfg.FailOn)
assert.Equal(t, DefaultConfig().Paths, cfg.Paths)
assert.Equal(t, DefaultConfig().Exclude, cfg.Exclude)
}
func TestConfig_ResolveConfigPath_Good(t *testing.T) {
assert.Equal(t, "/repo/.core/lint.yaml", ResolveConfigPath("/repo", ""))
assert.Equal(t, "/repo/config/lint.yaml", ResolveConfigPath("/repo", "config/lint.yaml"))
}
func TestConfig_ResolveConfigPath_Bad(t *testing.T) {
assert.Equal(t, ".core/lint.yaml", ResolveConfigPath("", ""))
}
func TestConfig_ResolveConfigPath_Ugly(t *testing.T) {
absolute := filepath.Join(t.TempDir(), "nested", "lint.yaml")
assert.Equal(t, absolute, ResolveConfigPath("/repo", absolute))
}
func TestConfig_LoadProjectConfig_Good(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, ".core"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, ".core", "lint.yaml"), []byte(`output: sarif
fail_on: warning
paths:
- src
exclude:
- generated/
`), 0o644))
cfg, path, err := LoadProjectConfig(dir, "")
require.NoError(t, err)
assert.Equal(t, filepath.Join(dir, ".core", "lint.yaml"), path)
assert.Equal(t, "sarif", cfg.Output)
assert.Equal(t, "warning", cfg.FailOn)
assert.Equal(t, []string{"src"}, cfg.Paths)
assert.Equal(t, []string{"generated/"}, cfg.Exclude)
assert.Equal(t, "golangci-lint", cfg.Lint.Go[0])
}
func TestConfig_LoadProjectConfig_Bad(t *testing.T) {
dir := t.TempDir()
cfg, path, err := LoadProjectConfig(dir, "")
require.NoError(t, err)
assert.Empty(t, path)
assert.Equal(t, DefaultConfig().Output, cfg.Output)
assert.Equal(t, DefaultConfig().FailOn, cfg.FailOn)
}
func TestConfig_LoadProjectConfig_Ugly(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, ".core"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, ".core", "lint.yaml"), []byte("{not: yaml"), 0o644))
_, path, err := LoadProjectConfig(dir, "")
assert.Error(t, err)
assert.Empty(t, path)
}
func TestConfig_ResolveSchedule_Good(t *testing.T) {
cfg := DefaultConfig()
cfg.Schedules = map[string]Schedule{
"nightly": {
Cron: "0 2 * * *",
Output: "json",
},
}
schedule, err := ResolveSchedule(cfg, "nightly")
require.NoError(t, err)
require.NotNil(t, schedule)
assert.Equal(t, "0 2 * * *", schedule.Cron)
assert.Equal(t, "json", schedule.Output)
}
func TestConfig_ResolveSchedule_Bad(t *testing.T) {
_, err := ResolveSchedule(DefaultConfig(), "missing")
assert.Error(t, err)
}
func TestConfig_ResolveSchedule_Ugly(t *testing.T) {
schedule, err := ResolveSchedule(DefaultConfig(), "")
require.NoError(t, err)
assert.Nil(t, schedule)
}

View file

@ -29,34 +29,6 @@ func TestDetect_Good_ProjectMarkersAndFiles(t *testing.T) {
) )
} }
func TestDetect_Good_MarkerCoverage(t *testing.T) {
dir := t.TempDir()
files := map[string]string{
"go.mod": "module example.com/test\n",
"composer.json": "{}\n",
"package.json": "{}\n",
"tsconfig.json": "{}\n",
"requirements.txt": "ruff\n",
"pyproject.toml": "[tool.ruff]\n",
"Cargo.toml": "[package]\nname = \"test\"\n",
"Dockerfile.dev": "FROM scratch\n",
"run.sh": "#!/bin/sh\n",
"main.cpp": "int main() { return 0; }\n",
"config.yaml": "kind: Config\n",
"config.yml": "kind: Config\n",
}
for name, content := range files {
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644))
}
assert.Equal(t,
[]string{"cpp", "dockerfile", "go", "js", "php", "python", "rust", "shell", "ts", "yaml"},
Detect(dir),
)
}
func TestDetectFromFiles_Good(t *testing.T) { func TestDetectFromFiles_Good(t *testing.T) {
files := []string{ files := []string{
"main.go", "main.go",
@ -73,7 +45,7 @@ func TestDetectFromFiles_Good(t *testing.T) {
) )
} }
func TestDetect_Bad_MissingPathReturnsEmptySlice(t *testing.T) { func TestDetect_MissingPathReturnsEmptySlice(t *testing.T) {
assert.Equal(t, []string{}, Detect(filepath.Join(t.TempDir(), "missing"))) assert.Equal(t, []string{}, Detect(filepath.Join(t.TempDir(), "missing")))
} }
@ -85,17 +57,3 @@ func TestDetect_Good_SkipsHiddenRootDirectory(t *testing.T) {
assert.Equal(t, []string{}, Detect(hiddenDir)) assert.Equal(t, []string{}, Detect(hiddenDir))
} }
func TestDetect_Ugly_SkipsNestedHiddenAndExcludedDirectories(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "root.go"), []byte("package main\n"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "vendor"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "vendor", "ignored.go"), []byte("package ignored\n"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(dir, ".core"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, ".core", "ignored.go"), []byte("package ignored\n"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "services", ".generated"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "services", ".generated", "ignored.go"), []byte("package ignored\n"), 0o644))
assert.Equal(t, []string{"go"}, Detect(dir))
}

View file

@ -62,26 +62,3 @@ func TestResolveRunOutputFormat_Good_ExplicitOutputBypassesConfigLoading(t *test
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "sarif", format) assert.Equal(t, "sarif", format)
} }
func TestResolveRunOutputFormat_Bad_BrokenConfig(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, ".core"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, ".core", "lint.yaml"), []byte("{not: yaml"), 0o644))
_, err := ResolveRunOutputFormat(RunInput{
Path: dir,
})
assert.Error(t, err)
}
func TestResolveRunOutputFormat_Ugly_MissingSchedule(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, ".core"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, ".core", "lint.yaml"), []byte("output: text\n"), 0o644))
_, err := ResolveRunOutputFormat(RunInput{
Path: dir,
Schedule: "nightly",
})
assert.Error(t, err)
}

View file

@ -75,7 +75,7 @@ func WriteJSONL(w io.Writer, findings []Finding) error {
// WriteText writes findings in a human-readable format. // WriteText writes findings in a human-readable format.
// //
// lint.WriteText(os.Stdout, findings) // lint.WriteText(os.Stdout, findings)
func WriteText(w io.Writer, findings []Finding) error { func WriteText(w io.Writer, findings []Finding) {
for _, finding := range findings { for _, finding := range findings {
message := finding.Message message := finding.Message
if message == "" { if message == "" {
@ -85,11 +85,8 @@ func WriteText(w io.Writer, findings []Finding) error {
if code == "" { if code == "" {
code = finding.RuleID code = finding.RuleID
} }
if _, err := fmt.Fprintf(w, "%s:%d [%s] %s (%s)\n", finding.File, finding.Line, finding.Severity, message, code); err != nil { fmt.Fprintf(w, "%s:%d [%s] %s (%s)\n", finding.File, finding.Line, finding.Severity, message, code)
return err
} }
}
return nil
} }
// WriteReportJSON writes the RFC report document as pretty-printed JSON. // WriteReportJSON writes the RFC report document as pretty-printed JSON.
@ -104,18 +101,15 @@ func WriteReportJSON(w io.Writer, report Report) error {
// WriteReportText writes report findings followed by a short summary. // WriteReportText writes report findings followed by a short summary.
// //
// lint.WriteReportText(os.Stdout, report) // lint.WriteReportText(os.Stdout, report)
func WriteReportText(w io.Writer, report Report) error { func WriteReportText(w io.Writer, report Report) {
if err := WriteText(w, report.Findings); err != nil { WriteText(w, report.Findings)
return err fmt.Fprintf(w, "\n%d finding(s): %d error(s), %d warning(s), %d info\n", report.Summary.Total, report.Summary.Errors, report.Summary.Warnings, report.Summary.Info)
}
_, err := fmt.Fprintf(w, "\n%d finding(s): %d error(s), %d warning(s), %d info\n", report.Summary.Total, report.Summary.Errors, report.Summary.Warnings, report.Summary.Info)
return err
} }
// WriteReportGitHub writes GitHub Actions annotation lines. // WriteReportGitHub writes GitHub Actions annotation lines.
// //
// lint.WriteReportGitHub(os.Stdout, report) // lint.WriteReportGitHub(os.Stdout, report)
func WriteReportGitHub(w io.Writer, report Report) error { func WriteReportGitHub(w io.Writer, report Report) {
for _, finding := range report.Findings { for _, finding := range report.Findings {
level := githubAnnotationLevel(finding.Severity) level := githubAnnotationLevel(finding.Severity)
@ -138,11 +132,8 @@ func WriteReportGitHub(w io.Writer, report Report) error {
if code == "" { if code == "" {
code = finding.RuleID code = finding.RuleID
} }
if _, err := fmt.Fprintf(w, "::%s%s::[%s] %s (%s)\n", level, location, finding.Tool, message, code); err != nil { fmt.Fprintf(w, "::%s%s::[%s] %s (%s)\n", level, location, finding.Tool, message, code)
return err
} }
}
return nil
} }
// WriteReportSARIF writes a minimal SARIF document for code scanning tools. // WriteReportSARIF writes a minimal SARIF document for code scanning tools.

View file

@ -3,7 +3,6 @@ package lint
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"strings" "strings"
"testing" "testing"
@ -58,19 +57,6 @@ func TestSummarise_Good_Empty(t *testing.T) {
assert.Empty(t, summary.BySeverity) assert.Empty(t, summary.BySeverity)
} }
func TestSummarise_Bad_BlankSeverityDefaultsToWarning(t *testing.T) {
summary := Summarise([]Finding{
{Severity: ""},
{Severity: "info"},
})
assert.Equal(t, 2, summary.Total)
assert.Equal(t, 1, summary.Warnings)
assert.Equal(t, 1, summary.Info)
assert.Equal(t, 0, summary.Errors)
assert.True(t, summary.Passed)
}
func TestWriteJSON_Good_Roundtrip(t *testing.T) { func TestWriteJSON_Good_Roundtrip(t *testing.T) {
findings := sampleFindings() findings := sampleFindings()
var buf bytes.Buffer var buf bytes.Buffer
@ -129,16 +115,10 @@ func TestWriteJSONL_Good_Empty(t *testing.T) {
assert.Empty(t, buf.String()) assert.Empty(t, buf.String())
} }
func TestWriteJSONL_Bad_PropagatesWriterErrors(t *testing.T) {
err := WriteJSONL(failingWriter{}, sampleFindings())
require.Error(t, err)
}
func TestWriteText_Good(t *testing.T) { func TestWriteText_Good(t *testing.T) {
findings := sampleFindings() findings := sampleFindings()
var buf bytes.Buffer var buf bytes.Buffer
err := WriteText(&buf, findings) WriteText(&buf, findings)
require.NoError(t, err)
output := buf.String() output := buf.String()
assert.Contains(t, output, "store/query.go:42") assert.Contains(t, output, "store/query.go:42")
@ -151,15 +131,14 @@ func TestWriteText_Good(t *testing.T) {
func TestWriteText_Good_Empty(t *testing.T) { func TestWriteText_Good_Empty(t *testing.T) {
var buf bytes.Buffer var buf bytes.Buffer
err := WriteText(&buf, nil) WriteText(&buf, nil)
require.NoError(t, err)
assert.Empty(t, buf.String()) assert.Empty(t, buf.String())
} }
func TestWriteReportGitHub_Good_MapsInfoToNotice(t *testing.T) { func TestWriteReportGitHub_Good_MapsInfoToNotice(t *testing.T) {
var buf bytes.Buffer var buf bytes.Buffer
err := WriteReportGitHub(&buf, Report{ WriteReportGitHub(&buf, Report{
Findings: []Finding{{ Findings: []Finding{{
Tool: "demo", Tool: "demo",
File: "example.go", File: "example.go",
@ -170,23 +149,10 @@ func TestWriteReportGitHub_Good_MapsInfoToNotice(t *testing.T) {
Message: "explanation", Message: "explanation",
}}, }},
}) })
require.NoError(t, err)
assert.Contains(t, buf.String(), "::notice file=example.go,line=7,col=3::[demo] explanation (demo-rule)") assert.Contains(t, buf.String(), "::notice file=example.go,line=7,col=3::[demo] explanation (demo-rule)")
} }
func TestWriteText_Bad_PropagatesWriterErrors(t *testing.T) {
err := WriteText(failingWriter{}, sampleFindings())
require.Error(t, err)
}
func TestWriteReportGitHub_Bad_PropagatesWriterErrors(t *testing.T) {
err := WriteReportGitHub(failingWriter{}, Report{
Findings: sampleFindings(),
})
require.Error(t, err)
}
func TestWriteReportSARIF_Good_MapsInfoToNote(t *testing.T) { func TestWriteReportSARIF_Good_MapsInfoToNote(t *testing.T) {
var buf bytes.Buffer var buf bytes.Buffer
@ -210,61 +176,3 @@ func TestWriteReportSARIF_Good_MapsInfoToNote(t *testing.T) {
results := runs[0].(map[string]any)["results"].([]any) results := runs[0].(map[string]any)["results"].([]any)
assert.Equal(t, "note", results[0].(map[string]any)["level"]) assert.Equal(t, "note", results[0].(map[string]any)["level"])
} }
func TestWriteReportJSON_Good_Roundtrip(t *testing.T) {
var buf bytes.Buffer
err := WriteReportJSON(&buf, Report{
Project: "demo",
Languages: []string{"go"},
Findings: []Finding{{
Tool: "demo",
File: "example.go",
Line: 7,
Column: 3,
Severity: "warning",
Code: "demo-rule",
Message: "explanation",
}},
Summary: Summary{Total: 1, Warnings: 1, Passed: true},
})
require.NoError(t, err)
var decoded Report
require.NoError(t, json.Unmarshal(buf.Bytes(), &decoded))
assert.Equal(t, "demo", decoded.Project)
assert.Equal(t, []string{"go"}, decoded.Languages)
require.Len(t, decoded.Findings, 1)
assert.Equal(t, "demo-rule", decoded.Findings[0].Code)
assert.Equal(t, 1, decoded.Summary.Total)
assert.Equal(t, 1, decoded.Summary.Warnings)
}
func TestWriteReportText_Good_IncludesSummary(t *testing.T) {
var buf bytes.Buffer
err := WriteReportText(&buf, Report{
Findings: sampleFindings(),
Summary: Summarise(sampleFindings()),
})
require.NoError(t, err)
output := buf.String()
assert.Contains(t, output, "store/query.go:42")
assert.Contains(t, output, "3 finding(s):")
assert.Contains(t, output, "0 error(s), 3 warning(s), 0 info")
}
func TestWriteReportText_Bad_PropagatesWriterErrors(t *testing.T) {
err := WriteReportText(failingWriter{}, Report{
Findings: sampleFindings(),
Summary: Summarise(sampleFindings()),
})
require.Error(t, err)
}
type failingWriter struct{}
func (failingWriter) Write([]byte) (int, error) {
return 0, errors.New("write failed")
}

View file

@ -29,9 +29,6 @@ func TestDetectLanguage_Good(t *testing.T) {
{"noextension", ""}, {"noextension", ""},
{"file.py", "python"}, {"file.py", "python"},
{"Dockerfile", "dockerfile"}, {"Dockerfile", "dockerfile"},
{"services/Dockerfile.prod", "dockerfile"},
{"configs/settings.yaml", "yaml"},
{"configs/settings.yml", "yaml"},
} }
for _, tt := range tests { for _, tt := range tests {
@ -42,15 +39,6 @@ func TestDetectLanguage_Good(t *testing.T) {
} }
} }
func TestDetectLanguage_Bad_UnknownExtension(t *testing.T) {
assert.Equal(t, "", DetectLanguage("notes.txt"))
assert.Equal(t, "", DetectLanguage("README"))
}
func TestDetectLanguage_Ugly_DockerfileVariant(t *testing.T) {
assert.Equal(t, "dockerfile", DetectLanguage("nested/Dockerfile.test"))
}
func TestScanDir_Good_FindsMatches(t *testing.T) { func TestScanDir_Good_FindsMatches(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
@ -221,58 +209,6 @@ func TestScanFile_Good_Python(t *testing.T) {
assert.Equal(t, "python", DetectLanguage(file)) assert.Equal(t, "python", DetectLanguage(file))
} }
func TestScanFile_Bad_NoMatchingLanguageRules(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "app.go")
err := os.WriteFile(file, []byte("package main\n"), 0o644)
require.NoError(t, err)
rules := []Rule{
{
ID: "php-only",
Title: "PHP TODO",
Severity: "low",
Languages: []string{"php"},
Pattern: `TODO`,
Fix: "Remove TODO",
Detection: "regex",
},
}
s, err := NewScanner(rules)
require.NoError(t, err)
findings, err := s.ScanFile(file)
require.NoError(t, err)
assert.Empty(t, findings)
}
func TestScanFile_Ugly_UnsupportedExtension(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "notes.txt")
err := os.WriteFile(file, []byte("TODO: this is not a recognised source file\n"), 0o644)
require.NoError(t, err)
rules := []Rule{
{
ID: "go-only",
Title: "Go TODO",
Severity: "low",
Languages: []string{"go"},
Pattern: `TODO`,
Fix: "Remove TODO",
Detection: "regex",
},
}
s, err := NewScanner(rules)
require.NoError(t, err)
findings, err := s.ScanFile(file)
require.NoError(t, err)
assert.Nil(t, findings)
}
func TestScanDir_Good_Subdirectories(t *testing.T) { func TestScanDir_Good_Subdirectories(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()

View file

@ -144,9 +144,6 @@ func (service *Service) Run(ctx context.Context, input RunInput) (Report, error)
var toolRuns []ToolRun var toolRuns []ToolRun
for _, adapter := range selectedAdapters { for _, adapter := range selectedAdapters {
if err := ctx.Err(); err != nil {
break
}
if input.Hook && !adapter.Fast() { if input.Hook && !adapter.Fast() {
toolRuns = append(toolRuns, ToolRun{ toolRuns = append(toolRuns, ToolRun{
Name: adapter.Name(), Name: adapter.Name(),

View file

@ -501,32 +501,6 @@ exit 0
assert.Equal(t, "prettier 3.2.1", report.Tools[0].Version) assert.Equal(t, "prettier 3.2.1", report.Tools[0].Version)
} }
func TestServiceRun_Good_ReportsMissingToolAsInfoFinding(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "composer.json"), []byte("{\n \"name\": \"example/test\"\n}\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "index.php"), []byte("<?php\n"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(dir, ".core"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, ".core", "lint.yaml"), []byte("lint:\n php:\n - missing-tool\n"), 0o644))
svc := &Service{adapters: []Adapter{
newCommandAdapter("missing-tool", []string{"definitely-not-installed-xyz"}, []string{"php"}, "correctness", "", false, true, projectPathArguments(), parseTextDiagnostics),
}}
report, err := svc.Run(context.Background(), RunInput{
Path: dir,
FailOn: "warning",
})
require.NoError(t, err)
require.Len(t, report.Tools, 1)
require.Len(t, report.Findings, 1)
assert.Equal(t, "skipped", report.Tools[0].Status)
assert.Equal(t, "info", report.Findings[0].Severity)
assert.Equal(t, "missing-tool", report.Findings[0].Code)
assert.Equal(t, "definitely-not-installed-xyz is not installed", report.Findings[0].Message)
assert.Equal(t, 1, report.Summary.Info)
assert.True(t, report.Summary.Passed)
}
func TestServiceRun_Good_DeduplicatesMergedFindings(t *testing.T) { func TestServiceRun_Good_DeduplicatesMergedFindings(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/test\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/test\n"), 0o644))
@ -566,38 +540,6 @@ func TestServiceTools_EmptyInventoryReturnsEmptySlice(t *testing.T) {
assert.Empty(t, tools) assert.Empty(t, tools)
} }
func TestServiceRun_Good_StopsDispatchingAfterContextCancel(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "composer.json"), []byte("{\n \"name\": \"example/test\"\n}\n"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(dir, ".core"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, ".core", "lint.yaml"), []byte(`lint:
php:
- first
- second
`), 0o644))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var secondRan bool
svc := &Service{adapters: []Adapter{
cancellingAdapter{name: "first", cancel: cancel},
recordingAdapter{name: "second", ran: &secondRan},
}}
report, err := svc.Run(ctx, RunInput{
Path: dir,
Lang: "php",
FailOn: "warning",
})
require.NoError(t, err)
require.Len(t, report.Tools, 1)
assert.Equal(t, "first", report.Tools[0].Name)
assert.False(t, secondRan)
assert.Empty(t, report.Findings)
}
type shortcutAdapter struct { type shortcutAdapter struct {
name string name string
category string category string
@ -631,92 +573,6 @@ func (adapter shortcutAdapter) Run(_ context.Context, _ RunInput, _ []string) Ad
} }
} }
type recordingAdapter struct {
name string
ran *bool
}
func (adapter recordingAdapter) Name() string { return adapter.name }
func (adapter recordingAdapter) Available() bool { return true }
func (adapter recordingAdapter) Languages() []string { return []string{"php"} }
func (adapter recordingAdapter) Command() string { return adapter.name }
func (adapter recordingAdapter) Entitlement() string { return "" }
func (adapter recordingAdapter) RequiresEntitlement() bool { return false }
func (adapter recordingAdapter) MatchesLanguage(languages []string) bool {
for _, language := range languages {
if language == "php" {
return true
}
}
return false
}
func (adapter recordingAdapter) Category() string { return "correctness" }
func (adapter recordingAdapter) Fast() bool { return true }
func (adapter recordingAdapter) Run(_ context.Context, _ RunInput, _ []string) AdapterResult {
if adapter.ran != nil {
*adapter.ran = true
}
return AdapterResult{
Tool: ToolRun{
Name: adapter.name,
Status: "passed",
Duration: "0s",
},
}
}
type cancellingAdapter struct {
name string
cancel context.CancelFunc
}
func (adapter cancellingAdapter) Name() string { return adapter.name }
func (adapter cancellingAdapter) Available() bool { return true }
func (adapter cancellingAdapter) Languages() []string { return []string{"php"} }
func (adapter cancellingAdapter) Command() string { return adapter.name }
func (adapter cancellingAdapter) Entitlement() string { return "" }
func (adapter cancellingAdapter) RequiresEntitlement() bool { return false }
func (adapter cancellingAdapter) MatchesLanguage(languages []string) bool {
for _, language := range languages {
if language == "php" {
return true
}
}
return false
}
func (adapter cancellingAdapter) Category() string { return "correctness" }
func (adapter cancellingAdapter) Fast() bool { return true }
func (adapter cancellingAdapter) Run(_ context.Context, _ RunInput, _ []string) AdapterResult {
if adapter.cancel != nil {
adapter.cancel()
}
return AdapterResult{
Tool: ToolRun{
Name: adapter.name,
Status: "passed",
Duration: "0s",
},
}
}
type duplicateAdapter struct { type duplicateAdapter struct {
name string name string
finding Finding finding Finding

View file

@ -111,7 +111,7 @@ func ParseVulnCheckJSON(stdout, stderr string) (*VulnResult, error) {
var msg govulncheckMessage var msg govulncheckMessage
if err := json.Unmarshal([]byte(line), &msg); err != nil { if err := json.Unmarshal([]byte(line), &msg); err != nil {
return nil, coreerr.E("ParseVulnCheckJSON", "invalid govulncheck JSON output", err) continue
} }
if msg.Config != nil { if msg.Config != nil {

View file

@ -43,8 +43,11 @@ also not json
{"finding":{"osv":"GO-2024-5678","trace":[{"package":"example.com/dep","function":"Fn"}]}} {"finding":{"osv":"GO-2024-5678","trace":[{"package":"example.com/dep","function":"Fn"}]}}
` `
result, err := ParseVulnCheckJSON(stdout, "") result, err := ParseVulnCheckJSON(stdout, "")
require.Error(t, err) require.NoError(t, err)
assert.Nil(t, result) assert.Equal(t, "example.com/app", result.Module)
require.Len(t, result.Findings, 1)
assert.Equal(t, "GO-2024-5678", result.Findings[0].ID)
assert.Equal(t, "Test vuln", result.Findings[0].Description)
} }
func TestParseVulnCheckJSON_Empty(t *testing.T) { func TestParseVulnCheckJSON_Empty(t *testing.T) {

BIN
tests/.DS_Store vendored

Binary file not shown.

BIN
tests/cli/.DS_Store vendored

Binary file not shown.

BIN
tests/cli/qa/.DS_Store vendored

Binary file not shown.

View file

@ -2,7 +2,7 @@ package main
import ( import (
"forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
_ "dappco.re/go/core/lint/cmd/qa" _ "forge.lthn.ai/core/lint/cmd/qa"
) )
func main() { func main() {