package diag import ( "bytes" "context" "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "regexp" "strconv" "strings" "time" ) // killGrace is how long a linter that has outlived the deadline gets between // SIGKILL and having its pipes torn down. It exists so a child that has // already been killed cannot keep Run blocked on an inherited pipe. const killGrace = 200 * time.Millisecond // linter is one command-line checker: how to run it on a file and how to read // what it printed. It is the fallback for a language with no server installed, // and for the ones whose linter is the better tool anyway. type linter struct { // name is what the model sees as the Source. name string // bin is the program looked up on PATH. bin string // invoke returns the directory to run in and the full argv, or ok=false // when this linter does not apply to the file after all — tsc without a // tsconfig.json, for instance. invoke func(path string) (dir string, argv []string, ok bool) // parse turns the program's output into diagnostics. Relative file names // in the output are relative to dir. It receives both streams because // compilers write to stderr and linters to stdout, and which is which is // not worth remembering per tool. parse func(stdout, stderr []byte, dir string) []Diagnostic // then, if set, is run only when this one found nothing. It is how Go gets // both the compiler's errors and vet's findings without paying for vet on // a file that does not compile. then *linter } // lintersFor returns the linters that could check path, best first. func lintersFor(path string) []*linter { if strings.HasSuffix(path, "_test.go") { // go build ignores test files, so the compiler cannot be asked about // one; vet type-checks them and reports the first error, which is // better than nothing and all that is on offer without a server. return []*linter{goVet} } return linters[strings.ToLower(filepath.Ext(path))] } // linters is the table of language → command, keyed by extension, in order of // preference. Only the first entry whose program is installed runs. // // Rust is deliberately absent. `cargo check --message-format=json` is the // right tool, but it checks the whole crate and its dependencies, and on a // cold target directory that is tens of seconds to minutes — far past // checkTimeout on every edit until the cache is warm, and several seconds // after. A linter that always times out is a linter that only adds latency. // Rust is covered by rust-analyzer when it is installed, which does the same // work once and keeps it in memory. var linters = map[string][]*linter{ ".go": {goBuild}, ".py": {ruff, pyflakes}, ".ts": {eslint, tsc}, ".tsx": {eslint, tsc}, ".mts": {eslint, tsc}, ".cts": {eslint, tsc}, ".js": {eslint}, ".jsx": {eslint}, ".mjs": {eslint}, ".cjs": {eslint}, ".sh": {shellcheck}, ".bash": {shellcheck}, } // Check runs the linter and parses its output. A non-zero exit is not an // error — every linter exits non-zero when it finds something — so failure is // judged by whether the output could be read: nothing parseable plus a // non-zero exit is a linter that did not run. func (l *linter) Check(ctx context.Context, path string) ([]Diagnostic, error) { dir, argv, ok := l.invoke(path) if !ok { return nil, fmt.Errorf("%s does not apply to %s", l.name, path) } // No shell, and the path goes to execve as it stands. It was jailed by the // tool before it got here, but a file name containing a space or a // semicolon is still a file name and not two arguments. cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) cmd.Dir = dir var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr // A linter that has outlived the deadline is killed, not waited for. Go's // default on cancel is SIGKILL, and a child that ignores it is not // something waiting longer would fix. cmd.WaitDelay = killGrace runErr := cmd.Run() if ctx.Err() != nil { return nil, fmt.Errorf("%s: %w", l.name, ctx.Err()) } ds := l.parse(stdout.Bytes(), stderr.Bytes(), dir) for i := range ds { if ds[i].Source == "" { ds[i].Source = l.name } } if len(ds) == 0 { if runErr != nil { var exit *exec.ExitError if errors.As(runErr, &exit) { return nil, fmt.Errorf("%s exited %d without a readable diagnostic: %s", l.name, exit.ExitCode(), preview(stderr.Bytes(), stdout.Bytes())) } return nil, fmt.Errorf("%s: %w", l.name, runErr) } if l.then != nil { return l.then.Check(ctx, path) } } return ds, nil } // Go. // // The compiler is run first because vet stops at the first type error and the // compiler reports them all; vet is run only on a file that compiles, for the // findings the compiler does not make (Printf verbs, unreachable code). Both // take well under a second on a warm build cache, which the agent's own bash // calls keep warm. gofmt was considered and rejected: it reports files, not // lines, and a formatting difference is not a broken build. var goBuild = &linter{ name: "go build", bin: "go", invoke: goInvoke("build", "-gcflags=-e", "-o", os.DevNull), parse: parseGoOutput, then: goVet, } var goVet = &linter{ name: "go vet", bin: "go", invoke: goInvoke("vet"), parse: parseGoOutput, } // goInvoke runs a go subcommand on the file's package when it is in a module, // and on the file alone when it is not — go build with no go.mod in sight // refuses the package form but accepts a list of files. func goInvoke(sub string, flags ...string) func(string) (string, []string, bool) { return func(path string) (string, []string, bool) { dir := filepath.Dir(path) argv := append([]string{"go", sub}, flags...) if ancestorWith(dir, []string{"go.mod"}) != "" { return dir, append(argv, "."), true } return dir, append(argv, filepath.Base(path)), true } } // goLine matches the compiler's and vet's one line per problem: // // ./main.go:7:6: undefined: fmt.Printl // vet: ./main.go:7:6: undefined: fmt.Printl // other.go:6:14: fmt.Printf format %d has arg "s" of wrong type string // // The column is optional because some vet analyzers omit it. Lines beginning // with # are package headers and are skipped, as is "too many errors". var goLine = regexp.MustCompile(`^(?:vet: )?(.+?\.go):(\d+)(?::(\d+))?: (.+)$`) func parseGoOutput(stdout, stderr []byte, dir string) []Diagnostic { var out []Diagnostic for _, line := range strings.Split(string(stderr)+"\n"+string(stdout), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } m := goLine.FindStringSubmatch(line) if m == nil { continue } out = append(out, Diagnostic{ File: absIn(dir, m[1]), Line: atoi(m[2]), Col: atoi(m[3]), Severity: SeverityError, Message: m[4], }) } return out } // Python. ruff when present because it is fast and prints JSON; pyflakes // otherwise, whose one-line-per-problem output is stable enough to match. var ruff = &linter{ name: "ruff", bin: "ruff", invoke: func(path string) (string, []string, bool) { return filepath.Dir(path), []string{"ruff", "check", "--output-format=json", "--exit-zero", path}, true }, parse: parseRuff, } // parseRuff reads `ruff check --output-format=json`: an array of findings, // each with a location and a rule code. func parseRuff(stdout, _ []byte, dir string) []Diagnostic { var items []struct { Code string `json:"code"` Filename string `json:"filename"` Message string `json:"message"` Location struct { Row int `json:"row"` Column int `json:"column"` } `json:"location"` } if err := json.Unmarshal(bytes.TrimSpace(stdout), &items); err != nil { return nil } out := make([]Diagnostic, 0, len(items)) for _, it := range items { msg := it.Message if it.Code != "" { msg = it.Code + " " + msg } out = append(out, Diagnostic{ File: absIn(dir, it.Filename), Line: it.Location.Row, Col: it.Location.Column, Severity: ruffSeverity(it.Code), Message: msg, }) } return out } // ruffSeverity: ruff has no severity of its own, so syntax errors and the // pyflakes family (F…, which includes undefined names) are errors and every // style rule is a warning. func ruffSeverity(code string) Severity { if code == "" || strings.HasPrefix(code, "F") || strings.HasPrefix(code, "E9") { return SeverityError } return SeverityWarning } var pyflakes = &linter{ name: "pyflakes", bin: "pyflakes", invoke: func(path string) (string, []string, bool) { return filepath.Dir(path), []string{"pyflakes", path}, true }, parse: parseColonLines("pyflakes", SeverityError), } // colonLine matches the file:line:col: message convention shared by pyflakes, // flake8, gcc and most tools written by people who have used gcc. The column // is optional because older pyflakes did not print one. var colonLine = regexp.MustCompile(`^(.+?):(\d+)(?::(\d+))?:\s*(.+)$`) func parseColonLines(_ string, sev Severity) func(stdout, stderr []byte, dir string) []Diagnostic { return func(stdout, stderr []byte, dir string) []Diagnostic { var out []Diagnostic for _, line := range strings.Split(string(stdout)+"\n"+string(stderr), "\n") { m := colonLine.FindStringSubmatch(strings.TrimSpace(line)) if m == nil { continue } out = append(out, Diagnostic{ File: absIn(dir, m[1]), Line: atoi(m[2]), Col: atoi(m[3]), Severity: sev, Message: m[4], }) } return out } } // TypeScript and JavaScript. eslint on the one file when it is installed; tsc // on the project otherwise, and only when a tsconfig.json is in reach, because // tsc without one type-checks against defaults nobody's code was written for // and reports a wall of noise. var eslint = &linter{ name: "eslint", bin: "eslint", invoke: func(path string) (string, []string, bool) { dir := ancestorWith(filepath.Dir(path), []string{"package.json"}) if dir == "" { dir = filepath.Dir(path) } return dir, []string{"eslint", "--format", "json", "--no-error-on-unmatched-pattern", path}, true }, parse: parseESLint, } // parseESLint reads `eslint --format json`: an array of files, each with an // array of messages. severity 2 is an error, 1 a warning. func parseESLint(stdout, _ []byte, dir string) []Diagnostic { var files []struct { FilePath string `json:"filePath"` Messages []struct { RuleID string `json:"ruleId"` Severity int `json:"severity"` Message string `json:"message"` Line int `json:"line"` Column int `json:"column"` } `json:"messages"` } if err := json.Unmarshal(bytes.TrimSpace(stdout), &files); err != nil { return nil } var out []Diagnostic for _, f := range files { for _, m := range f.Messages { sev := SeverityWarning if m.Severity == 2 { sev = SeverityError } msg := m.Message if m.RuleID != "" { msg += " (" + m.RuleID + ")" } out = append(out, Diagnostic{ File: absIn(dir, f.FilePath), Line: m.Line, Col: m.Column, Severity: sev, Message: msg, }) } } return out } var tsc = &linter{ name: "tsc", bin: "tsc", invoke: func(path string) (string, []string, bool) { dir := ancestorWith(filepath.Dir(path), []string{"tsconfig.json"}) if dir == "" { return "", nil, false } return dir, []string{"tsc", "--noEmit", "--pretty", "false", "-p", "tsconfig.json"}, true }, parse: parseTSC, } // tscLine matches tsc's non-pretty output: // // src/index.ts(3,7): error TS2322: Type 'string' is not assignable to type 'number'. var tscLine = regexp.MustCompile(`^(.+?)\((\d+),(\d+)\): (error|warning) (TS\d+): (.+)$`) func parseTSC(stdout, stderr []byte, dir string) []Diagnostic { var out []Diagnostic for _, line := range strings.Split(string(stdout)+"\n"+string(stderr), "\n") { m := tscLine.FindStringSubmatch(strings.TrimSpace(line)) if m == nil { continue } sev := SeverityError if m[4] == "warning" { sev = SeverityWarning } out = append(out, Diagnostic{ File: absIn(dir, m[1]), Line: atoi(m[2]), Col: atoi(m[3]), Severity: sev, Message: m[5] + ": " + m[6], }) } return out } // Shell. var shellcheck = &linter{ name: "shellcheck", bin: "shellcheck", invoke: func(path string) (string, []string, bool) { return filepath.Dir(path), []string{"shellcheck", "--format=json", path}, true }, parse: parseShellcheck, } // parseShellcheck reads `shellcheck --format=json`: a flat array with a level // of error, warning, info or style. func parseShellcheck(stdout, _ []byte, dir string) []Diagnostic { var items []struct { File string `json:"file"` Line int `json:"line"` Column int `json:"column"` Level string `json:"level"` Code int `json:"code"` Message string `json:"message"` } if err := json.Unmarshal(bytes.TrimSpace(stdout), &items); err != nil { return nil } out := make([]Diagnostic, 0, len(items)) for _, it := range items { sev := SeverityWarning switch it.Level { case "error": sev = SeverityError case "info": sev = SeverityInfo case "style": sev = SeverityHint } out = append(out, Diagnostic{ File: absIn(dir, it.File), Line: it.Line, Col: it.Column, Severity: sev, Message: fmt.Sprintf("SC%d %s", it.Code, it.Message), }) } return out } // absIn resolves a file name printed by a tool run in dir. func absIn(dir, name string) string { name = filepath.FromSlash(name) if filepath.IsAbs(name) { return filepath.Clean(name) } return filepath.Join(dir, name) } func atoi(s string) int { n, _ := strconv.Atoi(s) return n } // preview is the first part of what the command printed, for the error a // failed run carries. It is never shown to the model, but a test or a future // debug log that says "exited 2" without the output sends someone to run the // command by hand to learn what this process already knew. func preview(streams ...[]byte) string { const max = 200 for _, s := range streams { t := strings.TrimSpace(string(s)) if t == "" { continue } t = strings.Join(strings.Fields(t), " ") if len(t) > max { t = t[:max] + "…" } return t } return "(nothing)" }