463 lines
13 KiB
Go
463 lines
13 KiB
Go
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"
|
|
cataloglint "forge.lthn.ai/core/lint"
|
|
lintpkg "forge.lthn.ai/core/lint/pkg/lint"
|
|
)
|
|
|
|
func main() {
|
|
cli.WithAppName("core-lint")
|
|
cli.Main(cli.WithCommands("lint", addLintCommands))
|
|
}
|
|
|
|
func addLintCommands(root *cli.Command) {
|
|
addRFCCommands(root)
|
|
|
|
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"}),
|
|
newHookCommand(),
|
|
)
|
|
}
|
|
|
|
func newRunCommand(commandName string, summary string, defaults lintpkg.RunInput) *cli.Command {
|
|
var (
|
|
outputFormat string
|
|
configPath string
|
|
scheduleName string
|
|
failOnLevel string
|
|
categoryName string
|
|
languageName string
|
|
filePaths []string
|
|
hookMode bool
|
|
ciMode bool
|
|
sbomMode bool
|
|
)
|
|
|
|
command := cli.NewCommand(commandName, summary, "", func(command *cli.Command, args []string) error {
|
|
input := defaults
|
|
input.Output = outputFormat
|
|
input.Config = configPath
|
|
input.Schedule = scheduleName
|
|
input.FailOn = failOnLevel
|
|
input.Category = categoryName
|
|
input.Lang = languageName
|
|
input.Files = filePaths
|
|
input.Hook = hookMode
|
|
input.CI = ciMode
|
|
input.SBOM = sbomMode
|
|
|
|
if len(args) > 0 {
|
|
input.Path = args[0]
|
|
}
|
|
if input.Path == "" {
|
|
input.Path = "."
|
|
}
|
|
|
|
resolvedOutputFormat, err := lintpkg.ResolveRunOutputFormat(input)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
input.Output = resolvedOutputFormat
|
|
|
|
service := lintpkg.NewService()
|
|
report, err := service.Run(context.Background(), input)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := writeReport(command.OutOrStdout(), input.Output, report); err != nil {
|
|
return err
|
|
}
|
|
if !report.Summary.Passed {
|
|
return coreerr.E(
|
|
"cmd."+commandName,
|
|
fmt.Sprintf(
|
|
"lint failed (fail-on=%s): %d error(s), %d warning(s), %d info finding(s)",
|
|
input.FailOn,
|
|
report.Summary.Errors,
|
|
report.Summary.Warnings,
|
|
report.Summary.Info,
|
|
),
|
|
nil,
|
|
)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
cli.StringFlag(command, &outputFormat, "output", "o", defaults.Output, "Output format: json, text, github, sarif")
|
|
cli.StringFlag(command, &configPath, "config", "c", defaults.Config, "Config path (default: .core/lint.yaml)")
|
|
cli.StringFlag(command, &scheduleName, "schedule", "", "", "Run a named schedule from the config")
|
|
cli.StringFlag(command, &failOnLevel, "fail-on", "", defaults.FailOn, "Fail threshold: error, warning, info")
|
|
cli.StringFlag(command, &categoryName, "category", "", defaults.Category, "Restrict to one category")
|
|
cli.StringFlag(command, &languageName, "lang", "l", defaults.Lang, "Restrict to one language")
|
|
cli.StringSliceFlag(command, &filePaths, "files", "", defaults.Files, "Restrict scanning to specific files")
|
|
cli.BoolFlag(command, &hookMode, "hook", "", defaults.Hook, "Run in pre-commit mode against staged files")
|
|
cli.BoolFlag(command, &ciMode, "ci", "", defaults.CI, "GitHub Actions mode (github annotations)")
|
|
cli.BoolFlag(command, &sbomMode, "sbom", "", defaults.SBOM, "Enable compliance/SBOM tools")
|
|
|
|
return command
|
|
}
|
|
|
|
func newDetectCommand(commandName string, summary string) *cli.Command {
|
|
var output string
|
|
|
|
command := cli.NewCommand(commandName, summary, "", func(command *cli.Command, args []string) error {
|
|
projectPath := "."
|
|
if len(args) > 0 {
|
|
projectPath = args[0]
|
|
}
|
|
|
|
languages := lintpkg.Detect(projectPath)
|
|
switch output {
|
|
case "", "text":
|
|
for _, language := range languages {
|
|
fmt.Fprintln(command.OutOrStdout(), language)
|
|
}
|
|
return nil
|
|
case "json":
|
|
return writeIndentedJSON(command.OutOrStdout(), languages)
|
|
default:
|
|
return coreerr.E("cmd.detect", "unsupported output format "+output, nil)
|
|
}
|
|
})
|
|
|
|
cli.StringFlag(command, &output, "output", "o", "text", "Output format: text, json")
|
|
return command
|
|
}
|
|
|
|
func newToolsCommand(commandName string, summary string) *cli.Command {
|
|
var output string
|
|
var languageFilter string
|
|
|
|
command := cli.NewCommand(commandName, summary, "", func(command *cli.Command, args []string) error {
|
|
service := lintpkg.NewService()
|
|
|
|
var languages []string
|
|
if languageFilter != "" {
|
|
languages = []string{languageFilter}
|
|
}
|
|
|
|
tools := service.Tools(languages)
|
|
switch output {
|
|
case "", "text":
|
|
for _, tool := range tools {
|
|
status := "missing"
|
|
if tool.Available {
|
|
status = "available"
|
|
}
|
|
line := fmt.Sprintf("%-14s [%-11s] %s langs=%s", tool.Name, tool.Category, status, strings.Join(tool.Languages, ","))
|
|
if tool.Entitlement != "" {
|
|
line += " entitlement=" + tool.Entitlement
|
|
}
|
|
fmt.Fprintln(command.OutOrStdout(), line)
|
|
}
|
|
return nil
|
|
case "json":
|
|
return writeIndentedJSON(command.OutOrStdout(), tools)
|
|
default:
|
|
return coreerr.E("cmd.tools", "unsupported output format "+output, nil)
|
|
}
|
|
})
|
|
|
|
cli.StringFlag(command, &output, "output", "o", "text", "Output format: text, json")
|
|
cli.StringFlag(command, &languageFilter, "lang", "l", "", "Filter by language")
|
|
return command
|
|
}
|
|
|
|
func newInitCommand(commandName string, summary string) *cli.Command {
|
|
var force bool
|
|
|
|
command := cli.NewCommand(commandName, summary, "", func(command *cli.Command, args []string) error {
|
|
projectPath := "."
|
|
if len(args) > 0 {
|
|
projectPath = args[0]
|
|
}
|
|
|
|
service := lintpkg.NewService()
|
|
writtenPath, err := service.WriteDefaultConfig(projectPath, force)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintln(command.OutOrStdout(), writtenPath)
|
|
return nil
|
|
})
|
|
|
|
cli.BoolFlag(command, &force, "force", "f", false, "Overwrite an existing config")
|
|
return command
|
|
}
|
|
|
|
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(command *cli.Command, args []string) error {
|
|
projectPath := "."
|
|
if len(args) > 0 {
|
|
projectPath = args[0]
|
|
}
|
|
|
|
service := lintpkg.NewService()
|
|
if err := service.InstallHook(projectPath); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintln(command.OutOrStdout(), "installed")
|
|
return nil
|
|
})
|
|
|
|
removeCmd := cli.NewCommand("remove", "Remove the pre-commit hook", "", func(command *cli.Command, args []string) error {
|
|
projectPath := "."
|
|
if len(args) > 0 {
|
|
projectPath = args[0]
|
|
}
|
|
|
|
service := lintpkg.NewService()
|
|
if err := service.RemoveHook(projectPath); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintln(command.OutOrStdout(), "removed")
|
|
return nil
|
|
})
|
|
|
|
hookCmd.AddCommand(installCmd, removeCmd)
|
|
return hookCmd
|
|
}
|
|
|
|
func newCheckCommand() *cli.Command {
|
|
var (
|
|
format string
|
|
language string
|
|
severity string
|
|
)
|
|
|
|
command := cli.NewCommand("check", "Scan files for pattern matches", "", func(command *cli.Command, args []string) error {
|
|
catalog, err := cataloglint.LoadEmbeddedCatalog()
|
|
if err != nil {
|
|
return coreerr.E("cmd.check", "loading catalog", err)
|
|
}
|
|
|
|
rules := catalog.Rules
|
|
if language != "" {
|
|
rules = catalog.ForLanguage(language)
|
|
if len(rules) == 0 {
|
|
fmt.Fprintf(os.Stderr, "no rules for language %q\n", language)
|
|
return nil
|
|
}
|
|
}
|
|
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", severity)
|
|
return nil
|
|
}
|
|
rules = filtered
|
|
}
|
|
|
|
scanner, err := lintpkg.NewScanner(rules)
|
|
if err != nil {
|
|
return coreerr.E("cmd.check", "creating scanner", err)
|
|
}
|
|
|
|
paths := args
|
|
if len(paths) == 0 {
|
|
paths = []string{"."}
|
|
}
|
|
|
|
var findings []lintpkg.Finding
|
|
for _, path := range paths {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return coreerr.E("cmd.check", "stat "+path, err)
|
|
}
|
|
|
|
if info.IsDir() {
|
|
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
|
|
}
|
|
findings = append(findings, pathFindings...)
|
|
}
|
|
|
|
switch format {
|
|
case "json":
|
|
return lintpkg.WriteJSON(command.OutOrStdout(), findings)
|
|
case "jsonl":
|
|
return lintpkg.WriteJSONL(command.OutOrStdout(), findings)
|
|
case "sarif":
|
|
report := lintpkg.Report{
|
|
Findings: findings,
|
|
Summary: lintpkg.Summarise(findings),
|
|
}
|
|
return lintpkg.WriteReportSARIF(command.OutOrStdout(), report)
|
|
default:
|
|
lintpkg.WriteText(command.OutOrStdout(), findings)
|
|
if format == "text" && len(findings) > 0 {
|
|
writeCatalogSummary(command.OutOrStdout(), findings)
|
|
}
|
|
return nil
|
|
}
|
|
})
|
|
|
|
cli.StringFlag(command, &format, "format", "f", "text", "Output format: text, json, jsonl, sarif")
|
|
cli.StringFlag(command, &language, "lang", "l", "", "Filter rules by language")
|
|
cli.StringFlag(command, &severity, "severity", "s", "", "Minimum severity threshold (info, low, medium, high, critical)")
|
|
|
|
return command
|
|
}
|
|
|
|
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(command *cli.Command, args []string) error {
|
|
catalog, err := cataloglint.LoadEmbeddedCatalog()
|
|
if err != nil {
|
|
return coreerr.E("cmd.catalog.list", "loading catalog", err)
|
|
}
|
|
|
|
rules := catalog.Rules
|
|
if listLanguage != "" {
|
|
rules = catalog.ForLanguage(listLanguage)
|
|
}
|
|
if len(rules) == 0 {
|
|
fmt.Fprintln(command.OutOrStdout(), "No rules found.")
|
|
return nil
|
|
}
|
|
|
|
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(command.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(command *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(command.OutOrStdout(), "%s\n", string(data))
|
|
return nil
|
|
})
|
|
|
|
catalogCmd.AddCommand(listCmd, showCmd)
|
|
return catalogCmd
|
|
}
|
|
|
|
func writeReport(writer io.Writer, output string, report lintpkg.Report) error {
|
|
switch output {
|
|
case "json":
|
|
return lintpkg.WriteReportJSON(writer, report)
|
|
case "text":
|
|
lintpkg.WriteReportText(writer, report)
|
|
return nil
|
|
case "github":
|
|
lintpkg.WriteReportGitHub(writer, report)
|
|
return nil
|
|
case "sarif":
|
|
return lintpkg.WriteReportSARIF(writer, report)
|
|
default:
|
|
return coreerr.E("writeReport", "unsupported output format "+output, nil)
|
|
}
|
|
}
|
|
|
|
func writeIndentedJSON(writer io.Writer, value any) error {
|
|
encoder := json.NewEncoder(writer)
|
|
encoder.SetIndent("", " ")
|
|
return encoder.Encode(value)
|
|
}
|
|
|
|
func writeCatalogSummary(writer io.Writer, findings []lintpkg.Finding) {
|
|
summary := lintpkg.Summarise(findings)
|
|
fmt.Fprintf(writer, "\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(writer, " (%s)", strings.Join(parts, ", "))
|
|
}
|
|
fmt.Fprintln(writer)
|
|
}
|