feat(ax): implement RFC lint orchestration CLI

This commit is contained in:
Virgil 2026-03-30 13:50:39 +00:00
parent 119df680d7
commit 7ab634bcd2
15 changed files with 2527 additions and 109 deletions

View file

@ -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,143 +249,188 @@ 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
}
findings = append(findings, pathFindings...)
continue
}
pathFindings, err := scanner.ScanFile(path)
if err != nil {
return err
}
allFindings = append(allFindings, findings...)
findings = append(findings, pathFindings...)
}
switch checkFormat {
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
}
if checkFormat == "text" && len(allFindings) > 0 {
summary := lintpkg.Summarise(allFindings)
fmt.Fprintf(os.Stdout, "\n%d finding(s)", summary.Total)
orderedSeverities := []string{"critical", "high", "medium", "low", "info"}
seen := make(map[string]bool, len(summary.BySeverity))
var parts []string
for _, sev := range orderedSeverities {
count := summary.BySeverity[sev]
if count == 0 {
continue
}
seen[sev] = true
parts = append(parts, fmt.Sprintf("%d %s", count, sev))
}
var extraSeverities []string
for severity := range summary.BySeverity {
if seen[severity] {
continue
}
extraSeverities = append(extraSeverities, severity)
}
sort.Strings(extraSeverities)
for _, severity := range extraSeverities {
count := summary.BySeverity[severity]
if count == 0 {
continue
}
parts = append(parts, fmt.Sprintf("%d %s", count, severity))
}
if len(parts) > 0 {
fmt.Fprintf(os.Stdout, " (%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)")
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)")
// ── catalog ────────────────────────────────────────────────────────────
return cmd
}
func newCatalogCommand() *cli.Command {
catalogCmd := cli.NewGroup("catalog", "Browse the pattern catalog", "")
// catalog list
var listLang string
var listLanguage string
listCmd := cli.NewCommand("list", "List all rules in the catalog", "", func(cmd *cli.Command, args []string) error {
cat, err := lint.LoadEmbeddedCatalog()
catalog, err := cataloglint.LoadEmbeddedCatalog()
if err != nil {
return coreerr.E("cmd.catalog.list", "loading catalog", err)
}
rules := cat.Rules
if listLang != "" {
rules = cat.ForLanguage(listLang)
rules := catalog.Rules
if listLanguage != "" {
rules = catalog.ForLanguage(listLanguage)
}
if len(rules) == 0 {
fmt.Println("No rules found.")
fmt.Fprintln(cmd.OutOrStdout(), "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
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[i].Severity, rules[j].Severity) < 0
return strings.Compare(rules[left].Severity, rules[right].Severity) < 0
})
for _, r := range rules {
fmt.Printf("%-14s [%-8s] %s\n", r.ID, r.Severity, r.Title)
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")
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()
catalog, err := cataloglint.LoadEmbeddedCatalog()
if err != nil {
return coreerr.E("cmd.catalog.show", "loading catalog", err)
}
r := cat.ByID(args[0])
if r == nil {
rule := catalog.ByID(args[0])
if rule == nil {
return coreerr.E("cmd.catalog.show", "rule "+args[0]+" not found", nil)
}
data, err := json.MarshalIndent(r, "", " ")
data, err := json.MarshalIndent(rule, "", " ")
if err != nil {
return err
}
cli.Print("%s\n", string(data))
fmt.Fprintf(cmd.OutOrStdout(), "%s\n", string(data))
return nil
})
catalogCmd.AddCommand(listCmd, showCmd)
lintCmd.AddCommand(checkCmd, catalogCmd)
root.AddCommand(lintCmd)
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 _, severity := range orderedSeverities {
count := summary.BySeverity[severity]
if count == 0 {
continue
}
seen[severity] = true
parts = append(parts, fmt.Sprintf("%d %s", count, severity))
}
var extraSeverities []string
for severity := range summary.BySeverity {
if seen[severity] {
continue
}
extraSeverities = append(extraSeverities, severity)
}
sort.Strings(extraSeverities)
for _, severity := range extraSeverities {
count := summary.BySeverity[severity]
if count == 0 {
continue
}
parts = append(parts, fmt.Sprintf("%d %s", count, severity))
}
if len(parts) > 0 {
fmt.Fprintf(w, " (%s)", strings.Join(parts, ", "))
}
fmt.Fprintln(w)
}

164
cmd/core-lint/main_test.go Normal file
View 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
View 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
View 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
View 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
}

View 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),
)
}

View file

@ -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"`
}

View file

@ -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
View 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
View 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))
}

View 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

View 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

View 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

View 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

View 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