// Package diag is what checks a file the agent has just written. // // The agent edits a file and, today, learns that it broke the build three tool // calls later, when it runs the tests or the user complains. The compiler // already knew at the moment of the edit. This package runs whatever this // machine has for the language — a language server if one is installed, a // linter otherwise — against the file that just changed, and hands what it // said back to the edit and write tools so it lands in the result the model is // already reading. Nothing new is installed and nothing leaves the machine. // // It is one package rather than part of internal/tools for the same reason // websearch is: the tools call Report and everything about which programs are // run, how long they may take and how their output is read lives here, where // it can be read in one sitting. It imports no other internal package. package diag import ( "context" "fmt" "os/exec" "path/filepath" "sort" "strings" "sync" "time" ) // checkTimeout is how long one check may take, start to finish, including a // language server that is still starting up. // // The model waits through this on every edit, so it is a fraction of the bash // tool's minute rather than a multiple of it: two seconds of latency per edit // is the price of the compiler's opinion, and a check that has not finished by // then is a check to abandon. It is short enough that gopls on a cold cache // will usually miss it on the very first edit of a session — that is // accepted, because the server keeps loading in the background and every edit // after that gets an answer in well under a second. const checkTimeout = 2 * time.Second // settleWindow is how long, after the first batch of diagnostics for the // changed file arrives, a check goes on listening for more. // // A language server does not answer a didOpen with one tidy response. gopls // type-checks the package and publishes a batch per file in it, and some // servers publish an empty batch first to clear stale marks and the real one a // moment later. Returning on the first batch would report a clean file that is // about to be reported broken; waiting for the deadline would make every clean // edit cost the full timeout. A short window after the first batch collects // the neighbours and the correction without paying for silence. const settleWindow = 250 * time.Millisecond // startCooldown is how long a server that failed to start is left alone. Long // enough that a broken install is not retried on every edit; short enough // that fixing the install mid-session takes effect without a restart. const startCooldown = 5 * time.Minute // maxReported is how many diagnostics are shown to the model. Ten covers a // real mistake in full; a hundred is a file so broken that the model needs to // read it again rather than a list. const maxReported = 10 // Severity is how serious a diagnostic is, using the LSP's four levels. type Severity int // Severities, from worst to least. The numbering is the LSP's so that a // server's value can be used as it stands. const ( SeverityError Severity = 1 SeverityWarning Severity = 2 SeverityInfo Severity = 3 SeverityHint Severity = 4 ) // String returns the lower-case name a diagnostic line carries. func (s Severity) String() string { switch s { case SeverityError: return "error" case SeverityWarning: return "warning" case SeverityInfo: return "info" case SeverityHint: return "hint" } return fmt.Sprintf("severity(%d)", int(s)) } // Diagnostic is one thing a checker had to say about a file. It is the common // shape every backend is parsed into, so the formatting and the cap are written // once. type Diagnostic struct { // File is the absolute path the diagnostic is about. It is usually the // file that was checked, but a package-level tool reports neighbours too. File string // Line and Col are 1-based. Col is 0 when the tool did not say. Line, Col int Severity Severity Message string // Source is which program said it — "gopls", "ruff" — so the model knows // how much to trust it and the user knows what to install or uninstall. Source string } // Checker is one way of checking a file. The two implementations are a // language server and a linter command; the interface exists so the Runner // picks between them without caring which it got, and so tests can hand it a // fake. type Checker interface { // Check returns what the backend has to say about the file at path. A nil // slice with a nil error is a clean file. An error means the backend could // not run — not installed, timed out, unparseable output — and is never // shown to the model; it is silence with a reason. Check(ctx context.Context, path string) ([]Diagnostic, error) } // Runner is the one object the tools hold. It owns the language servers that // stay running between edits and chooses, per file, between a server and a // linter. A nil *Runner is valid and does nothing, which is how the feature is // turned off: every method tolerates a nil receiver so the call sites need no // switch of their own. type Runner struct { mu sync.Mutex servers map[string]*session // cooldown records, per server key, when it may next be started. A // server that failed to start is not tried again for a while, and in the // meantime the language's linter is used instead; without this a broken // gopls would be respawned on every Go edit and each edit would wait the // full deadline for it. cooldown map[string]time.Time closed bool // lookPath is exec.LookPath unless a test has replaced it, so the choice // between server and linter can be exercised on a machine with neither. lookPath func(string) (string, error) // idle is how long a server may sit unused before it is shut down. idle time.Duration } // NewRunner returns a Runner with no servers started. Servers are started on // the first file that needs one, so a session that never touches Go never pays // for gopls. func NewRunner() *Runner { return &Runner{ servers: map[string]*session{}, cooldown: map[string]time.Time{}, lookPath: exec.LookPath, idle: idleTimeout, } } // Report checks path and returns the block of text to append to a tool result. // root is what the file names in that text are made relative to — the working // root the model already thinks in. // // It returns "" when there is nothing to say, and that covers both a clean // file and a check that could not run. Silence is the good news, and a "0 // problems" line on every edit would be pure token cost, paid on the great // majority of edits, for information the absence of a block already carries. // A backend failure is silent for the same reason a missing backend is: the // edit succeeded, and the model has nothing to do about a linter that did not // start. func (r *Runner) Report(ctx context.Context, root, path string) (out string) { if r == nil { return "" } // The edit has already been applied by the time this runs. A bug in here // must not turn a successful edit into a crashed session, so this is the // one place in the harness a panic is caught rather than propagated. defer func() { if rec := recover(); rec != nil { out = "" } }() ctx, cancel := context.WithTimeout(ctx, checkTimeout) defer cancel() ds, err := r.Check(ctx, path) if err != nil || len(ds) == 0 { return "" } return Format(ds, root, path) } // Check runs the best available checker for path. It picks a language server // when one for the file's language is on PATH, otherwise the first installed // linter for its extension. It satisfies Checker so a Runner can stand in // wherever one is wanted. func (r *Runner) Check(ctx context.Context, path string) ([]Diagnostic, error) { if r == nil { return nil, nil } path, err := filepath.Abs(path) if err != nil { return nil, err } if s := r.server(path); s != nil { return s.Check(ctx, path) } if l := r.linter(path); l != nil { return l.Check(ctx, path) } return nil, errNoChecker } // Close shuts down every language server the Runner has started. It is safe to // call on a nil Runner and more than once. Servers are shut down concurrently // with a short deadline each, because a server that ignores shutdown must not // hold up exit. func (r *Runner) Close() { if r == nil { return } r.mu.Lock() r.closed = true servers := r.servers r.servers = map[string]*session{} r.mu.Unlock() var wg sync.WaitGroup for _, s := range servers { wg.Add(1) go func(s *session) { defer wg.Done() s.Close() }(s) } wg.Wait() } // server returns the running or starting language server for path's language // and workspace, starting one if the program is installed and none is running. // It returns nil when no server applies. func (r *Runner) server(path string) *session { s, _ := r.lookup(path) return s } // lookup is server with the reason for a nil. Check has no use for the reason // — a file it cannot check is silence either way — but a query the model // made by name deserves to hear whether the answer is "install gopls" or // "this is a Markdown file", and those are different things to do next. func (r *Runner) lookup(path string) (*session, error) { spec, ok := serverFor(path) if !ok { what := strings.ToLower(filepath.Ext(path)) if what == "" { what = filepath.Base(path) } return nil, fmt.Errorf("%w (%s)", ErrNoServer, what) } bin, err := r.lookPath(spec.Bin) if err != nil { return nil, fmt.Errorf("%w: %s handles %s files but is not on PATH", ErrNotInstalled, spec.Bin, strings.ToLower(filepath.Ext(path))) } root := workspaceRoot(path, spec.Markers) key := root + "\x00" + spec.Bin r.mu.Lock() defer r.mu.Unlock() if r.closed { return nil, fmt.Errorf("%s: %w", spec.Bin, errServerDead) } if s, ok := r.servers[key]; ok && !s.Dead() { return s, nil } if until, ok := r.cooldown[key]; ok && time.Now().Before(until) { return nil, fmt.Errorf("%s: %w; not retried for another %s", spec.Bin, ErrStartFailed, time.Until(until).Round(time.Second)) } s := startServer(bin, spec, root, r.idle, func(s *session) { r.exited(key, s) }) if s == nil { r.cooldown[key] = time.Now().Add(startCooldown) return nil, fmt.Errorf("%s: %w", spec.Bin, ErrStartFailed) } r.servers[key] = s return s, nil } // exited is called once a server's process has gone — an idle shutdown, a // crash, a refusal to start. It drops the server from the table so the next // file in that language starts a fresh one rather than talking to a corpse, // and if this one never got as far as answering initialize, puts the key on // cooldown. The identity check matters: a replacement may already be // registered under the same key, and forgetting that one would leak it. func (r *Runner) exited(key string, s *session) { r.mu.Lock() defer r.mu.Unlock() if r.servers[key] == s { delete(r.servers, key) } if s.failedToStart() { r.cooldown[key] = time.Now().Add(startCooldown) } } // linter returns the first installed linter for path's extension, or nil. func (r *Runner) linter(path string) Checker { for _, l := range lintersFor(path) { if _, err := r.lookPath(l.bin); err == nil { return l } } return nil } // errNoChecker is what Check returns when nothing on this machine can check // the file. It is an error rather than an empty result so a caller that wants // to tell "clean" from "unchecked" can. var errNoChecker = fmt.Errorf("no language server or linter installed for this file") // Format renders diagnostics for the model, or "" when there are none. File // names are made relative to root; path is the file that was checked, whose // diagnostics are listed first so the cap drops a neighbour's problems before // the model's own. func Format(ds []Diagnostic, root, path string) string { ds = keep(ds) if len(ds) == 0 { return "" } sortForReport(ds, path) source := ds[0].Source for _, d := range ds[1:] { if d.Source != source { source = "mixed" break } } var b strings.Builder fmt.Fprintf(&b, "diagnostics (%s): %s", source, plural(len(ds), "problem")) shown := ds if len(shown) > maxReported { shown = shown[:maxReported] } for _, d := range shown { b.WriteString("\n ") b.WriteString(relative(root, d.File)) if d.Line > 0 { fmt.Fprintf(&b, ":%d", d.Line) if d.Col > 0 { fmt.Fprintf(&b, ":%d", d.Col) } } b.WriteString(": ") // Errors are the default reading of a diagnostic line, so only the // lesser levels are labelled; the label is what tells the model it // may leave the line alone. if d.Severity != SeverityError && d.Severity != 0 { b.WriteString(d.Severity.String()) b.WriteString(": ") } b.WriteString(oneLine(d.Message)) } if dropped := len(ds) - len(shown); dropped > 0 { fmt.Fprintf(&b, "\n … and %d more not shown", dropped) } return b.String() } // keep drops the levels not worth the model's attention. Hints are a server's // stylistic asides — gopls's "could be simplified", pyright's greyed-out // unused import — and a model that fixes every hint it is shown rewrites code // it was not asked to touch. func keep(ds []Diagnostic) []Diagnostic { out := make([]Diagnostic, 0, len(ds)) for _, d := range ds { if d.Severity == SeverityHint { continue } if strings.TrimSpace(d.Message) == "" { continue } out = append(out, d) } return out } // sortForReport orders the changed file first, then by severity, then by // position, so the cap cuts from the least important end. func sortForReport(ds []Diagnostic, path string) { sort.SliceStable(ds, func(i, j int) bool { a, b := ds[i], ds[j] if (a.File == path) != (b.File == path) { return a.File == path } if a.File != b.File { return a.File < b.File } if a.Severity != b.Severity { return a.Severity < b.Severity } if a.Line != b.Line { return a.Line < b.Line } return a.Col < b.Col }) } // relative is path as the model would write it: relative to root when it is // under root, and as it stands otherwise. A neighbour above the working root // — a go.mod one directory up — is shown with its ../ rather than hidden. func relative(root, path string) string { if root == "" { return path } rel, err := filepath.Rel(root, path) if err != nil { return path } return filepath.ToSlash(rel) } // oneLine folds a multi-line message onto one line. Compiler messages are // almost always one line; the ones that are not (rustc's, with its notes and // help) would otherwise break the one-problem-per-line shape the model is // parsing by eye. func oneLine(s string) string { s = strings.TrimSpace(s) if !strings.ContainsAny(s, "\r\n") { return s } return strings.Join(strings.Fields(s), " ") } func plural(n int, noun string) string { if n == 1 { return fmt.Sprintf("1 %s", noun) } return fmt.Sprintf("%d %ss", n, noun) }