feat(ax): implement RFC lint orchestration CLI
This commit is contained in:
parent
119df680d7
commit
7ab634bcd2
15 changed files with 2527 additions and 109 deletions
|
|
@ -1,15 +1,17 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
lint "forge.lthn.ai/core/lint"
|
||||
cataloglint "forge.lthn.ai/core/lint"
|
||||
lintpkg "forge.lthn.ai/core/lint/pkg/lint"
|
||||
)
|
||||
|
||||
|
|
@ -19,37 +21,219 @@ func main() {
|
|||
}
|
||||
|
||||
func addLintCommands(root *cli.Command) {
|
||||
lintCmd := cli.NewGroup("lint", "Pattern-based code linter", "")
|
||||
addRFCCommands(root)
|
||||
|
||||
// ── check ──────────────────────────────────────────────────────────────
|
||||
lintCmd := cli.NewGroup("lint", "Pattern-based code linter", "")
|
||||
lintCmd.AddCommand(newCheckCommand(), newCatalogCommand())
|
||||
addRFCCommands(lintCmd)
|
||||
|
||||
root.AddCommand(lintCmd)
|
||||
}
|
||||
|
||||
func addRFCCommands(parent *cli.Command) {
|
||||
parent.AddCommand(
|
||||
newRunCommand("run", "Run configured linters", lintpkg.RunInput{}),
|
||||
newDetectCommand("detect", "Detect project languages"),
|
||||
newToolsCommand("tools", "List supported linter tools"),
|
||||
newInitCommand("init", "Generate .core/lint.yaml"),
|
||||
newRunCommand("go", "Run Go linters", lintpkg.RunInput{Lang: "go"}),
|
||||
newRunCommand("php", "Run PHP linters", lintpkg.RunInput{Lang: "php"}),
|
||||
newRunCommand("js", "Run JS/TS linters", lintpkg.RunInput{Lang: "js"}),
|
||||
newRunCommand("python", "Run Python linters", lintpkg.RunInput{Lang: "python"}),
|
||||
newRunCommand("security", "Run security linters", lintpkg.RunInput{Category: "security"}),
|
||||
newRunCommand("compliance", "Run compliance linters", lintpkg.RunInput{Category: "compliance", SBOM: true}),
|
||||
newHookCommand(),
|
||||
)
|
||||
}
|
||||
|
||||
func newRunCommand(use string, short string, defaults lintpkg.RunInput) *cli.Command {
|
||||
input := defaults
|
||||
|
||||
cmd := cli.NewCommand(use, short, "", func(cmd *cli.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
input.Path = args[0]
|
||||
}
|
||||
if input.Path == "" {
|
||||
input.Path = "."
|
||||
}
|
||||
|
||||
output, err := resolvedOutput(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input.Output = output
|
||||
|
||||
svc := lintpkg.NewService()
|
||||
report, err := svc.Run(context.Background(), input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := writeReport(cmd.OutOrStdout(), input.Output, report); err != nil {
|
||||
return err
|
||||
}
|
||||
if !report.Summary.Passed {
|
||||
return coreerr.E("cmd."+use, "lint failed", nil)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
cli.StringFlag(cmd, &input.Output, "output", "o", defaults.Output, "Output format: json, text, github, sarif")
|
||||
cli.StringFlag(cmd, &input.Config, "config", "c", defaults.Config, "Config path (default: .core/lint.yaml)")
|
||||
cli.StringFlag(cmd, &input.FailOn, "fail-on", "", defaults.FailOn, "Fail threshold: error, warning, info")
|
||||
cli.StringFlag(cmd, &input.Category, "category", "", defaults.Category, "Restrict to one category")
|
||||
cli.StringFlag(cmd, &input.Lang, "lang", "l", defaults.Lang, "Restrict to one language")
|
||||
cli.BoolFlag(cmd, &input.Hook, "hook", "", defaults.Hook, "Run in pre-commit mode against staged files")
|
||||
cli.BoolFlag(cmd, &input.CI, "ci", "", defaults.CI, "GitHub Actions mode (github annotations)")
|
||||
cli.BoolFlag(cmd, &input.SBOM, "sbom", "", defaults.SBOM, "Enable compliance/SBOM tools")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newDetectCommand(use string, short string) *cli.Command {
|
||||
var output string
|
||||
|
||||
cmd := cli.NewCommand(use, short, "", func(cmd *cli.Command, args []string) error {
|
||||
path := "."
|
||||
if len(args) > 0 {
|
||||
path = args[0]
|
||||
}
|
||||
|
||||
languages := lintpkg.Detect(path)
|
||||
switch output {
|
||||
case "", "text":
|
||||
for _, language := range languages {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), language)
|
||||
}
|
||||
return nil
|
||||
case "json":
|
||||
return writeJSON(cmd.OutOrStdout(), languages)
|
||||
default:
|
||||
return coreerr.E("cmd.detect", "unsupported output format "+output, nil)
|
||||
}
|
||||
})
|
||||
|
||||
cli.StringFlag(cmd, &output, "output", "o", "text", "Output format: text, json")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newToolsCommand(use string, short string) *cli.Command {
|
||||
var output string
|
||||
var language string
|
||||
|
||||
cmd := cli.NewCommand(use, short, "", func(cmd *cli.Command, args []string) error {
|
||||
svc := lintpkg.NewService()
|
||||
|
||||
var languages []string
|
||||
if language != "" {
|
||||
languages = []string{language}
|
||||
}
|
||||
|
||||
tools := svc.Tools(languages)
|
||||
switch output {
|
||||
case "", "text":
|
||||
for _, tool := range tools {
|
||||
status := "missing"
|
||||
if tool.Available {
|
||||
status = "available"
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%-14s [%-11s] %s\n", tool.Name, tool.Category, status)
|
||||
}
|
||||
return nil
|
||||
case "json":
|
||||
return writeJSON(cmd.OutOrStdout(), tools)
|
||||
default:
|
||||
return coreerr.E("cmd.tools", "unsupported output format "+output, nil)
|
||||
}
|
||||
})
|
||||
|
||||
cli.StringFlag(cmd, &output, "output", "o", "text", "Output format: text, json")
|
||||
cli.StringFlag(cmd, &language, "lang", "l", "", "Filter by language")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newInitCommand(use string, short string) *cli.Command {
|
||||
var force bool
|
||||
|
||||
cmd := cli.NewCommand(use, short, "", func(cmd *cli.Command, args []string) error {
|
||||
path := "."
|
||||
if len(args) > 0 {
|
||||
path = args[0]
|
||||
}
|
||||
|
||||
svc := lintpkg.NewService()
|
||||
writtenPath, err := svc.WriteDefaultConfig(path, force)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout(), writtenPath)
|
||||
return nil
|
||||
})
|
||||
|
||||
cli.BoolFlag(cmd, &force, "force", "f", false, "Overwrite an existing config")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newHookCommand() *cli.Command {
|
||||
hookCmd := cli.NewGroup("hook", "Install or remove the git pre-commit hook", "")
|
||||
|
||||
installCmd := cli.NewCommand("install", "Install the pre-commit hook", "", func(cmd *cli.Command, args []string) error {
|
||||
path := "."
|
||||
if len(args) > 0 {
|
||||
path = args[0]
|
||||
}
|
||||
|
||||
svc := lintpkg.NewService()
|
||||
if err := svc.InstallHook(path); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "installed")
|
||||
return nil
|
||||
})
|
||||
|
||||
removeCmd := cli.NewCommand("remove", "Remove the pre-commit hook", "", func(cmd *cli.Command, args []string) error {
|
||||
path := "."
|
||||
if len(args) > 0 {
|
||||
path = args[0]
|
||||
}
|
||||
|
||||
svc := lintpkg.NewService()
|
||||
if err := svc.RemoveHook(path); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "removed")
|
||||
return nil
|
||||
})
|
||||
|
||||
hookCmd.AddCommand(installCmd, removeCmd)
|
||||
return hookCmd
|
||||
}
|
||||
|
||||
func newCheckCommand() *cli.Command {
|
||||
var (
|
||||
checkFormat string
|
||||
checkLang string
|
||||
checkSeverity string
|
||||
format string
|
||||
language string
|
||||
severity string
|
||||
)
|
||||
|
||||
checkCmd := cli.NewCommand("check", "Scan files for pattern matches", "", func(cmd *cli.Command, args []string) error {
|
||||
cat, err := lint.LoadEmbeddedCatalog()
|
||||
cmd := cli.NewCommand("check", "Scan files for pattern matches", "", func(cmd *cli.Command, args []string) error {
|
||||
catalog, err := cataloglint.LoadEmbeddedCatalog()
|
||||
if err != nil {
|
||||
return coreerr.E("cmd.check", "loading catalog", err)
|
||||
}
|
||||
|
||||
rules := cat.Rules
|
||||
|
||||
// Filter by language if specified.
|
||||
if checkLang != "" {
|
||||
rules = cat.ForLanguage(checkLang)
|
||||
rules := catalog.Rules
|
||||
if language != "" {
|
||||
rules = catalog.ForLanguage(language)
|
||||
if len(rules) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "no rules for language %q\n", checkLang)
|
||||
fmt.Fprintf(os.Stderr, "no rules for language %q\n", language)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by severity threshold if specified.
|
||||
if checkSeverity != "" {
|
||||
filtered := (&lintpkg.Catalog{Rules: rules}).AtSeverity(checkSeverity)
|
||||
if severity != "" {
|
||||
filtered := (&lintpkg.Catalog{Rules: rules}).AtSeverity(severity)
|
||||
if len(filtered) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "no rules at severity %q or above\n", checkSeverity)
|
||||
fmt.Fprintf(os.Stderr, "no rules at severity %q or above\n", severity)
|
||||
return nil
|
||||
}
|
||||
rules = filtered
|
||||
|
|
@ -65,49 +249,168 @@ func addLintCommands(root *cli.Command) {
|
|||
paths = []string{"."}
|
||||
}
|
||||
|
||||
var allFindings []lintpkg.Finding
|
||||
for _, p := range paths {
|
||||
info, err := os.Stat(p)
|
||||
var findings []lintpkg.Finding
|
||||
for _, path := range paths {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return coreerr.E("cmd.check", "stat "+p, err)
|
||||
return coreerr.E("cmd.check", "stat "+path, err)
|
||||
}
|
||||
|
||||
var findings []lintpkg.Finding
|
||||
if info.IsDir() {
|
||||
findings, err = scanner.ScanDir(p)
|
||||
} else {
|
||||
findings, err = scanner.ScanFile(p)
|
||||
}
|
||||
pathFindings, err := scanner.ScanDir(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
allFindings = append(allFindings, findings...)
|
||||
findings = append(findings, pathFindings...)
|
||||
continue
|
||||
}
|
||||
|
||||
switch checkFormat {
|
||||
pathFindings, err := scanner.ScanFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
findings = append(findings, pathFindings...)
|
||||
}
|
||||
|
||||
switch format {
|
||||
case "json":
|
||||
return lintpkg.WriteJSON(os.Stdout, allFindings)
|
||||
return lintpkg.WriteJSON(cmd.OutOrStdout(), findings)
|
||||
case "jsonl":
|
||||
return lintpkg.WriteJSONL(os.Stdout, allFindings)
|
||||
return lintpkg.WriteJSONL(cmd.OutOrStdout(), findings)
|
||||
default:
|
||||
lintpkg.WriteText(os.Stdout, allFindings)
|
||||
lintpkg.WriteText(cmd.OutOrStdout(), findings)
|
||||
if format == "text" && len(findings) > 0 {
|
||||
writeLegacySummary(cmd.OutOrStdout(), findings)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
})
|
||||
|
||||
cli.StringFlag(cmd, &format, "format", "f", "text", "Output format: text, json, jsonl")
|
||||
cli.StringFlag(cmd, &language, "lang", "l", "", "Filter rules by language")
|
||||
cli.StringFlag(cmd, &severity, "severity", "s", "", "Minimum severity threshold (info, low, medium, high, critical)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCatalogCommand() *cli.Command {
|
||||
catalogCmd := cli.NewGroup("catalog", "Browse the pattern catalog", "")
|
||||
|
||||
var listLanguage string
|
||||
listCmd := cli.NewCommand("list", "List all rules in the catalog", "", func(cmd *cli.Command, args []string) error {
|
||||
catalog, err := cataloglint.LoadEmbeddedCatalog()
|
||||
if err != nil {
|
||||
return coreerr.E("cmd.catalog.list", "loading catalog", err)
|
||||
}
|
||||
|
||||
if checkFormat == "text" && len(allFindings) > 0 {
|
||||
summary := lintpkg.Summarise(allFindings)
|
||||
fmt.Fprintf(os.Stdout, "\n%d finding(s)", summary.Total)
|
||||
rules := catalog.Rules
|
||||
if listLanguage != "" {
|
||||
rules = catalog.ForLanguage(listLanguage)
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "No rules found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
orderedSeverities := []string{"critical", "high", "medium", "low", "info"}
|
||||
rules = append([]lintpkg.Rule(nil), rules...)
|
||||
sort.Slice(rules, func(left int, right int) bool {
|
||||
if rules[left].Severity == rules[right].Severity {
|
||||
return strings.Compare(rules[left].ID, rules[right].ID) < 0
|
||||
}
|
||||
return strings.Compare(rules[left].Severity, rules[right].Severity) < 0
|
||||
})
|
||||
|
||||
for _, rule := range rules {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%-14s [%-8s] %s\n", rule.ID, rule.Severity, rule.Title)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\n%d rule(s)\n", len(rules))
|
||||
return nil
|
||||
})
|
||||
cli.StringFlag(listCmd, &listLanguage, "lang", "l", "", "Filter by language")
|
||||
|
||||
showCmd := cli.NewCommand("show", "Show details of a specific rule", "", func(cmd *cli.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return coreerr.E("cmd.catalog.show", "rule ID required", nil)
|
||||
}
|
||||
|
||||
catalog, err := cataloglint.LoadEmbeddedCatalog()
|
||||
if err != nil {
|
||||
return coreerr.E("cmd.catalog.show", "loading catalog", err)
|
||||
}
|
||||
|
||||
rule := catalog.ByID(args[0])
|
||||
if rule == nil {
|
||||
return coreerr.E("cmd.catalog.show", "rule "+args[0]+" not found", nil)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(rule, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s\n", string(data))
|
||||
return nil
|
||||
})
|
||||
|
||||
catalogCmd.AddCommand(listCmd, showCmd)
|
||||
return catalogCmd
|
||||
}
|
||||
|
||||
func resolvedOutput(input lintpkg.RunInput) (string, error) {
|
||||
if input.Output != "" {
|
||||
return input.Output, nil
|
||||
}
|
||||
if input.CI {
|
||||
return "github", nil
|
||||
}
|
||||
|
||||
config, _, err := lintpkg.LoadProjectConfig(input.Path, input.Config)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if config.Output != "" {
|
||||
return config.Output, nil
|
||||
}
|
||||
return "text", nil
|
||||
}
|
||||
|
||||
func writeReport(w io.Writer, output string, report lintpkg.Report) error {
|
||||
switch output {
|
||||
case "json":
|
||||
return lintpkg.WriteReportJSON(w, report)
|
||||
case "text":
|
||||
lintpkg.WriteReportText(w, report)
|
||||
return nil
|
||||
case "github":
|
||||
lintpkg.WriteReportGitHub(w, report)
|
||||
return nil
|
||||
case "sarif":
|
||||
return lintpkg.WriteReportSARIF(w, report)
|
||||
default:
|
||||
return coreerr.E("writeReport", "unsupported output format "+output, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w io.Writer, value any) error {
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(value)
|
||||
}
|
||||
|
||||
func writeLegacySummary(w io.Writer, findings []lintpkg.Finding) {
|
||||
summary := lintpkg.Summarise(findings)
|
||||
fmt.Fprintf(w, "\n%d finding(s)", summary.Total)
|
||||
|
||||
orderedSeverities := []string{"critical", "high", "medium", "low", "info", "error", "warning"}
|
||||
seen := make(map[string]bool, len(summary.BySeverity))
|
||||
var parts []string
|
||||
|
||||
for _, sev := range orderedSeverities {
|
||||
count := summary.BySeverity[sev]
|
||||
for _, severity := range orderedSeverities {
|
||||
count := summary.BySeverity[severity]
|
||||
if count == 0 {
|
||||
continue
|
||||
}
|
||||
seen[sev] = true
|
||||
parts = append(parts, fmt.Sprintf("%d %s", count, sev))
|
||||
seen[severity] = true
|
||||
parts = append(parts, fmt.Sprintf("%d %s", count, severity))
|
||||
}
|
||||
|
||||
var extraSeverities []string
|
||||
|
|
@ -125,83 +428,9 @@ func addLintCommands(root *cli.Command) {
|
|||
}
|
||||
parts = append(parts, fmt.Sprintf("%d %s", count, severity))
|
||||
}
|
||||
|
||||
if len(parts) > 0 {
|
||||
fmt.Fprintf(os.Stdout, " (%s)", strings.Join(parts, ", "))
|
||||
fmt.Fprintf(w, " (%s)", strings.Join(parts, ", "))
|
||||
}
|
||||
fmt.Fprintln(os.Stdout)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
cli.StringFlag(checkCmd, &checkFormat, "format", "f", "text", "Output format: text, json, jsonl")
|
||||
cli.StringFlag(checkCmd, &checkLang, "lang", "l", "", "Filter rules by language (e.g. go, php, ts)")
|
||||
cli.StringFlag(checkCmd, &checkSeverity, "severity", "s", "", "Minimum severity threshold (info, low, medium, high, critical)")
|
||||
|
||||
// ── catalog ────────────────────────────────────────────────────────────
|
||||
catalogCmd := cli.NewGroup("catalog", "Browse the pattern catalog", "")
|
||||
|
||||
// catalog list
|
||||
var listLang string
|
||||
|
||||
listCmd := cli.NewCommand("list", "List all rules in the catalog", "", func(cmd *cli.Command, args []string) error {
|
||||
cat, err := lint.LoadEmbeddedCatalog()
|
||||
if err != nil {
|
||||
return coreerr.E("cmd.catalog.list", "loading catalog", err)
|
||||
}
|
||||
|
||||
rules := cat.Rules
|
||||
if listLang != "" {
|
||||
rules = cat.ForLanguage(listLang)
|
||||
}
|
||||
|
||||
if len(rules) == 0 {
|
||||
fmt.Println("No rules found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
rules = append([]lintpkg.Rule(nil), rules...)
|
||||
sort.Slice(rules, func(i, j int) bool {
|
||||
if rules[i].Severity == rules[j].Severity {
|
||||
return strings.Compare(rules[i].ID, rules[j].ID) < 0
|
||||
}
|
||||
return strings.Compare(rules[i].Severity, rules[j].Severity) < 0
|
||||
})
|
||||
|
||||
for _, r := range rules {
|
||||
fmt.Printf("%-14s [%-8s] %s\n", r.ID, r.Severity, r.Title)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\n%d rule(s)\n", len(rules))
|
||||
return nil
|
||||
})
|
||||
|
||||
cli.StringFlag(listCmd, &listLang, "lang", "l", "", "Filter by language")
|
||||
|
||||
// catalog show
|
||||
showCmd := cli.NewCommand("show", "Show details of a specific rule", "", func(cmd *cli.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return coreerr.E("cmd.catalog.show", "rule ID required", nil)
|
||||
}
|
||||
|
||||
cat, err := lint.LoadEmbeddedCatalog()
|
||||
if err != nil {
|
||||
return coreerr.E("cmd.catalog.show", "loading catalog", err)
|
||||
}
|
||||
|
||||
r := cat.ByID(args[0])
|
||||
if r == nil {
|
||||
return coreerr.E("cmd.catalog.show", "rule "+args[0]+" not found", nil)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(r, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cli.Print("%s\n", string(data))
|
||||
return nil
|
||||
})
|
||||
|
||||
catalogCmd.AddCommand(listCmd, showCmd)
|
||||
lintCmd.AddCommand(checkCmd, catalogCmd)
|
||||
root.AddCommand(lintCmd)
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
|
|
|||
164
cmd/core-lint/main_test.go
Normal file
164
cmd/core-lint/main_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
lintpkg "forge.lthn.ai/core/lint/pkg/lint"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var (
|
||||
buildBinaryOnce sync.Once
|
||||
builtBinaryPath string
|
||||
buildBinaryErr error
|
||||
)
|
||||
|
||||
func TestCLI_Run_JSON(t *testing.T) {
|
||||
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, "input.go"), []byte(`package sample
|
||||
|
||||
type service struct{}
|
||||
|
||||
func (service) Process(string) error { return nil }
|
||||
|
||||
func Run() {
|
||||
svc := service{}
|
||||
_ = svc.Process("data")
|
||||
}
|
||||
`), 0o644))
|
||||
|
||||
stdout, stderr, exitCode := runCLI(t, dir, "run", "--output", "json", "--fail-on", "warning", dir)
|
||||
assert.Equal(t, 1, exitCode, stderr)
|
||||
|
||||
var report lintpkg.Report
|
||||
require.NoError(t, json.Unmarshal([]byte(stdout), &report))
|
||||
require.Len(t, report.Findings, 1)
|
||||
assert.Equal(t, "go-cor-003", report.Findings[0].Code)
|
||||
assert.Equal(t, 1, report.Summary.Total)
|
||||
assert.False(t, report.Summary.Passed)
|
||||
}
|
||||
|
||||
func TestCLI_Detect_JSON(t *testing.T) {
|
||||
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, "package.json"), []byte("{}\n"), 0o644))
|
||||
|
||||
stdout, stderr, exitCode := runCLI(t, dir, "detect", "--output", "json", dir)
|
||||
assert.Equal(t, 0, exitCode, stderr)
|
||||
|
||||
var languages []string
|
||||
require.NoError(t, json.Unmarshal([]byte(stdout), &languages))
|
||||
assert.Equal(t, []string{"go", "js"}, languages)
|
||||
}
|
||||
|
||||
func TestCLI_Init_WritesConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
stdout, stderr, exitCode := runCLI(t, dir, "init", dir)
|
||||
assert.Equal(t, 0, exitCode, stderr)
|
||||
assert.Contains(t, stdout, ".core/lint.yaml")
|
||||
|
||||
configPath := filepath.Join(dir, ".core", "lint.yaml")
|
||||
content, err := os.ReadFile(configPath)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(content), "golangci-lint")
|
||||
assert.Contains(t, string(content), "fail_on: error")
|
||||
}
|
||||
|
||||
func TestCLI_HookInstallRemove(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not available")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
runCLIExpectSuccess(t, dir, "git", "init")
|
||||
runCLIExpectSuccess(t, dir, "git", "config", "user.email", "test@example.com")
|
||||
runCLIExpectSuccess(t, dir, "git", "config", "user.name", "Test User")
|
||||
|
||||
_, stderr, exitCode := runCLI(t, dir, "hook", "install", dir)
|
||||
assert.Equal(t, 0, exitCode, stderr)
|
||||
|
||||
hookPath := filepath.Join(dir, ".git", "hooks", "pre-commit")
|
||||
hookContent, err := os.ReadFile(hookPath)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(hookContent), "core-lint run --hook")
|
||||
|
||||
_, stderr, exitCode = runCLI(t, dir, "hook", "remove", dir)
|
||||
assert.Equal(t, 0, exitCode, stderr)
|
||||
|
||||
removedContent, err := os.ReadFile(hookPath)
|
||||
if err == nil {
|
||||
assert.NotContains(t, string(removedContent), "core-lint run --hook")
|
||||
}
|
||||
}
|
||||
|
||||
func runCLI(t *testing.T, workdir string, args ...string) (string, string, int) {
|
||||
t.Helper()
|
||||
|
||||
command := exec.Command(buildCLI(t), args...)
|
||||
command.Dir = workdir
|
||||
command.Env = os.Environ()
|
||||
stdout, err := command.Output()
|
||||
if err == nil {
|
||||
return string(stdout), "", 0
|
||||
}
|
||||
|
||||
exitCode := -1
|
||||
stderr := ""
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
stderr = string(exitErr.Stderr)
|
||||
}
|
||||
|
||||
return string(stdout), stderr, exitCode
|
||||
}
|
||||
|
||||
func runCLIExpectSuccess(t *testing.T, dir string, name string, args ...string) {
|
||||
t.Helper()
|
||||
|
||||
command := exec.Command(name, args...)
|
||||
command.Dir = dir
|
||||
output, err := command.CombinedOutput()
|
||||
require.NoError(t, err, string(output))
|
||||
}
|
||||
|
||||
func buildCLI(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
buildBinaryOnce.Do(func() {
|
||||
repoRoot := repoRoot(t)
|
||||
binDir, err := os.MkdirTemp("", "core-lint-bin-*")
|
||||
if err != nil {
|
||||
buildBinaryErr = err
|
||||
return
|
||||
}
|
||||
|
||||
builtBinaryPath = filepath.Join(binDir, "core-lint")
|
||||
command := exec.Command("go", "build", "-o", builtBinaryPath, "./cmd/core-lint")
|
||||
command.Dir = repoRoot
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
buildBinaryErr = fmt.Errorf("build core-lint: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
})
|
||||
|
||||
require.NoError(t, buildBinaryErr)
|
||||
return builtBinaryPath
|
||||
}
|
||||
|
||||
func repoRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
root, err := filepath.Abs(filepath.Join(".", "..", ".."))
|
||||
require.NoError(t, err)
|
||||
return root
|
||||
}
|
||||
855
pkg/lint/adapter.go
Normal file
855
pkg/lint/adapter.go
Normal file
|
|
@ -0,0 +1,855 @@
|
|||
package lint
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
)
|
||||
|
||||
// Adapter wraps one lint tool and normalises its output to Finding values.
|
||||
type Adapter interface {
|
||||
Name() string
|
||||
Available() bool
|
||||
Languages() []string
|
||||
Command() string
|
||||
Entitlement() string
|
||||
RequiresEntitlement() bool
|
||||
MatchesLanguage(languages []string) bool
|
||||
Category() string
|
||||
Fast() bool
|
||||
Run(ctx context.Context, input RunInput, files []string) AdapterResult
|
||||
}
|
||||
|
||||
// AdapterResult contains one tool execution plus the parsed findings from that run.
|
||||
type AdapterResult struct {
|
||||
Tool ToolRun
|
||||
Findings []Finding
|
||||
}
|
||||
|
||||
type parseFunc func(tool string, category string, output string) []Finding
|
||||
type argsBuilder func(projectPath string, files []string) []string
|
||||
|
||||
// CommandAdapter runs an external binary and parses its stdout/stderr.
|
||||
type CommandAdapter struct {
|
||||
name string
|
||||
binaries []string
|
||||
languages []string
|
||||
category string
|
||||
entitlement string
|
||||
requiresEntitlement bool
|
||||
fast bool
|
||||
buildArgs argsBuilder
|
||||
parseOutput parseFunc
|
||||
}
|
||||
|
||||
// CatalogAdapter wraps the embedded regex rule catalog as a built-in linter.
|
||||
type CatalogAdapter struct{}
|
||||
|
||||
func defaultAdapters() []Adapter {
|
||||
return []Adapter{
|
||||
newCommandAdapter("golangci-lint", []string{"golangci-lint"}, []string{"go"}, "correctness", "", false, true, goPackageArgs("run", "--out-format", "json"), parseJSONDiagnostics),
|
||||
newCommandAdapter("gosec", []string{"gosec"}, []string{"go"}, "security", "lint.security", true, false, goPackageArgs("-fmt", "json"), parseJSONDiagnostics),
|
||||
newCommandAdapter("govulncheck", []string{"govulncheck"}, []string{"go"}, "security", "", false, false, goPackageArgs("-json"), parseGovulncheckDiagnostics),
|
||||
newCommandAdapter("staticcheck", []string{"staticcheck"}, []string{"go"}, "correctness", "", false, true, goPackageArgs("-f", "json"), parseJSONDiagnostics),
|
||||
newCommandAdapter("revive", []string{"revive"}, []string{"go"}, "style", "", false, true, goPackageArgs("-formatter", "json"), parseJSONDiagnostics),
|
||||
newCommandAdapter("errcheck", []string{"errcheck"}, []string{"go"}, "correctness", "", false, true, goPackageArgs(), parseTextDiagnostics),
|
||||
newCommandAdapter("phpstan", []string{"phpstan"}, []string{"php"}, "correctness", "", false, true, pathArgs("analyse", "--error-format", "json"), parseJSONDiagnostics),
|
||||
newCommandAdapter("psalm", []string{"psalm"}, []string{"php"}, "correctness", "", false, true, pathArgs("--output-format", "json"), parseJSONDiagnostics),
|
||||
newCommandAdapter("phpcs", []string{"phpcs"}, []string{"php"}, "style", "", false, true, pathArgs("--report=json"), parseJSONDiagnostics),
|
||||
newCommandAdapter("phpmd", []string{"phpmd"}, []string{"php"}, "correctness", "", false, true, phpmdArgs(), parseJSONDiagnostics),
|
||||
newCommandAdapter("pint", []string{"pint"}, []string{"php"}, "style", "", false, true, pathArgs("--format", "json"), parseJSONDiagnostics),
|
||||
newCommandAdapter("biome", []string{"biome"}, []string{"js", "ts"}, "style", "", false, true, pathArgs("check", "--reporter", "json"), parseJSONDiagnostics),
|
||||
newCommandAdapter("oxlint", []string{"oxlint"}, []string{"js", "ts"}, "style", "", false, true, pathArgs("--format", "json"), parseJSONDiagnostics),
|
||||
newCommandAdapter("eslint", []string{"eslint"}, []string{"js"}, "style", "", false, true, pathArgs("--format", "json"), parseJSONDiagnostics),
|
||||
newCommandAdapter("typescript", []string{"tsc", "typescript"}, []string{"ts"}, "correctness", "", false, true, pathArgs("--pretty", "false"), parseTextDiagnostics),
|
||||
newCommandAdapter("ruff", []string{"ruff"}, []string{"python"}, "style", "", false, true, pathArgs("check", "--output-format", "json"), parseJSONDiagnostics),
|
||||
newCommandAdapter("mypy", []string{"mypy"}, []string{"python"}, "correctness", "", false, true, pathArgs("--output", "json"), parseJSONDiagnostics),
|
||||
newCommandAdapter("bandit", []string{"bandit"}, []string{"python"}, "security", "lint.security", true, false, recursivePathArgs("-f", "json", "-r"), parseJSONDiagnostics),
|
||||
newCommandAdapter("pylint", []string{"pylint"}, []string{"python"}, "style", "", false, true, pathArgs("--output-format", "json"), parseJSONDiagnostics),
|
||||
newCommandAdapter("shellcheck", []string{"shellcheck"}, []string{"shell"}, "correctness", "", false, true, fileArgs("-f", "json"), parseJSONDiagnostics),
|
||||
newCommandAdapter("hadolint", []string{"hadolint"}, []string{"dockerfile"}, "security", "", false, true, fileArgs("-f", "json"), parseJSONDiagnostics),
|
||||
newCommandAdapter("yamllint", []string{"yamllint"}, []string{"yaml"}, "style", "", false, true, pathArgs("-f", "parsable"), parseTextDiagnostics),
|
||||
newCommandAdapter("jsonlint", []string{"jsonlint"}, []string{"json"}, "style", "", false, true, fileArgs(), parseTextDiagnostics),
|
||||
newCommandAdapter("markdownlint", []string{"markdownlint", "markdownlint-cli"}, []string{"markdown"}, "style", "", false, true, pathArgs("--json"), parseJSONDiagnostics),
|
||||
newCommandAdapter("gitleaks", []string{"gitleaks"}, []string{"*"}, "security", "lint.security", true, false, recursivePathArgs("detect", "--no-git", "--report-format", "json", "--source"), parseJSONDiagnostics),
|
||||
newCommandAdapter("trivy", []string{"trivy"}, []string{"*"}, "security", "lint.security", true, false, pathArgs("fs", "--format", "json"), parseJSONDiagnostics),
|
||||
newCommandAdapter("semgrep", []string{"semgrep"}, []string{"*"}, "security", "lint.security", true, false, pathArgs("--json"), parseJSONDiagnostics),
|
||||
newCommandAdapter("syft", []string{"syft"}, []string{"*"}, "compliance", "lint.compliance", true, false, pathArgs("scan", "-o", "json"), parseJSONDiagnostics),
|
||||
newCommandAdapter("grype", []string{"grype"}, []string{"*"}, "security", "lint.compliance", true, false, pathArgs("-o", "json"), parseJSONDiagnostics),
|
||||
newCommandAdapter("scancode", []string{"scancode-toolkit", "scancode"}, []string{"*"}, "compliance", "lint.compliance", true, false, pathArgs("--json"), parseJSONDiagnostics),
|
||||
}
|
||||
}
|
||||
|
||||
func newCatalogAdapter() Adapter {
|
||||
return CatalogAdapter{}
|
||||
}
|
||||
|
||||
func newCommandAdapter(name string, binaries []string, languages []string, category string, entitlement string, requiresEntitlement bool, fast bool, builder argsBuilder, parser parseFunc) Adapter {
|
||||
return CommandAdapter{
|
||||
name: name,
|
||||
binaries: binaries,
|
||||
languages: languages,
|
||||
category: category,
|
||||
entitlement: entitlement,
|
||||
requiresEntitlement: requiresEntitlement,
|
||||
fast: fast,
|
||||
buildArgs: builder,
|
||||
parseOutput: parser,
|
||||
}
|
||||
}
|
||||
|
||||
func (adapter CommandAdapter) Name() string { return adapter.name }
|
||||
|
||||
func (adapter CommandAdapter) Available() bool {
|
||||
_, ok := adapter.availableBinary()
|
||||
return ok
|
||||
}
|
||||
|
||||
func (adapter CommandAdapter) Languages() []string {
|
||||
return append([]string(nil), adapter.languages...)
|
||||
}
|
||||
|
||||
func (adapter CommandAdapter) Command() string {
|
||||
if len(adapter.binaries) == 0 {
|
||||
return ""
|
||||
}
|
||||
return adapter.binaries[0]
|
||||
}
|
||||
|
||||
func (adapter CommandAdapter) Entitlement() string { return adapter.entitlement }
|
||||
|
||||
func (adapter CommandAdapter) RequiresEntitlement() bool { return adapter.requiresEntitlement }
|
||||
|
||||
func (adapter CommandAdapter) MatchesLanguage(languages []string) bool {
|
||||
if len(adapter.languages) == 0 || len(languages) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(adapter.languages) == 1 && adapter.languages[0] == "*" {
|
||||
return true
|
||||
}
|
||||
for _, language := range languages {
|
||||
if strings.EqualFold(language, adapter.category) {
|
||||
return true
|
||||
}
|
||||
for _, supported := range adapter.languages {
|
||||
if supported == language {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (adapter CommandAdapter) Category() string { return adapter.category }
|
||||
|
||||
func (adapter CommandAdapter) Fast() bool { return adapter.fast }
|
||||
|
||||
func (adapter CommandAdapter) Run(ctx context.Context, input RunInput, files []string) AdapterResult {
|
||||
startedAt := time.Now()
|
||||
result := AdapterResult{
|
||||
Tool: ToolRun{
|
||||
Name: adapter.name,
|
||||
},
|
||||
}
|
||||
|
||||
binary, ok := adapter.availableBinary()
|
||||
if !ok {
|
||||
result.Tool.Status = "skipped"
|
||||
result.Tool.Duration = "0s"
|
||||
return result
|
||||
}
|
||||
|
||||
runContext, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
args := adapter.buildArgs(input.Path, files)
|
||||
stdout, stderr, exitCode, runErr := runCommand(runContext, input.Path, binary, args)
|
||||
|
||||
result.Tool.Version = ""
|
||||
result.Tool.Duration = time.Since(startedAt).Round(time.Millisecond).String()
|
||||
|
||||
if errors.Is(runContext.Err(), context.DeadlineExceeded) {
|
||||
result.Tool.Status = "timeout"
|
||||
return result
|
||||
}
|
||||
|
||||
output := strings.TrimSpace(stdout)
|
||||
if strings.TrimSpace(stderr) != "" {
|
||||
if output != "" {
|
||||
output += "\n" + strings.TrimSpace(stderr)
|
||||
} else {
|
||||
output = strings.TrimSpace(stderr)
|
||||
}
|
||||
}
|
||||
|
||||
if adapter.parseOutput != nil && output != "" {
|
||||
result.Findings = adapter.parseOutput(adapter.name, adapter.category, output)
|
||||
}
|
||||
if len(result.Findings) == 0 && output != "" {
|
||||
result.Findings = parseTextDiagnostics(adapter.name, adapter.category, output)
|
||||
}
|
||||
if len(result.Findings) == 0 && runErr != nil {
|
||||
result.Findings = []Finding{{
|
||||
Tool: adapter.name,
|
||||
Severity: defaultSeverityForCategory(adapter.category),
|
||||
Code: "command-failed",
|
||||
Message: strings.TrimSpace(firstNonEmpty(output, runErr.Error())),
|
||||
Category: adapter.category,
|
||||
}}
|
||||
}
|
||||
|
||||
for index := range result.Findings {
|
||||
if result.Findings[index].Tool == "" {
|
||||
result.Findings[index].Tool = adapter.name
|
||||
}
|
||||
if result.Findings[index].Category == "" {
|
||||
result.Findings[index].Category = adapter.category
|
||||
}
|
||||
if result.Findings[index].Severity == "" {
|
||||
result.Findings[index].Severity = defaultSeverityForCategory(adapter.category)
|
||||
} else {
|
||||
result.Findings[index].Severity = normaliseSeverity(result.Findings[index].Severity)
|
||||
}
|
||||
}
|
||||
|
||||
result.Tool.Findings = len(result.Findings)
|
||||
switch {
|
||||
case runErr != nil || exitCode != 0 || len(result.Findings) > 0:
|
||||
result.Tool.Status = "failed"
|
||||
default:
|
||||
result.Tool.Status = "passed"
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (adapter CommandAdapter) availableBinary() (string, bool) {
|
||||
for _, binary := range adapter.binaries {
|
||||
path, err := exec.LookPath(binary)
|
||||
if err == nil {
|
||||
return path, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (CatalogAdapter) Name() string { return "catalog" }
|
||||
|
||||
func (CatalogAdapter) Available() bool { return true }
|
||||
|
||||
func (CatalogAdapter) Languages() []string { return []string{"go"} }
|
||||
|
||||
func (CatalogAdapter) Command() string { return "catalog" }
|
||||
|
||||
func (CatalogAdapter) Entitlement() string { return "" }
|
||||
|
||||
func (CatalogAdapter) RequiresEntitlement() bool { return false }
|
||||
|
||||
func (CatalogAdapter) MatchesLanguage(languages []string) bool {
|
||||
return len(languages) == 0 || slicesContains(languages, "go")
|
||||
}
|
||||
|
||||
func (CatalogAdapter) Category() string { return "correctness" }
|
||||
|
||||
func (CatalogAdapter) Fast() bool { return true }
|
||||
|
||||
func (CatalogAdapter) Run(_ context.Context, input RunInput, files []string) AdapterResult {
|
||||
startedAt := time.Now()
|
||||
result := AdapterResult{
|
||||
Tool: ToolRun{
|
||||
Name: "catalog",
|
||||
},
|
||||
}
|
||||
|
||||
catalog, err := loadBuiltinCatalog()
|
||||
if err != nil {
|
||||
result.Tool.Status = "failed"
|
||||
result.Tool.Duration = time.Since(startedAt).Round(time.Millisecond).String()
|
||||
result.Findings = []Finding{{
|
||||
Tool: "catalog",
|
||||
Severity: "error",
|
||||
Code: "catalog-load",
|
||||
Message: err.Error(),
|
||||
Category: "correctness",
|
||||
}}
|
||||
result.Tool.Findings = len(result.Findings)
|
||||
return result
|
||||
}
|
||||
|
||||
rules := catalog.Rules
|
||||
if input.Category != "" {
|
||||
rules = filterRulesByTag(rules, input.Category)
|
||||
}
|
||||
|
||||
scanner, err := NewScanner(rules)
|
||||
if err != nil {
|
||||
result.Tool.Status = "failed"
|
||||
result.Tool.Duration = time.Since(startedAt).Round(time.Millisecond).String()
|
||||
result.Findings = []Finding{{
|
||||
Tool: "catalog",
|
||||
Severity: "error",
|
||||
Code: "catalog-scan",
|
||||
Message: err.Error(),
|
||||
Category: "correctness",
|
||||
}}
|
||||
result.Tool.Findings = len(result.Findings)
|
||||
return result
|
||||
}
|
||||
|
||||
var findings []Finding
|
||||
if len(files) > 0 {
|
||||
for _, file := range files {
|
||||
scanPath := file
|
||||
if !filepath.IsAbs(scanPath) {
|
||||
scanPath = filepath.Join(input.Path, file)
|
||||
}
|
||||
fileFindings, scanErr := scanner.ScanFile(scanPath)
|
||||
if scanErr != nil {
|
||||
continue
|
||||
}
|
||||
findings = append(findings, fileFindings...)
|
||||
}
|
||||
} else {
|
||||
findings, _ = scanner.ScanDir(input.Path)
|
||||
}
|
||||
|
||||
for index := range findings {
|
||||
rule := catalog.ByID(findings[index].RuleID)
|
||||
findings[index].Tool = "catalog"
|
||||
findings[index].Code = findings[index].RuleID
|
||||
findings[index].Message = findings[index].Title
|
||||
findings[index].Severity = normaliseSeverity(findings[index].Severity)
|
||||
if rule != nil {
|
||||
findings[index].Category = ruleCategory(*rule)
|
||||
}
|
||||
}
|
||||
|
||||
result.Findings = findings
|
||||
result.Tool.Findings = len(findings)
|
||||
result.Tool.Duration = time.Since(startedAt).Round(time.Millisecond).String()
|
||||
if len(findings) > 0 {
|
||||
result.Tool.Status = "failed"
|
||||
} else {
|
||||
result.Tool.Status = "passed"
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func loadBuiltinCatalog() (*Catalog, error) {
|
||||
rules, err := ParseRules([]byte(defaultCatalogRulesYAML))
|
||||
if err != nil {
|
||||
return nil, coreerr.E("loadBuiltinCatalog", "parse embedded fallback rules", err)
|
||||
}
|
||||
return &Catalog{Rules: rules}, nil
|
||||
}
|
||||
|
||||
func goPackageArgs(prefix ...string) argsBuilder {
|
||||
return func(_ string, files []string) []string {
|
||||
args := append([]string(nil), prefix...)
|
||||
if len(files) > 0 {
|
||||
return append(args, files...)
|
||||
}
|
||||
return append(args, "./...")
|
||||
}
|
||||
}
|
||||
|
||||
func pathArgs(prefix ...string) argsBuilder {
|
||||
return func(_ string, files []string) []string {
|
||||
args := append([]string(nil), prefix...)
|
||||
if len(files) > 0 {
|
||||
return append(args, files...)
|
||||
}
|
||||
return append(args, ".")
|
||||
}
|
||||
}
|
||||
|
||||
func recursivePathArgs(prefix ...string) argsBuilder {
|
||||
return func(_ string, files []string) []string {
|
||||
args := append([]string(nil), prefix...)
|
||||
if len(files) > 0 {
|
||||
return append(args, files...)
|
||||
}
|
||||
return append(args, ".")
|
||||
}
|
||||
}
|
||||
|
||||
func fileArgs(prefix ...string) argsBuilder {
|
||||
return func(_ string, files []string) []string {
|
||||
args := append([]string(nil), prefix...)
|
||||
if len(files) > 0 {
|
||||
return append(args, files...)
|
||||
}
|
||||
return append(args, ".")
|
||||
}
|
||||
}
|
||||
|
||||
func phpmdArgs() argsBuilder {
|
||||
return func(_ string, files []string) []string {
|
||||
target := "."
|
||||
if len(files) > 0 {
|
||||
target = strings.Join(files, ",")
|
||||
}
|
||||
return []string{target, "json", "cleancode,codesize,controversial,design,naming,unusedcode"}
|
||||
}
|
||||
}
|
||||
|
||||
func runCommand(ctx context.Context, workingDir string, binary string, args []string) (string, string, int, error) {
|
||||
command := exec.CommandContext(ctx, binary, args...)
|
||||
if workingDir != "" {
|
||||
command.Dir = workingDir
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
command.Stdout = &stdout
|
||||
command.Stderr = &stderr
|
||||
|
||||
err := command.Run()
|
||||
if err == nil {
|
||||
return stdout.String(), stderr.String(), 0, nil
|
||||
}
|
||||
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return stdout.String(), stderr.String(), exitErr.ExitCode(), err
|
||||
}
|
||||
|
||||
return stdout.String(), stderr.String(), -1, err
|
||||
}
|
||||
|
||||
func parseGovulncheckDiagnostics(tool string, category string, output string) []Finding {
|
||||
result, err := ParseVulnCheckJSON(output, "")
|
||||
if err != nil || result == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var findings []Finding
|
||||
for _, vuln := range result.Findings {
|
||||
message := strings.TrimSpace(firstNonEmpty(vuln.Description, vuln.Package))
|
||||
if message == "" {
|
||||
message = vuln.ID
|
||||
}
|
||||
findings = append(findings, Finding{
|
||||
Tool: tool,
|
||||
File: vuln.Package,
|
||||
Severity: "error",
|
||||
Code: vuln.ID,
|
||||
Message: message,
|
||||
Category: category,
|
||||
})
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
func parseJSONDiagnostics(tool string, category string, output string) []Finding {
|
||||
decoder := json.NewDecoder(strings.NewReader(output))
|
||||
var findings []Finding
|
||||
|
||||
for {
|
||||
var value any
|
||||
err := decoder.Decode(&value)
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
findings = append(findings, collectJSONDiagnostics(tool, category, value)...)
|
||||
}
|
||||
|
||||
return dedupeFindings(findings)
|
||||
}
|
||||
|
||||
func collectJSONDiagnostics(tool string, category string, value any) []Finding {
|
||||
switch typed := value.(type) {
|
||||
case []any:
|
||||
var findings []Finding
|
||||
for _, child := range typed {
|
||||
findings = append(findings, collectJSONDiagnostics(tool, category, child)...)
|
||||
}
|
||||
return findings
|
||||
case map[string]any:
|
||||
var findings []Finding
|
||||
if finding, ok := findingFromMap(tool, category, typed); ok {
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
for _, child := range typed {
|
||||
findings = append(findings, collectJSONDiagnostics(tool, category, child)...)
|
||||
}
|
||||
return findings
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func findingFromMap(tool string, category string, fields map[string]any) (Finding, bool) {
|
||||
file := firstStringPath(fields,
|
||||
[]string{"file"},
|
||||
[]string{"File"},
|
||||
[]string{"filename"},
|
||||
[]string{"path"},
|
||||
[]string{"location", "path"},
|
||||
[]string{"artifactLocation", "uri"},
|
||||
[]string{"Target"},
|
||||
)
|
||||
line := firstIntPath(fields,
|
||||
[]string{"line"},
|
||||
[]string{"Line"},
|
||||
[]string{"startLine"},
|
||||
[]string{"StartLine"},
|
||||
[]string{"region", "startLine"},
|
||||
[]string{"location", "start", "line"},
|
||||
[]string{"Start", "Line"},
|
||||
)
|
||||
column := firstIntPath(fields,
|
||||
[]string{"column"},
|
||||
[]string{"Column"},
|
||||
[]string{"col"},
|
||||
[]string{"startColumn"},
|
||||
[]string{"StartColumn"},
|
||||
[]string{"region", "startColumn"},
|
||||
[]string{"location", "start", "column"},
|
||||
)
|
||||
code := firstStringPath(fields,
|
||||
[]string{"code"},
|
||||
[]string{"Code"},
|
||||
[]string{"rule"},
|
||||
[]string{"Rule"},
|
||||
[]string{"rule_id"},
|
||||
[]string{"RuleID"},
|
||||
[]string{"check_id"},
|
||||
[]string{"checkId"},
|
||||
[]string{"id"},
|
||||
[]string{"ID"},
|
||||
)
|
||||
message := firstStringPath(fields,
|
||||
[]string{"message"},
|
||||
[]string{"Message"},
|
||||
[]string{"description"},
|
||||
[]string{"Description"},
|
||||
[]string{"title"},
|
||||
[]string{"Title"},
|
||||
[]string{"message", "text"},
|
||||
[]string{"Message", "Text"},
|
||||
)
|
||||
severity := firstStringPath(fields,
|
||||
[]string{"severity"},
|
||||
[]string{"Severity"},
|
||||
[]string{"level"},
|
||||
[]string{"Level"},
|
||||
[]string{"type"},
|
||||
[]string{"Type"},
|
||||
)
|
||||
|
||||
if message == "" && code == "" {
|
||||
return Finding{}, false
|
||||
}
|
||||
if file == "" && line == 0 && !strings.Contains(strings.ToLower(category), "security") && code == "" {
|
||||
return Finding{}, false
|
||||
}
|
||||
|
||||
return Finding{
|
||||
Tool: tool,
|
||||
File: file,
|
||||
Line: line,
|
||||
Column: column,
|
||||
Severity: firstNonEmpty(normaliseSeverity(severity), defaultSeverityForCategory(category)),
|
||||
Code: code,
|
||||
Message: message,
|
||||
Category: category,
|
||||
}, true
|
||||
}
|
||||
|
||||
func parseTextDiagnostics(tool string, category string, output string) []Finding {
|
||||
var findings []Finding
|
||||
|
||||
for line := range strings.SplitSeq(strings.TrimSpace(output), "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if finding, ok := parseTextDiagnosticLine(tool, category, trimmed); ok {
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
}
|
||||
|
||||
if len(findings) == 0 && strings.TrimSpace(output) != "" {
|
||||
findings = append(findings, Finding{
|
||||
Tool: tool,
|
||||
Severity: defaultSeverityForCategory(category),
|
||||
Code: "diagnostic",
|
||||
Message: strings.TrimSpace(output),
|
||||
Category: category,
|
||||
})
|
||||
}
|
||||
|
||||
return dedupeFindings(findings)
|
||||
}
|
||||
|
||||
func parseTextDiagnosticLine(tool string, category string, line string) (Finding, bool) {
|
||||
segments := strings.Split(line, ":")
|
||||
if len(segments) < 3 {
|
||||
return Finding{}, false
|
||||
}
|
||||
|
||||
lineNumber, lineErr := strconv.Atoi(strings.TrimSpace(segments[1]))
|
||||
if lineErr != nil {
|
||||
return Finding{}, false
|
||||
}
|
||||
|
||||
columnNumber := 0
|
||||
messageIndex := 2
|
||||
if len(segments) > 3 {
|
||||
if parsedColumn, columnErr := strconv.Atoi(strings.TrimSpace(segments[2])); columnErr == nil {
|
||||
columnNumber = parsedColumn
|
||||
messageIndex = 3
|
||||
}
|
||||
}
|
||||
|
||||
message := strings.TrimSpace(strings.Join(segments[messageIndex:], ":"))
|
||||
if message == "" {
|
||||
return Finding{}, false
|
||||
}
|
||||
|
||||
severity := defaultSeverityForCategory(category)
|
||||
switch {
|
||||
case strings.Contains(strings.ToLower(message), "warning"):
|
||||
severity = "warning"
|
||||
case strings.Contains(strings.ToLower(message), "error"):
|
||||
severity = "error"
|
||||
}
|
||||
|
||||
return Finding{
|
||||
Tool: tool,
|
||||
File: filepath.ToSlash(strings.TrimSpace(segments[0])),
|
||||
Line: lineNumber,
|
||||
Column: columnNumber,
|
||||
Severity: severity,
|
||||
Code: "diagnostic",
|
||||
Message: message,
|
||||
Category: category,
|
||||
}, true
|
||||
}
|
||||
|
||||
func firstStringPath(fields map[string]any, paths ...[]string) string {
|
||||
for _, path := range paths {
|
||||
if value, ok := lookupPath(fields, path); ok {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(typed) != "" {
|
||||
return strings.TrimSpace(typed)
|
||||
}
|
||||
case json.Number:
|
||||
return typed.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstIntPath(fields map[string]any, paths ...[]string) int {
|
||||
for _, path := range paths {
|
||||
if value, ok := lookupPath(fields, path); ok {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed
|
||||
case int64:
|
||||
return int(typed)
|
||||
case float64:
|
||||
return int(typed)
|
||||
case json.Number:
|
||||
parsed, _ := typed.Int64()
|
||||
return int(parsed)
|
||||
case string:
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(typed))
|
||||
if err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func lookupPath(fields map[string]any, path []string) (any, bool) {
|
||||
current := any(fields)
|
||||
for _, segment := range path {
|
||||
object, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
value, found := mapValue(object, segment)
|
||||
if !found {
|
||||
return nil, false
|
||||
}
|
||||
current = value
|
||||
}
|
||||
return current, true
|
||||
}
|
||||
|
||||
func mapValue(fields map[string]any, key string) (any, bool) {
|
||||
if value, ok := fields[key]; ok {
|
||||
return value, true
|
||||
}
|
||||
lowerKey := strings.ToLower(key)
|
||||
for fieldKey, value := range fields {
|
||||
if strings.ToLower(fieldKey) == lowerKey {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func dedupeFindings(findings []Finding) []Finding {
|
||||
seen := make(map[string]bool)
|
||||
var deduped []Finding
|
||||
for _, finding := range findings {
|
||||
key := strings.Join([]string{
|
||||
finding.Tool,
|
||||
finding.File,
|
||||
strconv.Itoa(finding.Line),
|
||||
strconv.Itoa(finding.Column),
|
||||
finding.Code,
|
||||
finding.Message,
|
||||
}, "|")
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
deduped = append(deduped, finding)
|
||||
}
|
||||
return deduped
|
||||
}
|
||||
|
||||
func filterRulesByTag(rules []Rule, tag string) []Rule {
|
||||
var filtered []Rule
|
||||
for _, rule := range rules {
|
||||
for _, currentTag := range rule.Tags {
|
||||
if currentTag == tag {
|
||||
filtered = append(filtered, rule)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func ruleCategory(rule Rule) string {
|
||||
for _, tag := range rule.Tags {
|
||||
switch tag {
|
||||
case "security", "style", "correctness", "performance", "compliance":
|
||||
return tag
|
||||
}
|
||||
}
|
||||
return "correctness"
|
||||
}
|
||||
|
||||
func normaliseSeverity(severity string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(severity)) {
|
||||
case "critical", "high", "error", "errors":
|
||||
return "error"
|
||||
case "medium", "low", "warning", "warn":
|
||||
return "warning"
|
||||
case "info", "note":
|
||||
return "info"
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(severity))
|
||||
}
|
||||
}
|
||||
|
||||
func defaultSeverityForCategory(category string) string {
|
||||
switch category {
|
||||
case "security":
|
||||
return "error"
|
||||
case "compliance":
|
||||
return "warning"
|
||||
default:
|
||||
return "warning"
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func slicesContains(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const defaultCatalogRulesYAML = `
|
||||
- id: go-cor-003
|
||||
title: "Silent error swallowing with blank identifier"
|
||||
severity: medium
|
||||
languages: [go]
|
||||
tags: [correctness, errors]
|
||||
pattern: '^\s*_\s*=\s*\w+\.\w+\('
|
||||
exclude_pattern: 'defer|Close\(|Flush\('
|
||||
fix: "Handle the error explicitly — log it, return it, or document why it is safe to discard"
|
||||
detection: regex
|
||||
auto_fixable: false
|
||||
|
||||
- id: go-cor-004
|
||||
title: "Panic in library code"
|
||||
severity: high
|
||||
languages: [go]
|
||||
tags: [correctness, panic]
|
||||
pattern: '\bpanic\('
|
||||
exclude_pattern: '_test\.go|// unreachable|Must\w+\('
|
||||
fix: "Return an error instead of panicking — panics in libraries crash the caller"
|
||||
detection: regex
|
||||
auto_fixable: false
|
||||
|
||||
- id: go-sec-001
|
||||
title: "SQL wildcard injection in LIKE clauses"
|
||||
severity: high
|
||||
languages: [go]
|
||||
tags: [security, injection]
|
||||
pattern: 'LIKE\s+\?.*["%].*\+'
|
||||
fix: "Use parameterised LIKE with EscapeLike() helper to sanitise wildcard characters"
|
||||
detection: regex
|
||||
auto_fixable: false
|
||||
|
||||
- id: go-sec-002
|
||||
title: "Path traversal via filepath.Join"
|
||||
severity: high
|
||||
languages: [go]
|
||||
tags: [security, path-traversal]
|
||||
pattern: 'filepath\.Join\(.*,\s*\w+\)'
|
||||
exclude_pattern: 'filepath\.Clean|securejoin|ValidatePath'
|
||||
fix: "Validate the path component or use securejoin to prevent directory traversal"
|
||||
detection: regex
|
||||
auto_fixable: false
|
||||
|
||||
- id: go-sec-004
|
||||
title: "Non-constant-time authentication comparison"
|
||||
severity: critical
|
||||
languages: [go]
|
||||
tags: [security, timing-attack]
|
||||
pattern: '==\s*\w*(token|key|secret|password|hash|digest|hmac|mac|sig)'
|
||||
exclude_pattern: 'subtle\.ConstantTimeCompare|hmac\.Equal'
|
||||
fix: "Use subtle.ConstantTimeCompare() or hmac.Equal() for timing-safe comparison"
|
||||
detection: regex
|
||||
auto_fixable: false
|
||||
`
|
||||
161
pkg/lint/config.go
Normal file
161
pkg/lint/config.go
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
package lint
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// DefaultConfigPath is the repo-local config path used by core-lint.
|
||||
const DefaultConfigPath = ".core/lint.yaml"
|
||||
|
||||
// LintConfig defines which tools run for each language and how results fail the build.
|
||||
//
|
||||
// cfg := lint.DefaultConfig()
|
||||
// cfg.FailOn = "warning"
|
||||
type LintConfig struct {
|
||||
Lint ToolGroups `yaml:"lint" json:"lint"`
|
||||
Output string `yaml:"output" json:"output"`
|
||||
FailOn string `yaml:"fail_on" json:"fail_on"`
|
||||
Paths []string `yaml:"paths" json:"paths"`
|
||||
Exclude []string `yaml:"exclude" json:"exclude"`
|
||||
Schedules map[string]Schedule `yaml:"schedules,omitempty" json:"schedules,omitempty"`
|
||||
}
|
||||
|
||||
// ToolGroups maps config groups to tool names.
|
||||
type ToolGroups struct {
|
||||
Go []string `yaml:"go,omitempty" json:"go,omitempty"`
|
||||
PHP []string `yaml:"php,omitempty" json:"php,omitempty"`
|
||||
JS []string `yaml:"js,omitempty" json:"js,omitempty"`
|
||||
TS []string `yaml:"ts,omitempty" json:"ts,omitempty"`
|
||||
Python []string `yaml:"python,omitempty" json:"python,omitempty"`
|
||||
Infra []string `yaml:"infra,omitempty" json:"infra,omitempty"`
|
||||
Security []string `yaml:"security,omitempty" json:"security,omitempty"`
|
||||
Compliance []string `yaml:"compliance,omitempty" json:"compliance,omitempty"`
|
||||
}
|
||||
|
||||
// Schedule declares a named lint run for external schedulers.
|
||||
type Schedule struct {
|
||||
Cron string `yaml:"cron" json:"cron"`
|
||||
Categories []string `yaml:"categories,omitempty" json:"categories,omitempty"`
|
||||
Output string `yaml:"output,omitempty" json:"output,omitempty"`
|
||||
Paths []string `yaml:"paths,omitempty" json:"paths,omitempty"`
|
||||
FailOn string `yaml:"fail_on,omitempty" json:"fail_on,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultConfig returns the RFC baseline config used when a repo has no local file yet.
|
||||
func DefaultConfig() LintConfig {
|
||||
return LintConfig{
|
||||
Lint: ToolGroups{
|
||||
Go: []string{
|
||||
"golangci-lint",
|
||||
"gosec",
|
||||
"govulncheck",
|
||||
"staticcheck",
|
||||
"revive",
|
||||
"errcheck",
|
||||
},
|
||||
PHP: []string{
|
||||
"phpstan",
|
||||
"psalm",
|
||||
"phpcs",
|
||||
"phpmd",
|
||||
"pint",
|
||||
},
|
||||
JS: []string{
|
||||
"biome",
|
||||
"oxlint",
|
||||
"eslint",
|
||||
"prettier",
|
||||
},
|
||||
TS: []string{
|
||||
"biome",
|
||||
"oxlint",
|
||||
"typescript",
|
||||
},
|
||||
Python: []string{
|
||||
"ruff",
|
||||
"mypy",
|
||||
"bandit",
|
||||
"pylint",
|
||||
},
|
||||
Infra: []string{
|
||||
"shellcheck",
|
||||
"hadolint",
|
||||
"yamllint",
|
||||
"jsonlint",
|
||||
"markdownlint",
|
||||
},
|
||||
Security: []string{
|
||||
"gitleaks",
|
||||
"trivy",
|
||||
"gosec",
|
||||
"bandit",
|
||||
"semgrep",
|
||||
},
|
||||
Compliance: []string{
|
||||
"syft",
|
||||
"grype",
|
||||
"scancode",
|
||||
},
|
||||
},
|
||||
Output: "json",
|
||||
FailOn: "error",
|
||||
Paths: []string{"."},
|
||||
Exclude: []string{"vendor/", "node_modules/", ".core/"},
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultConfigYAML marshals the default config as the file content for `core-lint init`.
|
||||
func DefaultConfigYAML() (string, error) {
|
||||
data, err := yaml.Marshal(DefaultConfig())
|
||||
if err != nil {
|
||||
return "", coreerr.E("DefaultConfigYAML", "marshal default config", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// ResolveConfigPath resolves an explicit config path or the repo-local default.
|
||||
//
|
||||
// path := lint.ResolveConfigPath(".", "")
|
||||
func ResolveConfigPath(projectPath string, override string) string {
|
||||
if projectPath == "" {
|
||||
projectPath = "."
|
||||
}
|
||||
if override == "" {
|
||||
return filepath.Join(projectPath, DefaultConfigPath)
|
||||
}
|
||||
if filepath.IsAbs(override) {
|
||||
return override
|
||||
}
|
||||
return filepath.Join(projectPath, override)
|
||||
}
|
||||
|
||||
// LoadProjectConfig reads `.core/lint.yaml` if present, otherwise returns the default config.
|
||||
//
|
||||
// cfg, path, err := lint.LoadProjectConfig(".", "")
|
||||
func LoadProjectConfig(projectPath string, override string) (LintConfig, string, error) {
|
||||
config := DefaultConfig()
|
||||
path := ResolveConfigPath(projectPath, override)
|
||||
|
||||
_, err := coreio.Local.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return config, "", nil
|
||||
}
|
||||
return config, "", coreerr.E("LoadProjectConfig", "stat "+path, err)
|
||||
}
|
||||
|
||||
raw, err := coreio.Local.Read(path)
|
||||
if err != nil {
|
||||
return config, "", coreerr.E("LoadProjectConfig", "read "+path, err)
|
||||
}
|
||||
if err := yaml.Unmarshal([]byte(raw), &config); err != nil {
|
||||
return config, "", coreerr.E("LoadProjectConfig", "parse "+path, err)
|
||||
}
|
||||
|
||||
return config, path, nil
|
||||
}
|
||||
117
pkg/lint/detect_project.go
Normal file
117
pkg/lint/detect_project.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
package lint
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var projectLanguageByExtension = map[string]string{
|
||||
".go": "go",
|
||||
".php": "php",
|
||||
".js": "js",
|
||||
".jsx": "js",
|
||||
".ts": "ts",
|
||||
".tsx": "ts",
|
||||
".py": "python",
|
||||
".rs": "rust",
|
||||
".sh": "shell",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".json": "json",
|
||||
".md": "markdown",
|
||||
}
|
||||
|
||||
// Detect returns the project languages inferred from markers and file names.
|
||||
//
|
||||
// langs := lint.Detect(".")
|
||||
func Detect(path string) []string {
|
||||
if path == "" {
|
||||
path = "."
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
recordDetectedPath(seen, path)
|
||||
return sortedDetectedLanguages(seen)
|
||||
}
|
||||
|
||||
_ = filepath.WalkDir(path, func(currentPath string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if entry.IsDir() {
|
||||
if currentPath != path && IsExcludedDir(entry.Name()) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
recordDetectedPath(seen, currentPath)
|
||||
return nil
|
||||
})
|
||||
|
||||
return sortedDetectedLanguages(seen)
|
||||
}
|
||||
|
||||
func detectFromFiles(files []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
for _, file := range files {
|
||||
recordDetectedPath(seen, file)
|
||||
}
|
||||
return sortedDetectedLanguages(seen)
|
||||
}
|
||||
|
||||
func recordDetectedPath(seen map[string]bool, path string) {
|
||||
name := filepath.Base(path)
|
||||
matchedMarker := false
|
||||
|
||||
switch {
|
||||
case name == "go.mod":
|
||||
seen["go"] = true
|
||||
matchedMarker = true
|
||||
case name == "composer.json":
|
||||
seen["php"] = true
|
||||
matchedMarker = true
|
||||
case name == "package.json":
|
||||
seen["js"] = true
|
||||
matchedMarker = true
|
||||
case name == "tsconfig.json":
|
||||
seen["ts"] = true
|
||||
matchedMarker = true
|
||||
case name == "requirements.txt", name == "pyproject.toml":
|
||||
seen["python"] = true
|
||||
matchedMarker = true
|
||||
case name == "Cargo.toml":
|
||||
seen["rust"] = true
|
||||
matchedMarker = true
|
||||
case strings.HasPrefix(name, "Dockerfile"):
|
||||
seen["dockerfile"] = true
|
||||
matchedMarker = true
|
||||
}
|
||||
|
||||
if matchedMarker {
|
||||
return
|
||||
}
|
||||
|
||||
if lang, ok := projectLanguageByExtension[strings.ToLower(filepath.Ext(name))]; ok {
|
||||
seen[lang] = true
|
||||
}
|
||||
}
|
||||
|
||||
func sortedDetectedLanguages(seen map[string]bool) []string {
|
||||
var languages []string
|
||||
for language := range seen {
|
||||
languages = append(languages, language)
|
||||
}
|
||||
slices.Sort(languages)
|
||||
return languages
|
||||
}
|
||||
44
pkg/lint/detect_project_test.go
Normal file
44
pkg/lint/detect_project_test.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package lint
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDetect_Good_ProjectMarkersAndFiles(t *testing.T) {
|
||||
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, "package.json"), []byte("{}\n"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "tsconfig.json"), []byte("{}\n"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte("ruff\n"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte("FROM scratch\n"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "run.sh"), []byte("#!/bin/sh\n"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Test\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))
|
||||
|
||||
assert.Equal(t,
|
||||
[]string{"dockerfile", "go", "js", "markdown", "python", "shell", "ts"},
|
||||
Detect(dir),
|
||||
)
|
||||
}
|
||||
|
||||
func TestDetectFromFiles_Good(t *testing.T) {
|
||||
files := []string{
|
||||
"main.go",
|
||||
"web/app.ts",
|
||||
"Dockerfile",
|
||||
"scripts/run.sh",
|
||||
"docs/index.md",
|
||||
}
|
||||
|
||||
assert.Equal(t,
|
||||
[]string{"dockerfile", "go", "markdown", "shell", "ts"},
|
||||
detectFromFiles(files),
|
||||
)
|
||||
}
|
||||
|
|
@ -10,13 +10,18 @@ import (
|
|||
|
||||
// Finding represents a single match of a rule against a source file.
|
||||
type Finding struct {
|
||||
RuleID string `json:"rule_id"`
|
||||
Title string `json:"title"`
|
||||
Severity string `json:"severity"`
|
||||
Tool string `json:"tool,omitempty"`
|
||||
File string `json:"file"`
|
||||
Line int `json:"line"`
|
||||
Match string `json:"match"`
|
||||
Fix string `json:"fix"`
|
||||
Column int `json:"column,omitempty"`
|
||||
Severity string `json:"severity"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Fix string `json:"fix,omitempty"`
|
||||
RuleID string `json:"rule_id,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Match string `json:"match,omitempty"`
|
||||
Repo string `json:"repo,omitempty"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,17 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Summary holds aggregate counts for a set of findings.
|
||||
type Summary struct {
|
||||
Total int `json:"total"`
|
||||
BySeverity map[string]int `json:"by_severity"`
|
||||
Errors int `json:"errors"`
|
||||
Warnings int `json:"warnings"`
|
||||
Info int `json:"info"`
|
||||
Passed bool `json:"passed"`
|
||||
BySeverity map[string]int `json:"by_severity,omitempty"`
|
||||
}
|
||||
|
||||
// Summarise counts findings by severity.
|
||||
|
|
@ -19,8 +24,21 @@ func Summarise(findings []Finding) Summary {
|
|||
BySeverity: make(map[string]int),
|
||||
}
|
||||
for _, f := range findings {
|
||||
s.BySeverity[f.Severity]++
|
||||
severity := strings.TrimSpace(f.Severity)
|
||||
if severity == "" {
|
||||
severity = "warning"
|
||||
}
|
||||
s.BySeverity[severity]++
|
||||
switch severity {
|
||||
case "error":
|
||||
s.Errors++
|
||||
case "info":
|
||||
s.Info++
|
||||
default:
|
||||
s.Warnings++
|
||||
}
|
||||
}
|
||||
s.Passed = s.Errors == 0
|
||||
return s
|
||||
}
|
||||
|
||||
|
|
@ -53,6 +71,136 @@ func WriteJSONL(w io.Writer, findings []Finding) error {
|
|||
// file:line [severity] title (rule-id)
|
||||
func WriteText(w io.Writer, findings []Finding) {
|
||||
for _, f := range findings {
|
||||
fmt.Fprintf(w, "%s:%d [%s] %s (%s)\n", f.File, f.Line, f.Severity, f.Title, f.RuleID)
|
||||
message := f.Message
|
||||
if message == "" {
|
||||
message = f.Title
|
||||
}
|
||||
code := f.Code
|
||||
if code == "" {
|
||||
code = f.RuleID
|
||||
}
|
||||
fmt.Fprintf(w, "%s:%d [%s] %s (%s)\n", f.File, f.Line, f.Severity, message, code)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteReportJSON writes the RFC report document as pretty-printed JSON.
|
||||
func WriteReportJSON(w io.Writer, report Report) error {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(report)
|
||||
}
|
||||
|
||||
// WriteReportText writes report findings followed by a short summary.
|
||||
func WriteReportText(w io.Writer, report Report) {
|
||||
WriteText(w, report.Findings)
|
||||
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)
|
||||
}
|
||||
|
||||
// WriteReportGitHub writes GitHub Actions annotation lines.
|
||||
func WriteReportGitHub(w io.Writer, report Report) {
|
||||
for _, finding := range report.Findings {
|
||||
level := finding.Severity
|
||||
if level == "" {
|
||||
level = "warning"
|
||||
}
|
||||
|
||||
location := ""
|
||||
if finding.File != "" {
|
||||
location = fmt.Sprintf(" file=%s", finding.File)
|
||||
if finding.Line > 0 {
|
||||
location += fmt.Sprintf(",line=%d", finding.Line)
|
||||
}
|
||||
if finding.Column > 0 {
|
||||
location += fmt.Sprintf(",col=%d", finding.Column)
|
||||
}
|
||||
}
|
||||
|
||||
message := finding.Message
|
||||
if message == "" {
|
||||
message = finding.Title
|
||||
}
|
||||
code := finding.Code
|
||||
if code == "" {
|
||||
code = finding.RuleID
|
||||
}
|
||||
fmt.Fprintf(w, "::%s%s::[%s] %s (%s)\n", level, location, finding.Tool, message, code)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteReportSARIF writes a minimal SARIF document for code scanning tools.
|
||||
func WriteReportSARIF(w io.Writer, report Report) error {
|
||||
type sarifMessage struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
type sarifRegion struct {
|
||||
StartLine int `json:"startLine,omitempty"`
|
||||
StartColumn int `json:"startColumn,omitempty"`
|
||||
}
|
||||
type sarifArtifactLocation struct {
|
||||
URI string `json:"uri,omitempty"`
|
||||
}
|
||||
type sarifPhysicalLocation struct {
|
||||
ArtifactLocation sarifArtifactLocation `json:"artifactLocation"`
|
||||
Region sarifRegion `json:"region,omitempty"`
|
||||
}
|
||||
type sarifLocation struct {
|
||||
PhysicalLocation sarifPhysicalLocation `json:"physicalLocation"`
|
||||
}
|
||||
type sarifResult struct {
|
||||
RuleID string `json:"ruleId,omitempty"`
|
||||
Level string `json:"level,omitempty"`
|
||||
Message sarifMessage `json:"message"`
|
||||
Locations []sarifLocation `json:"locations,omitempty"`
|
||||
}
|
||||
type sarifRun struct {
|
||||
Tool struct {
|
||||
Driver struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"driver"`
|
||||
} `json:"tool"`
|
||||
Results []sarifResult `json:"results"`
|
||||
}
|
||||
type sarifLog struct {
|
||||
Version string `json:"version"`
|
||||
Schema string `json:"$schema"`
|
||||
Runs []sarifRun `json:"runs"`
|
||||
}
|
||||
|
||||
sarifRunValue := sarifRun{}
|
||||
sarifRunValue.Tool.Driver.Name = "core-lint"
|
||||
|
||||
for _, finding := range report.Findings {
|
||||
message := finding.Message
|
||||
if message == "" {
|
||||
message = finding.Title
|
||||
}
|
||||
ruleID := finding.Code
|
||||
if ruleID == "" {
|
||||
ruleID = finding.RuleID
|
||||
}
|
||||
|
||||
result := sarifResult{
|
||||
RuleID: ruleID,
|
||||
Level: finding.Severity,
|
||||
Message: sarifMessage{Text: message},
|
||||
}
|
||||
if finding.File != "" {
|
||||
result.Locations = []sarifLocation{{
|
||||
PhysicalLocation: sarifPhysicalLocation{
|
||||
ArtifactLocation: sarifArtifactLocation{URI: finding.File},
|
||||
Region: sarifRegion{
|
||||
StartLine: finding.Line,
|
||||
StartColumn: finding.Column,
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
sarifRunValue.Results = append(sarifRunValue.Results, result)
|
||||
}
|
||||
|
||||
return json.NewEncoder(w).Encode(sarifLog{
|
||||
Version: "2.1.0",
|
||||
Schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
||||
Runs: []sarifRun{sarifRunValue},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
509
pkg/lint/service.go
Normal file
509
pkg/lint/service.go
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
package lint
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
)
|
||||
|
||||
const (
|
||||
hookStartMarker = "# core-lint hook start"
|
||||
hookEndMarker = "# core-lint hook end"
|
||||
)
|
||||
|
||||
// RunInput is the DTO for `core-lint run` and the language/category shortcuts.
|
||||
//
|
||||
// input := lint.RunInput{Path: ".", Output: "json", FailOn: "warning"}
|
||||
type RunInput struct {
|
||||
Path string `json:"path"`
|
||||
Output string `json:"output,omitempty"`
|
||||
Config string `json:"config,omitempty"`
|
||||
FailOn string `json:"fail_on,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Hook bool `json:"hook,omitempty"`
|
||||
CI bool `json:"ci,omitempty"`
|
||||
Files []string `json:"files,omitempty"`
|
||||
SBOM bool `json:"sbom,omitempty"`
|
||||
}
|
||||
|
||||
// ToolInfo describes a supported linter tool and whether it is available in PATH.
|
||||
type ToolInfo struct {
|
||||
Name string `json:"name"`
|
||||
Available bool `json:"available"`
|
||||
Languages []string `json:"languages"`
|
||||
Category string `json:"category"`
|
||||
Entitlement string `json:"entitlement,omitempty"`
|
||||
}
|
||||
|
||||
// Report aggregates every tool run into a single output document.
|
||||
type Report struct {
|
||||
Project string `json:"project"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Duration string `json:"duration"`
|
||||
Languages []string `json:"languages"`
|
||||
Tools []ToolRun `json:"tools"`
|
||||
Findings []Finding `json:"findings"`
|
||||
Summary Summary `json:"summary"`
|
||||
}
|
||||
|
||||
// ToolRun records the execution status of one adapter.
|
||||
type ToolRun struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Duration string `json:"duration"`
|
||||
Findings int `json:"findings"`
|
||||
}
|
||||
|
||||
// Service orchestrates the configured lint adapters for a project.
|
||||
//
|
||||
// svc := lint.NewService()
|
||||
// report, err := svc.Run(ctx, lint.RunInput{Path: ".", Output: "json"})
|
||||
type Service struct {
|
||||
adapters []Adapter
|
||||
}
|
||||
|
||||
// NewService constructs a lint orchestrator with the built-in adapter registry.
|
||||
func NewService() *Service {
|
||||
return &Service{adapters: defaultAdapters()}
|
||||
}
|
||||
|
||||
// Run executes the selected adapters and returns the merged report.
|
||||
func (s *Service) Run(ctx context.Context, input RunInput) (Report, error) {
|
||||
startedAt := time.Now().UTC()
|
||||
input = normaliseRunInput(input)
|
||||
|
||||
config, _, err := LoadProjectConfig(input.Path, input.Config)
|
||||
if err != nil {
|
||||
return Report{}, err
|
||||
}
|
||||
if input.FailOn == "" {
|
||||
input.FailOn = config.FailOn
|
||||
}
|
||||
|
||||
files := slices.Clone(input.Files)
|
||||
if input.Hook && len(files) == 0 {
|
||||
files, err = s.stagedFiles(input.Path)
|
||||
if err != nil {
|
||||
return Report{}, err
|
||||
}
|
||||
}
|
||||
|
||||
languages := s.languagesForInput(input, files)
|
||||
selectedAdapters := s.selectAdapters(config, languages, input)
|
||||
|
||||
var findings []Finding
|
||||
var toolRuns []ToolRun
|
||||
|
||||
for _, adapter := range selectedAdapters {
|
||||
if input.Hook && !adapter.Fast() {
|
||||
toolRuns = append(toolRuns, ToolRun{
|
||||
Name: adapter.Name(),
|
||||
Status: "skipped",
|
||||
Duration: "0s",
|
||||
Findings: 0,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
result := adapter.Run(ctx, input, files)
|
||||
toolRuns = append(toolRuns, result.Tool)
|
||||
findings = append(findings, normaliseReportFindings(result.Findings, input.Path)...)
|
||||
}
|
||||
|
||||
sortToolRuns(toolRuns)
|
||||
sortFindings(findings)
|
||||
|
||||
report := Report{
|
||||
Project: projectName(input.Path),
|
||||
Timestamp: startedAt,
|
||||
Duration: time.Since(startedAt).Round(time.Millisecond).String(),
|
||||
Languages: slices.Clone(languages),
|
||||
Tools: toolRuns,
|
||||
Findings: findings,
|
||||
Summary: Summarise(findings),
|
||||
}
|
||||
report.Summary.Passed = passesThreshold(report.Summary, input.FailOn)
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// Tools returns the current adapter inventory for display in the CLI.
|
||||
func (s *Service) Tools(languages []string) []ToolInfo {
|
||||
var tools []ToolInfo
|
||||
for _, adapter := range s.adapters {
|
||||
if len(languages) > 0 && !adapter.MatchesLanguage(languages) {
|
||||
continue
|
||||
}
|
||||
tools = append(tools, ToolInfo{
|
||||
Name: adapter.Name(),
|
||||
Available: adapter.Available(),
|
||||
Languages: slices.Clone(adapter.Languages()),
|
||||
Category: adapter.Category(),
|
||||
Entitlement: adapter.Entitlement(),
|
||||
})
|
||||
}
|
||||
slices.SortFunc(tools, func(left ToolInfo, right ToolInfo) int {
|
||||
return strings.Compare(left.Name, right.Name)
|
||||
})
|
||||
return tools
|
||||
}
|
||||
|
||||
// WriteDefaultConfig creates `.core/lint.yaml` in the target project.
|
||||
//
|
||||
// path, err := svc.WriteDefaultConfig(".", false)
|
||||
func (s *Service) WriteDefaultConfig(projectPath string, force bool) (string, error) {
|
||||
if projectPath == "" {
|
||||
projectPath = "."
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(projectPath, DefaultConfigPath)
|
||||
if !force {
|
||||
if _, err := os.Stat(targetPath); err == nil {
|
||||
return "", coreerr.E("Service.WriteDefaultConfig", targetPath+" already exists", nil)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return "", coreerr.E("Service.WriteDefaultConfig", "mkdir "+filepath.Dir(targetPath), err)
|
||||
}
|
||||
|
||||
content, err := DefaultConfigYAML()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := coreio.Local.Write(targetPath, content); err != nil {
|
||||
return "", coreerr.E("Service.WriteDefaultConfig", "write "+targetPath, err)
|
||||
}
|
||||
|
||||
return targetPath, nil
|
||||
}
|
||||
|
||||
// InstallHook adds a git pre-commit hook that runs `core-lint run --hook`.
|
||||
func (s *Service) InstallHook(projectPath string) error {
|
||||
hookPath, err := hookFilePath(projectPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
block := hookScriptBlock(false)
|
||||
content := "#!/bin/sh\n" + block
|
||||
|
||||
raw, readErr := coreio.Local.Read(hookPath)
|
||||
if readErr == nil {
|
||||
if strings.Contains(raw, hookStartMarker) {
|
||||
return nil
|
||||
}
|
||||
|
||||
trimmed := strings.TrimRight(raw, "\n")
|
||||
if trimmed == "" {
|
||||
content = "#!/bin/sh\n" + block
|
||||
} else {
|
||||
content = trimmed + "\n\n" + hookScriptBlock(true)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(hookPath), 0o755); err != nil {
|
||||
return coreerr.E("Service.InstallHook", "mkdir "+filepath.Dir(hookPath), err)
|
||||
}
|
||||
if err := coreio.Local.Write(hookPath, content); err != nil {
|
||||
return coreerr.E("Service.InstallHook", "write "+hookPath, err)
|
||||
}
|
||||
if err := os.Chmod(hookPath, 0o755); err != nil {
|
||||
return coreerr.E("Service.InstallHook", "chmod "+hookPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveHook removes the block previously installed by InstallHook.
|
||||
func (s *Service) RemoveHook(projectPath string) error {
|
||||
hookPath, err := hookFilePath(projectPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
raw, err := coreio.Local.Read(hookPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return coreerr.E("Service.RemoveHook", "read "+hookPath, err)
|
||||
}
|
||||
|
||||
startIndex := strings.Index(raw, hookStartMarker)
|
||||
endIndex := strings.Index(raw, hookEndMarker)
|
||||
if startIndex < 0 || endIndex < 0 || endIndex < startIndex {
|
||||
return nil
|
||||
}
|
||||
|
||||
endIndex += len(hookEndMarker)
|
||||
content := strings.TrimSpace(raw[:startIndex] + raw[endIndex:])
|
||||
if content == "" {
|
||||
if err := os.Remove(hookPath); err != nil && !os.IsNotExist(err) {
|
||||
return coreerr.E("Service.RemoveHook", "remove "+hookPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
content += "\n"
|
||||
if err := coreio.Local.Write(hookPath, content); err != nil {
|
||||
return coreerr.E("Service.RemoveHook", "write "+hookPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) languagesForInput(input RunInput, files []string) []string {
|
||||
if input.Lang != "" {
|
||||
return []string{input.Lang}
|
||||
}
|
||||
if len(files) > 0 {
|
||||
return detectFromFiles(files)
|
||||
}
|
||||
return Detect(input.Path)
|
||||
}
|
||||
|
||||
func (s *Service) selectAdapters(config LintConfig, languages []string, input RunInput) []Adapter {
|
||||
enabled := make(map[string]bool)
|
||||
for _, name := range enabledToolNames(config, languages, input) {
|
||||
enabled[name] = true
|
||||
}
|
||||
|
||||
var selected []Adapter
|
||||
for _, adapter := range s.adapters {
|
||||
if len(enabled) > 0 && !enabled[adapter.Name()] {
|
||||
continue
|
||||
}
|
||||
if input.Category != "" && adapter.Category() != input.Category {
|
||||
continue
|
||||
}
|
||||
if !adapter.MatchesLanguage(languages) {
|
||||
continue
|
||||
}
|
||||
selected = append(selected, adapter)
|
||||
}
|
||||
|
||||
if slices.Contains(languages, "go") && input.Category != "compliance" {
|
||||
if !hasAdapter(selected, "catalog") {
|
||||
selected = append([]Adapter{newCatalogAdapter()}, selected...)
|
||||
}
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
func (s *Service) stagedFiles(projectPath string) ([]string, error) {
|
||||
toolkit := NewToolkit(projectPath)
|
||||
stdout, stderr, exitCode, err := toolkit.Run("git", "diff", "--cached", "--name-only")
|
||||
if err != nil && exitCode != 0 {
|
||||
return nil, coreerr.E("Service.stagedFiles", "git diff --cached --name-only: "+strings.TrimSpace(stderr), err)
|
||||
}
|
||||
|
||||
var files []string
|
||||
for line := range strings.SplitSeq(strings.TrimSpace(stdout), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
files = append(files, line)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func enabledToolNames(config LintConfig, languages []string, input RunInput) []string {
|
||||
var names []string
|
||||
|
||||
if input.Category == "security" {
|
||||
names = append(names, config.Lint.Security...)
|
||||
return dedupeStrings(names)
|
||||
}
|
||||
if input.Category == "compliance" {
|
||||
names = append(names, config.Lint.Compliance...)
|
||||
return dedupeStrings(names)
|
||||
}
|
||||
|
||||
if input.Lang != "" {
|
||||
names = append(names, groupForLanguage(config.Lint, input.Lang)...)
|
||||
return dedupeStrings(names)
|
||||
}
|
||||
|
||||
for _, language := range languages {
|
||||
names = append(names, groupForLanguage(config.Lint, language)...)
|
||||
}
|
||||
names = append(names, config.Lint.Infra...)
|
||||
if input.CI || input.Category == "security" {
|
||||
names = append(names, config.Lint.Security...)
|
||||
}
|
||||
if input.SBOM {
|
||||
names = append(names, config.Lint.Compliance...)
|
||||
}
|
||||
|
||||
return dedupeStrings(names)
|
||||
}
|
||||
|
||||
func groupForLanguage(groups ToolGroups, language string) []string {
|
||||
switch language {
|
||||
case "go":
|
||||
return groups.Go
|
||||
case "php":
|
||||
return groups.PHP
|
||||
case "js":
|
||||
return groups.JS
|
||||
case "ts":
|
||||
return groups.TS
|
||||
case "python":
|
||||
return groups.Python
|
||||
case "shell", "dockerfile", "yaml", "json", "markdown":
|
||||
return groups.Infra
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func hookFilePath(projectPath string) (string, error) {
|
||||
if projectPath == "" {
|
||||
projectPath = "."
|
||||
}
|
||||
|
||||
toolkit := NewToolkit(projectPath)
|
||||
stdout, stderr, exitCode, err := toolkit.Run("git", "rev-parse", "--git-dir")
|
||||
if err != nil && exitCode != 0 {
|
||||
return "", coreerr.E("hookFilePath", "git rev-parse --git-dir: "+strings.TrimSpace(stderr), err)
|
||||
}
|
||||
|
||||
gitDir := strings.TrimSpace(stdout)
|
||||
if gitDir == "" {
|
||||
return "", coreerr.E("hookFilePath", "git directory is empty", nil)
|
||||
}
|
||||
if !filepath.IsAbs(gitDir) {
|
||||
gitDir = filepath.Join(projectPath, gitDir)
|
||||
}
|
||||
return filepath.Join(gitDir, "hooks", "pre-commit"), nil
|
||||
}
|
||||
|
||||
func hookScriptBlock(appended bool) string {
|
||||
command := "exec core-lint run --hook"
|
||||
if appended {
|
||||
command = "core-lint run --hook || exit $?"
|
||||
}
|
||||
|
||||
return hookStartMarker + "\n# Installed by core-lint\n" + command + "\n" + hookEndMarker + "\n"
|
||||
}
|
||||
|
||||
func normaliseRunInput(input RunInput) RunInput {
|
||||
if input.Path == "" {
|
||||
input.Path = "."
|
||||
}
|
||||
if input.CI && input.Output == "" {
|
||||
input.Output = "github"
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func normaliseReportFindings(findings []Finding, projectPath string) []Finding {
|
||||
normalised := make([]Finding, 0, len(findings))
|
||||
for _, finding := range findings {
|
||||
if finding.Code == "" {
|
||||
finding.Code = finding.RuleID
|
||||
}
|
||||
if finding.Message == "" {
|
||||
finding.Message = finding.Title
|
||||
}
|
||||
if finding.Tool == "" {
|
||||
finding.Tool = "catalog"
|
||||
}
|
||||
if finding.Severity == "" {
|
||||
finding.Severity = "warning"
|
||||
} else {
|
||||
finding.Severity = normaliseSeverity(finding.Severity)
|
||||
}
|
||||
if finding.File != "" && projectPath != "" {
|
||||
if relativePath, err := filepath.Rel(projectPath, finding.File); err == nil && relativePath != "" && !strings.HasPrefix(relativePath, "..") {
|
||||
finding.File = filepath.ToSlash(relativePath)
|
||||
} else {
|
||||
finding.File = filepath.ToSlash(finding.File)
|
||||
}
|
||||
}
|
||||
normalised = append(normalised, finding)
|
||||
}
|
||||
return normalised
|
||||
}
|
||||
|
||||
func projectName(path string) string {
|
||||
absolutePath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return filepath.Base(path)
|
||||
}
|
||||
return filepath.Base(absolutePath)
|
||||
}
|
||||
|
||||
func dedupeStrings(values []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var deduped []string
|
||||
for _, value := range values {
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
deduped = append(deduped, value)
|
||||
}
|
||||
return deduped
|
||||
}
|
||||
|
||||
func hasAdapter(adapters []Adapter, name string) bool {
|
||||
for _, adapter := range adapters {
|
||||
if adapter.Name() == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func passesThreshold(summary Summary, threshold string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(threshold)) {
|
||||
case "", "error":
|
||||
return summary.Errors == 0
|
||||
case "warning":
|
||||
return summary.Errors == 0 && summary.Warnings == 0
|
||||
case "info":
|
||||
return summary.Total == 0
|
||||
default:
|
||||
return summary.Errors == 0
|
||||
}
|
||||
}
|
||||
|
||||
func sortFindings(findings []Finding) {
|
||||
slices.SortFunc(findings, func(left Finding, right Finding) int {
|
||||
switch {
|
||||
case left.File != right.File:
|
||||
return strings.Compare(left.File, right.File)
|
||||
case left.Line != right.Line:
|
||||
if left.Line < right.Line {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
case left.Column != right.Column:
|
||||
if left.Column < right.Column {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
case left.Tool != right.Tool:
|
||||
return strings.Compare(left.Tool, right.Tool)
|
||||
default:
|
||||
return strings.Compare(left.Code, right.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func sortToolRuns(toolRuns []ToolRun) {
|
||||
slices.SortFunc(toolRuns, func(left ToolRun, right ToolRun) int {
|
||||
return strings.Compare(left.Name, right.Name)
|
||||
})
|
||||
}
|
||||
100
pkg/lint/service_test.go
Normal file
100
pkg/lint/service_test.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package lint
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestServiceRun_Good_CatalogFindings(t *testing.T) {
|
||||
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, "input.go"), []byte(`package sample
|
||||
|
||||
type service struct{}
|
||||
|
||||
func (service) Process(string) error { return nil }
|
||||
|
||||
func Run() {
|
||||
svc := service{}
|
||||
_ = svc.Process("data")
|
||||
}
|
||||
`), 0o644))
|
||||
|
||||
svc := NewService()
|
||||
report, err := svc.Run(context.Background(), RunInput{
|
||||
Path: dir,
|
||||
FailOn: "warning",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, report.Findings, 1)
|
||||
assert.Equal(t, "warning", report.Findings[0].Severity)
|
||||
assert.Equal(t, "catalog", report.Findings[0].Tool)
|
||||
assert.Equal(t, "go-cor-003", report.Findings[0].Code)
|
||||
assert.Equal(t, "correctness", report.Findings[0].Category)
|
||||
assert.Equal(t, 1, report.Summary.Total)
|
||||
assert.Equal(t, 1, report.Summary.Warnings)
|
||||
assert.False(t, report.Summary.Passed)
|
||||
assert.Contains(t, report.Languages, "go")
|
||||
require.NotEmpty(t, report.Tools)
|
||||
assert.Equal(t, "catalog", report.Tools[0].Name)
|
||||
}
|
||||
|
||||
func TestServiceRun_Good_HookModeUsesStagedFiles(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not available")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
runTestCommand(t, dir, "git", "init")
|
||||
runTestCommand(t, dir, "git", "config", "user.email", "test@example.com")
|
||||
runTestCommand(t, dir, "git", "config", "user.name", "Test User")
|
||||
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, "staged.go"), []byte(`package sample
|
||||
|
||||
type service struct{}
|
||||
|
||||
func (service) Process(string) error { return nil }
|
||||
|
||||
func run() {
|
||||
svc := service{}
|
||||
_ = svc.Process("data")
|
||||
}
|
||||
`), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "unstaged.go"), []byte(`package sample
|
||||
|
||||
func run2() {
|
||||
panic("boom")
|
||||
}
|
||||
`), 0o644))
|
||||
|
||||
runTestCommand(t, dir, "git", "add", "go.mod", "staged.go")
|
||||
|
||||
svc := NewService()
|
||||
report, err := svc.Run(context.Background(), RunInput{
|
||||
Path: dir,
|
||||
Hook: true,
|
||||
FailOn: "warning",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, report.Findings, 1)
|
||||
assert.Equal(t, "staged.go", report.Findings[0].File)
|
||||
assert.Equal(t, "go-cor-003", report.Findings[0].Code)
|
||||
assert.False(t, report.Summary.Passed)
|
||||
}
|
||||
|
||||
func runTestCommand(t *testing.T, dir string, name string, args ...string) {
|
||||
t.Helper()
|
||||
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Dir = dir
|
||||
output, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, string(output))
|
||||
}
|
||||
13
tests/cli/lint/Taskfile.yaml
Normal file
13
tests/cli/lint/Taskfile.yaml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
version: "3"
|
||||
|
||||
tasks:
|
||||
test:
|
||||
cmds:
|
||||
- task -d check test
|
||||
- task -d catalog/list test
|
||||
- task -d catalog/show test
|
||||
- task -d detect test
|
||||
- task -d tools test
|
||||
- task -d init test
|
||||
- task -d run test
|
||||
|
||||
17
tests/cli/lint/detect/Taskfile.yaml
Normal file
17
tests/cli/lint/detect/Taskfile.yaml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
version: "3"
|
||||
|
||||
tasks:
|
||||
test:
|
||||
cmds:
|
||||
- |
|
||||
bash <<'EOF'
|
||||
set -euo pipefail
|
||||
source ../../_lib/run.sh
|
||||
|
||||
go build -trimpath -ldflags="-s -w" -o bin/core-lint ../../../../cmd/core-lint
|
||||
|
||||
output="$(mktemp)"
|
||||
run_capture_stdout 0 "$output" ./bin/core-lint detect --output json ../check/fixtures
|
||||
jq -e '. == ["go"]' "$output" >/dev/null
|
||||
EOF
|
||||
|
||||
19
tests/cli/lint/init/Taskfile.yaml
Normal file
19
tests/cli/lint/init/Taskfile.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
version: "3"
|
||||
|
||||
tasks:
|
||||
test:
|
||||
cmds:
|
||||
- |
|
||||
bash <<'EOF'
|
||||
set -euo pipefail
|
||||
source ../../_lib/run.sh
|
||||
|
||||
go build -trimpath -ldflags="-s -w" -o bin/core-lint ../../../../cmd/core-lint
|
||||
|
||||
project_dir="$(mktemp -d)"
|
||||
output="$(mktemp)"
|
||||
run_capture_stdout 0 "$output" ./bin/core-lint init "$project_dir"
|
||||
test -f "$project_dir/.core/lint.yaml"
|
||||
grep -Fq "golangci-lint" "$project_dir/.core/lint.yaml"
|
||||
EOF
|
||||
|
||||
19
tests/cli/lint/run/Taskfile.yaml
Normal file
19
tests/cli/lint/run/Taskfile.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
version: "3"
|
||||
|
||||
tasks:
|
||||
test:
|
||||
cmds:
|
||||
- |
|
||||
bash <<'EOF'
|
||||
set -euo pipefail
|
||||
source ../../_lib/run.sh
|
||||
|
||||
go build -trimpath -ldflags="-s -w" -o bin/core-lint ../../../../cmd/core-lint
|
||||
|
||||
output="$(mktemp)"
|
||||
run_capture_stdout 1 "$output" ./bin/core-lint run --output json --fail-on warning ../check/fixtures
|
||||
jq -e '.findings | length == 1' "$output" >/dev/null
|
||||
jq -e '.findings[0].code == "go-cor-003"' "$output" >/dev/null
|
||||
jq -e '.summary.warnings == 1 and .summary.passed == false' "$output" >/dev/null
|
||||
EOF
|
||||
|
||||
18
tests/cli/lint/tools/Taskfile.yaml
Normal file
18
tests/cli/lint/tools/Taskfile.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
version: "3"
|
||||
|
||||
tasks:
|
||||
test:
|
||||
cmds:
|
||||
- |
|
||||
bash <<'EOF'
|
||||
set -euo pipefail
|
||||
source ../../_lib/run.sh
|
||||
|
||||
go build -trimpath -ldflags="-s -w" -o bin/core-lint ../../../../cmd/core-lint
|
||||
|
||||
output="$(mktemp)"
|
||||
run_capture_stdout 0 "$output" ./bin/core-lint tools --output json --lang go
|
||||
jq -e '.[] | select(.name == "golangci-lint")' "$output" >/dev/null
|
||||
jq -e '.[] | select(.name == "govulncheck")' "$output" >/dev/null
|
||||
EOF
|
||||
|
||||
Loading…
Add table
Reference in a new issue