lint/cmd/core-lint/main.go
Virgil 85dc5f75d0 refactor(lint): align naming with AX principles
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 12:48:12 +00:00

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