diff --git a/cmd/core-lint/main.go b/cmd/core-lint/main.go index 2d0ba47..7affa95 100644 --- a/cmd/core-lint/main.go +++ b/cmd/core-lint/main.go @@ -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) } diff --git a/cmd/core-lint/main_test.go b/cmd/core-lint/main_test.go new file mode 100644 index 0000000..28ba1c4 --- /dev/null +++ b/cmd/core-lint/main_test.go @@ -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 +} diff --git a/pkg/lint/adapter.go b/pkg/lint/adapter.go new file mode 100644 index 0000000..589189f --- /dev/null +++ b/pkg/lint/adapter.go @@ -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 +` diff --git a/pkg/lint/config.go b/pkg/lint/config.go new file mode 100644 index 0000000..bedd8b7 --- /dev/null +++ b/pkg/lint/config.go @@ -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 +} diff --git a/pkg/lint/detect_project.go b/pkg/lint/detect_project.go new file mode 100644 index 0000000..80b411e --- /dev/null +++ b/pkg/lint/detect_project.go @@ -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 +} diff --git a/pkg/lint/detect_project_test.go b/pkg/lint/detect_project_test.go new file mode 100644 index 0000000..3fa8d43 --- /dev/null +++ b/pkg/lint/detect_project_test.go @@ -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), + ) +} diff --git a/pkg/lint/matcher.go b/pkg/lint/matcher.go index 502263f..d39bd7c 100644 --- a/pkg/lint/matcher.go +++ b/pkg/lint/matcher.go @@ -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"` } diff --git a/pkg/lint/report.go b/pkg/lint/report.go index b4a23f2..6fe451a 100644 --- a/pkg/lint/report.go +++ b/pkg/lint/report.go @@ -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}, + }) +} diff --git a/pkg/lint/service.go b/pkg/lint/service.go new file mode 100644 index 0000000..5a24faa --- /dev/null +++ b/pkg/lint/service.go @@ -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) + }) +} diff --git a/pkg/lint/service_test.go b/pkg/lint/service_test.go new file mode 100644 index 0000000..28476a6 --- /dev/null +++ b/pkg/lint/service_test.go @@ -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)) +} diff --git a/tests/cli/lint/Taskfile.yaml b/tests/cli/lint/Taskfile.yaml new file mode 100644 index 0000000..aa648b4 --- /dev/null +++ b/tests/cli/lint/Taskfile.yaml @@ -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 + diff --git a/tests/cli/lint/detect/Taskfile.yaml b/tests/cli/lint/detect/Taskfile.yaml new file mode 100644 index 0000000..101aeae --- /dev/null +++ b/tests/cli/lint/detect/Taskfile.yaml @@ -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 + diff --git a/tests/cli/lint/init/Taskfile.yaml b/tests/cli/lint/init/Taskfile.yaml new file mode 100644 index 0000000..af68341 --- /dev/null +++ b/tests/cli/lint/init/Taskfile.yaml @@ -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 + diff --git a/tests/cli/lint/run/Taskfile.yaml b/tests/cli/lint/run/Taskfile.yaml new file mode 100644 index 0000000..b58e2b3 --- /dev/null +++ b/tests/cli/lint/run/Taskfile.yaml @@ -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 + diff --git a/tests/cli/lint/tools/Taskfile.yaml b/tests/cli/lint/tools/Taskfile.yaml new file mode 100644 index 0000000..f78c6f1 --- /dev/null +++ b/tests/cli/lint/tools/Taskfile.yaml @@ -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 +