review

review patchsets using your default editor
Log | Files | Refs

commit d88d25b5e123353506e14fbbc651d4c78e02af9b
parent 3cf4697eff5c068ac7d860172dc63b27463ea046
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Wed, 23 Sep 2026 17:58:58 -0300

review: move judgement-free checks out of the readings and into Go

The readings asked a model for what a program can measure: assertions
that cannot fail, names that stutter or shadow, clones, coverage gaps,
leftovers, formatting, nesting, and the compilers' and linters' own
diagnostics. Each is now a deterministic check or an analyser, reported
with a stable id, run before a provider is built, and survives a model
that cannot answer. Repository exploration moves from shell programs into
Go: a change's tree is materialised once through git archive and every
frontend reads through it, so the tool ports wherever git does.

Two defects in the frontends and analysers close with this. Semgrep
never ran without a repository config, because --config auto refuses to
build under --metrics off; the fallback is the registry's p/default pack.
The TypeScript frontend read only bare test() and it() calls; it now
reads Node, Bun and Deno shapes, with modifiers, tables, and Deno's
object and named-function forms, and the deleted-test check reads the
same shapes.

Diffstat:
Aanalysers.go | 827+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aanalysers_test.go | 282+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aassertions_test.go | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abench.go | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abench_test.go | 47+++++++++++++++++++++++++++++++++++++++++++++++
Mcache_test.go | 24++++++++++++++++++++++++
Mclient.go | 119++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Aclones.go | 212+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aclones_test.go | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acoverage.go | 203+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acoverage_test.go | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcriteria/claims.md | 4+++-
Mcriteria/namer.md | 9+++------
Mcriteria/tests.md | 6++++++
Mfinding.go | 29+++++++++++++++++++++++------
Mfinding_test.go | 42+++++++++++++++++++++++++++++++++++++++---
Aformatting.go | 43+++++++++++++++++++++++++++++++++++++++++++
Aformatting_test.go | 40++++++++++++++++++++++++++++++++++++++++
Mfrontend.go | 2+-
Mfrontend_test.go | 13+++++++------
Mgaming.go | 48+++++++++++++++++++++++++++++++++++++++++++-----
Mgaming_test.go | 18++++++++++++++++++
Mgofrontend.go | 40+++++++++++++++++++++++++++++++++++-----
Mhelper_test.go | 7+++++++
Mheuristic.go | 5+++--
Ahook.go | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahook_test.go | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mindex.go | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mjob.go | 211+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mjob_test.go | 42++++++++++++++++++++++++++++++++++++++++++
Akinds.go | 234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Akinds_test.go | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aleftovers.go | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aleftovers_test.go | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aloop_test.go | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmain.go | 253+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mmain_test.go | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Anames.go | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anames_test.go | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Modinfrontend.go | 60++++++++++++++++++++++++++++++++++++++++--------------------
Mpacket.go | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mpacket_test.go | 41+++++++++++++++++++++++++++++++++++++++++
Aprose.go | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aprose_test.go | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mreadme.md | 316+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Arules.go | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Arules_test.go | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ashape.go | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ashape_test.go | 38++++++++++++++++++++++++++++++++++++++
Asidecar/govet/go.mod | 10++++++++++
Asidecar/govet/go.sum | 8++++++++
Asidecar/govet/main.go | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mstatic.go | 211+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mstatic_test.go | 110++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mstaticcheck.go | 195++++++++++++++-----------------------------------------------------------------
Atests.go | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atree.go | 238+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atree_test.go | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtsfrontend.go | 111++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mtsfrontend_test.go | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
60 files changed, 6532 insertions(+), 508 deletions(-)

diff --git a/analysers.go b/analysers.go @@ -0,0 +1,827 @@ +package main + +// The compilers and analysers a repository's languages already have are the +// deterministic checks with the most to say, and review has nothing to +// teach them. What it decides is which of their findings belong to the +// change: an error anywhere in a unit the change touched is the change's +// to answer, because the tree does not compile until it is; a warning is +// the change's only where it lands on a line the change added. Each is run +// with its strictest settings and asked for JSON, on the tree the change +// arrives at. + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strings" + "time" +) + +// analyserTimeout bounds one analyser's run. A cold first analysis of a +// large module can take minutes; past this the analyser says nothing rather +// than holding the review, and the run warms the cache for the next one. +const analyserTimeout = 5 * time.Minute + +// Diagnostic is one thing an analyser said, located in the tree. +type Diagnostic struct { + File string + Line int + Code string + Message string + Severity Severity + // Fault is whether the diagnostic is a compile error — the tree does + // not build — rather than an analyser's opinion about code that does. + Fault bool +} + +// Analyser is one compiler or analyser, run over the units the change's +// files belong to. +type Analyser struct { + // Name labels the analyser and prefixes its rule ids: name/code. + Name string + // Covers is whether a path is one of the analyser's language. + Covers func(path string) bool + // Ready is whether the analyser can run in this tree: its binary is on + // the path, and the tree has what it needs. The reason it cannot is + // told once, on stderr. + Ready func(tree string) (bool, string) + // Run analyses the units the files belong to, in the tree, and returns + // what it found with paths relative to the tree. + Run func(ctx context.Context, tree, root string, files []string) ([]Diagnostic, error) + // InPlace is whether the analyser needs the working tree's surroundings + // — installed packages, a node_modules — and so cannot read a range's + // materialised tree. + InPlace bool +} + +// Analysers are the compilers and analysers review knows how to run, in the +// order their findings are worth having. +func Analysers() []Analyser { + return []Analyser{goBuild, goVet, staticcheckAnalyser, odinCheck, tsc, ruff, mypy, cargoCheck, semgrep} +} + +// checkAnalysers runs every analyser that covers a changed file and can run, +// and keeps the findings that belong to the change. +func checkAnalysers(root, rev string, c *Change, analysers ...Analyser) []Finding { + tree, err := treeAt(root, rev) + if err != nil { + return nil + } + _, ranged := ends(rev) + added := addedLines(c.Diff) + changed := map[string]bool{} + for _, f := range c.Files { + changed[f] = true + } + var out []Finding + for _, a := range analysers { + var files []string + for _, f := range c.Files { + if a.Covers(f) && tree.Exists(f) { + files = append(files, f) + } + } + if len(files) == 0 { + continue + } + if ready, why := a.Ready(tree.Dir()); !ready { + if why != "" { + fmt.Fprintf(os.Stderr, "skipping %s: %s\n", a.Name, why) + } + continue + } + if a.InPlace && ranged { + fmt.Fprintf(os.Stderr, "skipping %s: it reads the working tree, and the change is a range\n", a.Name) + continue + } + ctx, cancel := context.WithTimeout(context.Background(), analyserTimeout) + diagnostics, err := a.Run(ctx, tree.Dir(), root, files) + expired := errors.Is(ctx.Err(), context.DeadlineExceeded) + cancel() + if expired { + fmt.Fprintf(os.Stderr, "skipping %s: a run past %v is not waited for\n", a.Name, analyserTimeout) + continue + } + if err != nil { + fmt.Fprintf(os.Stderr, "skipping %s: %s\n", a.Name, first(err.Error(), 200)) + continue + } + seen := map[string]bool{} + for _, d := range diagnostics { + if d.File == "" || d.Line == 0 { + continue + } + // A fault is the change's wherever it lands: the unit it + // touched no longer compiles. An opinion is the change's only + // on a line it added. + if !d.Fault && (!changed[d.File] || !slices.Contains(added[d.File], d.Line)) { + continue + } + key := fmt.Sprintf("%s:%d:%s:%s", d.File, d.Line, d.Code, d.Message) + if seen[key] { + continue + } + seen[key] = true + rule := a.Name + if d.Code != "" { + rule += "/" + d.Code + } + out = append(out, Finding{ + Job: "static", Rule: rule, Severity: d.Severity, + File: d.File, Line: d.Line, Message: d.Message, + }) + } + } + return out +} + +// execute works one command in the tree and returns its output. A command that +// reports findings by exiting non-zero is not a failed run: its stdout is +// the answer, and only an empty stdout with a non-zero exit is a fault. +func execute(ctx context.Context, dir string, env []string, name string, args ...string) ([]byte, error) { + stdout, stderr, err := executeBoth(ctx, dir, env, name, args...) + if err != nil && len(stdout) == 0 { + var exit *exec.ExitError + if !errors.As(err, &exit) { + return nil, fmt.Errorf("%s: %w", name, err) + } + return nil, fmt.Errorf("%s: %s", name, tail(orElse(strings.TrimSpace(string(stderr)), err.Error()), 200)) + } + return stdout, nil +} + +// executeBoth works one command and returns both streams: the Odin compiler +// writes its JSON to stderr, and a tool that fails before it starts says +// why there too. +func executeBoth(ctx context.Context, dir string, env []string, name string, args ...string) (stdout, stderr []byte, err error) { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Dir = dir + if env != nil { + cmd.Env = append(os.Environ(), env...) + } + var out, errs bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errs + err = cmd.Run() + return out.Bytes(), errs.Bytes(), err +} + +// onPath is a Ready that needs only the binary. +func onPath(binary string) func(string) (bool, string) { + return func(string) (bool, string) { + if _, err := exec.LookPath(binary); err != nil { + return false, "" + } + return true, "" + } +} + +// goReady is whether a Go tool can run: the go command, and a module at the +// tree's root. +func goReady(binary string) func(string) (bool, string) { + return func(tree string) (bool, string) { + if _, err := exec.LookPath(binary); err != nil { + return false, "" + } + if _, err := os.Stat(filepath.Join(tree, "go.mod")); err != nil { + return false, "no go.mod at the repository root" + } + return true, "" + } +} + +func isGo(path string) bool { return strings.HasSuffix(path, ".go") } + +// goPackages names the packages of the files, as patterns the go command +// takes. The leading ./ is load-bearing: without it a directory reads as a +// module path and matches nothing. +func goPackages(tree string, files []string) []string { + dirs := map[string]bool{} + for _, f := range files { + dir := filepath.Dir(f) + if dir == "vendor" || strings.HasPrefix(dir, "vendor/") { + continue + } + if _, err := os.Stat(filepath.Join(tree, dir)); err != nil { + continue + } + dirs[dir] = true + } + var out []string + for dir := range dirs { + out = append(out, "./"+filepath.ToSlash(dir)) + } + slices.Sort(out) + return out +} + +// position matches the file:line:column: message the Go tools print. +var position = regexp.MustCompile(`^(.+?):(\d+)(?::(\d+))?: (.*)$`) + +// goBuild compiles the packages the change touched. What does not compile +// is the change's wherever the error lands. +var goBuild = Analyser{ + Name: "go-build", + Covers: isGo, + Ready: goReady("go"), + Run: func(ctx context.Context, tree, root string, files []string) ([]Diagnostic, error) { + pkgs := goPackages(tree, files) + if len(pkgs) == 0 { + return nil, nil + } + out, err := execute(ctx, tree, nil, "go", append([]string{"build", "-json", "-o", os.DevNull}, pkgs...)...) + if err != nil { + return nil, err + } + return parseGoBuild(tree, out), nil + }, +} + +// parseGoBuild reads the compiler's errors out of go build -json: build +// events whose output lines are file:line:col: message, relative to the +// tree. +func parseGoBuild(tree string, out []byte) []Diagnostic { + var diagnostics []Diagnostic + for _, line := range bytes.Split(out, []byte("\n")) { + var event struct { + Action string `json:"Action"` + Output string `json:"Output"` + } + if json.Unmarshal(line, &event) != nil || event.Action != "build-output" { + continue + } + for _, text := range strings.Split(event.Output, "\n") { + m := position.FindStringSubmatch(strings.TrimSpace(text)) + if m == nil || strings.HasPrefix(text, "#") { + continue + } + file := relative(tree, filepath.Join(tree, filepath.FromSlash(m[1]))) + diagnostics = append(diagnostics, Diagnostic{ + File: filepath.ToSlash(file), Line: atoi(m[2]), Message: m[4], + Severity: MustFix, Fault: true, + }) + } + } + return diagnostics +} + +// vetTool is the multichecker built from sidecar/govet: vet's own analysers +// and the ones from golang.org/x/tools it leaves out — nilness, shadow, +// unusedwrite and the rest. When it is on the path, vet runs it instead of +// its default set. +const vetTool = "review-vet" + +// vetSeverity is how seriously to take one of vet's analysers. Vet's default +// set and the bug-finding extras are faults the analyser argues for; shadow +// and unusedwrite are judgement; modernize is taste. +func vetSeverity(analyzer string) Severity { + switch analyzer { + case "shadow", "unusedwrite": + return Consider + } + if strings.HasPrefix(analyzer, "modernize") || slices.Contains(modernizers, analyzer) { + return Note + } + return MustFix +} + +// modernizers are the names of the modernize suite's analysers, which report +// an older idiom where a newer one exists. +var modernizers = strings.Fields(`any atomictypes embedlit errorsastype forvar importcomment mapsloop minmax newexpr + omitzero plusbuild rangeint reflecttypeassert reflecttypefor slicesbackward slicesclip slicescontains slicessort + stditerators stringscut stringscutprefix stringsseq stringsbuilder testingcontext unsafefuncs waitgroup`) + +// goVet runs vet over the packages the change touched — with review-vet +// where it is installed — and reads its JSON: one object per package, one +// list per analyser. +var goVet = Analyser{ + Name: "go-vet", + Covers: isGo, + Ready: goReady("go"), + Run: func(ctx context.Context, tree, root string, files []string) ([]Diagnostic, error) { + pkgs := goPackages(tree, files) + if len(pkgs) == 0 { + return nil, nil + } + args := []string{"vet", "-json"} + if tool, err := exec.LookPath(vetTool); err == nil { + args = append(args, "-vettool="+tool) + } + out, err := execute(ctx, tree, nil, "go", append(args, pkgs...)...) + if err != nil { + return nil, err + } + return parseGoVet(tree, out), nil + }, +} + +// parseGoVet reads vet's JSON: one object per package, one list per +// analyser. The JSON is preceded by a comment line naming the package, and +// a package that fails to type-check is reported in prose rather than JSON; +// both are skipped, since the build has already said what does not compile. +func parseGoVet(tree string, out []byte) []Diagnostic { + var diagnostics []Diagnostic + { + for _, chunk := range splitJSONObjects(out) { + var report map[string]map[string][]struct { + Posn string `json:"posn"` + Message string `json:"message"` + } + if json.Unmarshal(chunk, &report) != nil { + continue + } + for _, analysers := range report { + for analyzer, found := range analysers { + for _, f := range found { + file, line := splitPosition(f.Posn) + if file == "" { + continue + } + if filepath.IsAbs(file) { + file = relative(tree, file) + } + diagnostics = append(diagnostics, Diagnostic{ + File: filepath.ToSlash(file), Line: line, Code: analyzer, + Message: f.Message, Severity: vetSeverity(analyzer), + }) + } + } + } + } + } + return diagnostics +} + +// splitJSONObjects cuts a stream of top-level JSON objects, with anything +// between them — vet's # comment lines, prose about a failed package — left +// out. An object opens only at the start of a line, as vet writes them, so +// a brace in the prose opens nothing. +func splitJSONObjects(out []byte) [][]byte { + var chunks [][]byte + depth, start := 0, -1 + inString, lineStart := false, true + for i := 0; i < len(out); i++ { + c := out[i] + switch { + case inString: + if c == '\\' { + i++ + } else if c == '"' { + inString = false + } + case depth > 0 && c == '"': + inString = true + case c == '{' && (depth > 0 || lineStart): + if depth == 0 { + start = i + } + depth++ + case c == '}' && depth > 0: + depth-- + if depth == 0 { + chunks = append(chunks, out[start:i+1]) + start = -1 + } + } + lineStart = c == '\n' + } + return chunks +} + +// splitPosition reads file and line out of file:line:col. +func splitPosition(posn string) (string, int) { + m := regexp.MustCompile(`^(.+?):(\d+)(?::\d+)?$`).FindStringSubmatch(posn) + if m == nil { + return "", 0 + } + return m[1], atoi(m[2]) +} + +// odinCheck type-checks each package the change touched with every vet +// switch and the compiler's own style, and reads its JSON errors. +var odinCheck = Analyser{ + Name: "odin-check", + Covers: func(path string) bool { return strings.HasSuffix(path, ".odin") }, + Ready: onPath("odin"), + Run: func(ctx context.Context, tree, root string, files []string) ([]Diagnostic, error) { + dirs := map[string]bool{} + for _, f := range files { + dirs[filepath.Dir(f)] = true + } + var diagnostics []Diagnostic + for _, dir := range slices.Sorted(func(yield func(string) bool) { + for d := range dirs { + if !yield(d) { + return + } + } + }) { + // The compiler writes its JSON to stderr and exits non-zero + // when it has errors, which is the answer, not a failure. + stdout, stderr, err := executeBoth(ctx, tree, nil, "odin", "check", dir, "-vet", "-strict-style", "-json-errors", "-no-entry-point") + out := append(stdout, stderr...) + if err != nil && !bytes.Contains(out, []byte("error_count")) { + return diagnostics, fmt.Errorf("odin: %s", tail(strings.TrimSpace(string(out)), 200)) + } + diagnostics = append(diagnostics, parseOdin(tree, out)...) + } + return diagnostics, nil + }, +} + +// parseOdin reads the compiler's -json-errors: a type error is a fault, a +// vet failure the vet's opinion, a style failure a note. +func parseOdin(tree string, out []byte) []Diagnostic { + var report struct { + Errors []struct { + Type string `json:"type"` + Pos struct { + File string `json:"file"` + Line int `json:"line"` + } `json:"pos"` + Msgs []string `json:"msgs"` + } `json:"errors"` + } + // The compiler prints its own prose before the JSON when it cannot + // even start; the object is taken from wherever it sits. + if raw, err := object(string(out)); err == nil { + json.Unmarshal([]byte(raw), &report) + } + var diagnostics []Diagnostic + for _, e := range report.Errors { + message := strings.Join(e.Msgs, "; ") + d := Diagnostic{ + File: filepath.ToSlash(relative(tree, e.Pos.File)), Line: e.Pos.Line, + Message: message, Severity: MustFix, Fault: true, + } + switch { + case strings.Contains(message, "-strict-style"): + d.Code, d.Severity, d.Fault = "style", Note, false + case e.Type == "warning": + d.Severity, d.Fault = Consider, false + case strings.Contains(message, "declared but not used"), strings.Contains(message, "shadow"): + // A vet failure fails the build under -vet, but it is the + // vet's opinion, not a type error. + d.Code, d.Severity, d.Fault = "vet", Consider, false + } + diagnostics = append(diagnostics, d) + } + return diagnostics +} + +// tscLine matches the compiler's plain output: file(line,col): error TSnnnn: message. +var tscLine = regexp.MustCompile(`^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$`) + +// tsc type-checks each project a changed TypeScript file belongs to, under +// the project's own tsconfig — strictness is the project's to set — and +// emits nothing. It needs the project's node_modules, so it reads the +// working tree only. +var tsc = Analyser{ + Name: "tsc", + Covers: func(path string) bool { return grammarOf(path) != "" }, + InPlace: true, + Ready: func(tree string) (bool, string) { + if _, err := exec.LookPath("tsc"); err == nil { + return true, "" + } + if _, err := os.Stat(filepath.Join(tree, "node_modules", ".bin", "tsc")); err == nil { + return true, "" + } + return false, "" + }, + Run: func(ctx context.Context, tree, root string, files []string) ([]Diagnostic, error) { + binary := filepath.Join(tree, "node_modules", ".bin", "tsc") + if _, err := os.Stat(binary); err != nil { + binary = "tsc" + } + projects := map[string]bool{} + for _, f := range files { + if p := nearest(tree, filepath.Dir(f), "tsconfig.json"); p != "" { + projects[p] = true + } + } + if len(projects) == 0 { + return nil, fmt.Errorf("no tsconfig.json above the changed files") + } + var diagnostics []Diagnostic + for _, project := range slices.Sorted(func(yield func(string) bool) { + for p := range projects { + if !yield(p) { + return + } + } + }) { + dir := filepath.Join(tree, filepath.Dir(project)) + out, err := execute(ctx, dir, nil, binary, "--noEmit", "--pretty", "false", "-p", "tsconfig.json") + if err != nil { + return diagnostics, err + } + diagnostics = append(diagnostics, parseTsc(tree, dir, out)...) + } + return diagnostics, nil + }, +} + +// parseTsc reads the compiler's plain output, with paths relative to the +// project directory it ran in. +func parseTsc(tree, dir string, out []byte) []Diagnostic { + var diagnostics []Diagnostic + for _, line := range strings.Split(string(out), "\n") { + m := tscLine.FindStringSubmatch(strings.TrimSpace(line)) + if m == nil { + continue + } + file := relative(tree, filepath.Join(dir, filepath.FromSlash(m[1]))) + diagnostics = append(diagnostics, Diagnostic{ + File: filepath.ToSlash(file), Line: atoi(m[2]), Code: m[4], Message: m[5], + Severity: MustFix, Fault: true, + }) + } + return diagnostics +} + +// nearest is the path, relative to the tree, of the first file called name +// in dir or a directory above it, or empty. +func nearest(tree, dir, name string) string { + for { + candidate := filepath.Join(dir, name) + if _, err := os.Stat(filepath.Join(tree, candidate)); err == nil { + return filepath.ToSlash(candidate) + } + if dir == "." || dir == "" || dir == "/" { + return "" + } + dir = filepath.Dir(dir) + } +} + +func isPython(path string) bool { return strings.HasSuffix(path, ".py") } + +// ruff lints the changed Python files with the project's own configuration +// and reads its JSON. Its style codes are notes; what pyflakes would have +// said is worth considering. +var ruff = Analyser{ + Name: "ruff", + Covers: isPython, + Ready: onPath("ruff"), + Run: func(ctx context.Context, tree, root string, files []string) ([]Diagnostic, error) { + out, err := execute(ctx, tree, nil, "ruff", append([]string{"check", "--output-format", "json", "--exit-zero"}, files...)...) + if err != nil { + return nil, err + } + return parseRuff(tree, out) + }, +} + +// parseRuff reads ruff's JSON array. Style codes — E, W, import order, +// docstrings — are notes; the rest is worth considering. +func parseRuff(tree string, out []byte) ([]Diagnostic, error) { + var report []struct { + Code string `json:"code"` + Message string `json:"message"` + Filename string `json:"filename"` + Location struct { + Row int `json:"row"` + } `json:"location"` + } + if err := json.Unmarshal(out, &report); err != nil { + return nil, fmt.Errorf("ruff: %w", err) + } + var diagnostics []Diagnostic + for _, r := range report { + severity := Consider + if strings.HasPrefix(r.Code, "E") || strings.HasPrefix(r.Code, "W") || strings.HasPrefix(r.Code, "I") || strings.HasPrefix(r.Code, "D") { + severity = Note + } + diagnostics = append(diagnostics, Diagnostic{ + File: filepath.ToSlash(relative(tree, r.Filename)), Line: r.Location.Row, + Code: r.Code, Message: r.Message, Severity: severity, + }) + } + return diagnostics, nil +} + +// mypy type-checks the changed Python files and reads its JSON, one object +// per line. Imports it cannot find are the environment's business, not the +// change's, and are not reported. +var mypy = Analyser{ + Name: "mypy", + Covers: isPython, + Ready: onPath("mypy"), + InPlace: true, + Run: func(ctx context.Context, tree, root string, files []string) ([]Diagnostic, error) { + out, err := execute(ctx, tree, nil, "mypy", append([]string{"--output", "json", "--no-error-summary", "--ignore-missing-imports"}, files...)...) + if err != nil { + return nil, err + } + return parseMypy(tree, out), nil + }, +} + +// parseMypy reads mypy's JSON, one object per line: an error is a type +// error and must-fix, anything else a note. +func parseMypy(tree string, out []byte) []Diagnostic { + var diagnostics []Diagnostic + for _, line := range bytes.Split(out, []byte("\n")) { + var r struct { + File string `json:"file"` + Line int `json:"line"` + Message string `json:"message"` + Code string `json:"code"` + Severity string `json:"severity"` + } + if json.Unmarshal(line, &r) != nil || r.File == "" { + continue + } + severity := MustFix + if r.Severity != "error" { + severity = Note + } + diagnostics = append(diagnostics, Diagnostic{ + File: filepath.ToSlash(relative(tree, r.File)), Line: r.Line, + Code: r.Code, Message: r.Message, Severity: severity, + }) + } + return diagnostics +} + +// cargoCheck type-checks each crate a changed Rust file belongs to — with +// clippy where it is installed, which checks and lints in one run — and +// reads the compiler's JSON messages. The target directory is the +// repository's own, so a range's materialised tree reuses the build cache. +var cargoCheck = Analyser{ + Name: "cargo", + Covers: func(path string) bool { return strings.HasSuffix(path, ".rs") }, + Ready: onPath("cargo"), + Run: func(ctx context.Context, tree, root string, files []string) ([]Diagnostic, error) { + crates := map[string]bool{} + for _, f := range files { + if c := nearest(tree, filepath.Dir(f), "Cargo.toml"); c != "" { + crates[c] = true + } + } + if len(crates) == 0 { + return nil, fmt.Errorf("no Cargo.toml above the changed files") + } + verb := "check" + if exec.CommandContext(ctx, "cargo", "clippy", "--version").Run() == nil { + verb = "clippy" + } + var diagnostics []Diagnostic + for _, crate := range slices.Sorted(func(yield func(string) bool) { + for c := range crates { + if !yield(c) { + return + } + } + }) { + dir := filepath.Join(tree, filepath.Dir(crate)) + target := filepath.Join(root, filepath.Dir(crate), "target") + out, err := execute(ctx, dir, []string{"CARGO_TARGET_DIR=" + target}, "cargo", verb, "--message-format", "json", "--quiet") + if err != nil { + return diagnostics, err + } + diagnostics = append(diagnostics, parseCargo(tree, dir, out)...) + } + return diagnostics, nil + }, +} + +// parseCargo reads the compiler messages out of cargo's JSON stream, one +// per primary span, with paths relative to the crate directory it ran in. +func parseCargo(tree, dir string, out []byte) []Diagnostic { + var diagnostics []Diagnostic + for _, line := range bytes.Split(out, []byte("\n")) { + var event struct { + Reason string `json:"reason"` + Message struct { + Level string `json:"level"` + Message string `json:"message"` + Code *struct { + Code string `json:"code"` + } `json:"code"` + Spans []struct { + File string `json:"file_name"` + Line int `json:"line_start"` + Primary bool `json:"is_primary"` + } `json:"spans"` + } `json:"message"` + } + if json.Unmarshal(line, &event) != nil || event.Reason != "compiler-message" { + continue + } + for _, span := range event.Message.Spans { + if !span.Primary { + continue + } + d := Diagnostic{ + File: filepath.ToSlash(relative(tree, filepath.Join(dir, filepath.FromSlash(span.File)))), + Line: span.Line, + Message: event.Message.Message, + } + if event.Message.Code != nil { + d.Code = event.Message.Code.Code + } + switch event.Message.Level { + case "error": + d.Severity, d.Fault = MustFix, true + case "warning": + d.Severity = Consider + default: + continue + } + diagnostics = append(diagnostics, d) + break + } + } + return diagnostics +} + +// semgrepLanguages are the extensions semgrep parses that review's other +// analysers do not already cover with a compiler of their own, and the ones +// they do: semgrep's rules are about patterns, not types, and say things a +// compiler does not. +var semgrepLanguages = []string{ + ".go", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".rs", ".java", ".kt", ".kts", + ".rb", ".php", ".c", ".h", ".cc", ".cpp", ".hpp", ".cs", ".swift", ".scala", ".lua", + ".ex", ".exs", ".dart", ".sh", ".bash", ".tf", ".yaml", ".yml", ".json", ".html", ".sol", +} + +// semgrepConfig is the rule set semgrep is pointed at: the repository's own +// configuration where it has one — that is the repository's word on what +// matters — and the registry's default pack otherwise, fetched over the +// network. Not `auto`: semgrep refuses to build that selection with metrics +// off, and review never sends metrics. +func semgrepConfig(tree string) string { + for _, name := range []string{".semgrep.yml", ".semgrep.yaml", ".semgrep"} { + if _, err := os.Stat(filepath.Join(tree, name)); err == nil { + return name + } + } + return "p/default" +} + +// semgrep runs the pattern analyser over the changed files it can parse, +// and reads its JSON. A rule's severity is the rule author's word on it, +// kept as reported: ERROR must-fix, WARNING consider, INFO note. +var semgrep = Analyser{ + Name: "semgrep", + Covers: func(path string) bool { + return hasSuffix(path, semgrepLanguages) && isCodeFile(path) || hasSuffix(path, []string{".yaml", ".yml", ".json", ".html"}) + }, + Ready: onPath("semgrep"), + Run: func(ctx context.Context, tree, root string, files []string) ([]Diagnostic, error) { + args := []string{"scan", "--json", "--quiet", "--config", semgrepConfig(tree), "--metrics", "off"} + out, err := execute(ctx, tree, nil, "semgrep", append(args, files...)...) + if err != nil { + return nil, err + } + return parseSemgrep(tree, out) + }, +} + +// parseSemgrep reads semgrep's JSON report: one result per match, located +// by its start line, with the rule's id and severity. +func parseSemgrep(tree string, out []byte) ([]Diagnostic, error) { + var report struct { + Results []struct { + CheckID string `json:"check_id"` + Path string `json:"path"` + Start struct { + Line int `json:"line"` + } `json:"start"` + Extra struct { + Message string `json:"message"` + Severity string `json:"severity"` + } `json:"extra"` + } `json:"results"` + } + if err := json.Unmarshal(out, &report); err != nil { + return nil, fmt.Errorf("semgrep: %w", err) + } + var diagnostics []Diagnostic + for _, r := range report.Results { + severity := Note + switch strings.ToUpper(r.Extra.Severity) { + case "ERROR", "HIGH", "CRITICAL": + severity = MustFix + case "WARNING", "MEDIUM": + severity = Consider + } + file := r.Path + if filepath.IsAbs(file) { + file = relative(tree, file) + } + diagnostics = append(diagnostics, Diagnostic{ + File: filepath.ToSlash(file), Line: r.Start.Line, Code: r.CheckID, + Message: strings.TrimSpace(r.Extra.Message), Severity: severity, + }) + } + return diagnostics, nil +} diff --git a/analysers_test.go b/analysers_test.go @@ -0,0 +1,282 @@ +package main + +// The analysers are the repository's own compilers, asked for JSON. These +// tests hold what review does with their answers: a fault is the change's +// wherever it lands, an opinion only on a line the change added, and each +// tool's output is read into the same shape. + +import ( + "os/exec" + "strings" + "testing" +) + +func needGo(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("go"); err != nil { + t.Skip("go is not installed") + } +} + +// goFixture is a module with one package: a printf fault on line 6 that vet +// sees, and nothing the compiler minds. +func goFixture(t *testing.T) *repo { + t.Helper() + r := newRepo(t) + r.write("go.mod", "module probe\n\ngo 1.27.0\n") + r.write("a.go", "package probe\n\nimport \"fmt\"\n\nfunc F() {\n\tfmt.Printf(\"%d\", \"s\")\n}\n") + return r +} + +func TestGoVetReadsTheChange(t *testing.T) { + needGo(t) + r := goFixture(t) + r.stage("go.mod", "a.go") + change, err := Gather("", r.Root) + if err != nil { + t.Fatal(err) + } + findings := checkAnalysers(r.Root, "", change, goBuild, goVet) + if len(findings) != 1 || findings[0].Rule != "go-vet/printf" || findings[0].File != "a.go" || findings[0].Line != 6 || findings[0].Severity != MustFix { + t.Fatalf("got %v", findings) + } +} + +// An opinion on a line the change did not add is the analyser's, not the +// change's. +func TestGoVetKeepsToTheChange(t *testing.T) { + needGo(t) + r := goFixture(t) + r.commit("first", "go.mod", "a.go") + r.write("a.go", "package probe\n\nimport \"fmt\"\n\nfunc F() {\n\tfmt.Printf(\"%d\", \"s\")\n}\n\nfunc G() {}\n") + r.stage("a.go") + change, err := Gather("", r.Root) + if err != nil { + t.Fatal(err) + } + if findings := checkAnalysers(r.Root, "", change, goVet); len(findings) != 0 { + t.Errorf("got %v, want the old fault left alone", findings) + } +} + +// A build error is the change's wherever it lands: the package the change +// touched no longer compiles. +func TestGoBuildReportsAFaultAnywhereInThePackage(t *testing.T) { + needGo(t) + r := newRepo(t) + r.write("go.mod", "module probe\n\ngo 1.27.0\n") + r.write("a.go", "package probe\n\nfunc F() int { return helper() }\n") + r.write("b.go", "package probe\n\nfunc helper() int { return 1 }\n") + r.commit("first", "go.mod", "a.go", "b.go") + // The change removes the helper; the error lands in the unchanged file. + r.write("b.go", "package probe\n") + r.stage("b.go") + change, err := Gather("", r.Root) + if err != nil { + t.Fatal(err) + } + findings := checkAnalysers(r.Root, "", change, goBuild, goVet) + if len(findings) != 1 || findings[0].Rule != "go-build" || findings[0].File != "a.go" || findings[0].Line != 3 || findings[0].Severity != MustFix { + t.Fatalf("got %v", findings) + } + if !strings.Contains(findings[0].Message, "undefined: helper") { + t.Errorf("message %q", findings[0].Message) + } +} + +// A range is analysed on the tree it arrived at, not the working tree. +func TestGoVetReadsARange(t *testing.T) { + needGo(t) + r := goFixture(t) + r.write("a.go", "package probe\n") + r.commit("first", "go.mod", "a.go") + r.write("a.go", "package probe\n\nimport \"fmt\"\n\nfunc F() {\n\tfmt.Printf(\"%d\", \"s\")\n}\n") + rev := r.commit("second", "a.go") + r.write("a.go", "package probe\n") // The working tree is clean; the range is not. + change, err := Gather(rev+"^.."+rev, r.Root) + if err != nil { + t.Fatal(err) + } + findings := checkAnalysers(r.Root, rev+"^.."+rev, change, goVet) + if len(findings) != 1 || findings[0].Rule != "go-vet/printf" { + t.Fatalf("got %v", findings) + } +} + +func TestOdinCheckReadsTheChange(t *testing.T) { + if _, err := exec.LookPath("odin"); err != nil { + t.Skip("odin is not installed") + } + r := newRepo(t) + r.write("lib/lib.odin", "package lib\n\ncount :: proc(xs: []int) -> int {\n\tunused := 3\n\treturn len(xs)\n}\n") + r.stage("lib/lib.odin") + change, err := Gather("", r.Root) + if err != nil { + t.Fatal(err) + } + findings := checkAnalysers(r.Root, "", change, odinCheck) + if len(findings) != 1 || findings[0].Rule != "odin-check/vet" || findings[0].File != "lib/lib.odin" || findings[0].Line != 4 || findings[0].Severity != Consider { + t.Fatalf("got %v", findings) + } +} + +func TestParseGoBuild(t *testing.T) { + out := []byte(`{"ImportPath":"probe","Action":"build-output","Output":"# probe\n"} +{"ImportPath":"probe","Action":"build-output","Output":"./a.go:3:15: undefined: helper\n"} +{"ImportPath":"probe","Action":"build-fail"} +`) + got := parseGoBuild("/tree", out) + if len(got) != 1 || got[0].File != "a.go" || got[0].Line != 3 || !got[0].Fault || got[0].Message != "undefined: helper" { + t.Errorf("got %+v", got) + } +} + +func TestParseGoVet(t *testing.T) { + out := []byte("# probe\n{\n\t\"probe\": {\n\t\t\"printf\": [{\"posn\": \"/tree/a.go:6:14\", \"message\": \"wrong type\"}],\n\t\t\"shadow\": [{\"posn\": \"/tree/b.go:9:2\", \"message\": \"declaration of err shadows\"}]\n\t}\n}\n# other\nvet: other/x.go:3: undefined: y\n") + got := parseGoVet("/tree", out) + if len(got) != 2 { + t.Fatalf("got %+v", got) + } + by := map[string]Diagnostic{} + for _, d := range got { + by[d.Code] = d + } + if by["printf"].File != "a.go" || by["printf"].Line != 6 || by["printf"].Severity != MustFix { + t.Errorf("printf: %+v", by["printf"]) + } + if by["shadow"].Severity != Consider { + t.Errorf("shadow: %+v", by["shadow"]) + } +} + +func TestVetSeverity(t *testing.T) { + for name, want := range map[string]Severity{"printf": MustFix, "nilness": MustFix, "shadow": Consider, "unusedwrite": Consider, "rangeint": Note, "stringscut": Note} { + if got := vetSeverity(name); got != want { + t.Errorf("%s: %s, want %s", name, got, want) + } + } +} + +func TestParseOdin(t *testing.T) { + out := []byte(`{"error_count": 3, "errors": [ + {"type": "error", "pos": {"file": "/tree/lib/a.odin", "line": 6}, "msgs": ["'unused' declared but not used"]}, + {"type": "warning", "pos": {"file": "/tree/lib/a.odin", "line": 4}, "msgs": ["Syntax Error: With '-strict-style' the attached brace style (1TBS) is enforced"]}, + {"type": "error", "pos": {"file": "/tree/lib/a.odin", "line": 9}, "msgs": ["Undeclared name: foo"]} + ]}`) + got := parseOdin("/tree", out) + if len(got) != 3 { + t.Fatalf("got %+v", got) + } + if got[0].Code != "vet" || got[0].Severity != Consider || got[0].Fault { + t.Errorf("vet: %+v", got[0]) + } + if got[1].Code != "style" || got[1].Severity != Note || got[1].Fault { + t.Errorf("style: %+v", got[1]) + } + if got[2].Code != "" || got[2].Severity != MustFix || !got[2].Fault || got[2].File != "lib/a.odin" { + t.Errorf("type error: %+v", got[2]) + } +} + +func TestParseTsc(t *testing.T) { + out := []byte("src/a.ts(12,5): error TS2322: Type 'string' is not assignable to type 'number'.\nnoise\n") + got := parseTsc("/tree", "/tree/web", out) + if len(got) != 1 || got[0].File != "web/src/a.ts" || got[0].Line != 12 || got[0].Code != "TS2322" || !got[0].Fault { + t.Errorf("got %+v", got) + } +} + +func TestParseRuff(t *testing.T) { + out := []byte(`[{"code":"F401","message":"os imported but unused","filename":"/tree/a.py","location":{"row":1,"column":8}}, + {"code":"E501","message":"Line too long","filename":"/tree/a.py","location":{"row":9,"column":89}}]`) + got, err := parseRuff("/tree", out) + if err != nil || len(got) != 2 { + t.Fatalf("got %+v, %v", got, err) + } + if got[0].Severity != Consider || got[0].File != "a.py" || got[1].Severity != Note { + t.Errorf("got %+v", got) + } + if _, err := parseRuff("/tree", []byte("not json")); err == nil { + t.Error("prose was read as a report") + } +} + +func TestParseMypy(t *testing.T) { + out := []byte(`{"file": "a.py", "line": 4, "column": 4, "message": "Incompatible return value type", "hint": null, "code": "return-value", "severity": "error"} +{"file": "a.py", "line": 4, "column": 4, "message": "See the docs", "hint": null, "code": "return-value", "severity": "note"} +`) + got := parseMypy("/tree", out) + if len(got) != 2 || got[0].Severity != MustFix || got[0].Code != "return-value" || got[1].Severity != Note { + t.Errorf("got %+v", got) + } +} + +func TestParseCargo(t *testing.T) { + out := []byte(`{"reason":"compiler-artifact","target":{}} +{"reason":"compiler-message","message":{"level":"error","message":"cannot find value x","code":{"code":"E0425"},"spans":[{"file_name":"src/main.rs","line_start":3,"is_primary":false},{"file_name":"src/main.rs","line_start":4,"is_primary":true}]}} +{"reason":"compiler-message","message":{"level":"warning","message":"unused variable","code":{"code":"unused_variables"},"spans":[{"file_name":"src/lib.rs","line_start":7,"is_primary":true}]}} +{"reason":"compiler-message","message":{"level":"note","message":"aborting","code":null,"spans":[]}} +`) + got := parseCargo("/tree", "/tree/crate", out) + if len(got) != 2 { + t.Fatalf("got %+v", got) + } + if got[0].File != "crate/src/main.rs" || got[0].Line != 4 || got[0].Code != "E0425" || !got[0].Fault { + t.Errorf("error: %+v", got[0]) + } + if got[1].Severity != Consider || got[1].Fault { + t.Errorf("warning: %+v", got[1]) + } +} + +func TestSplitJSONObjects(t *testing.T) { + chunks := splitJSONObjects([]byte("# a\n{\"x\": {\"y\": \"}\"}}\nprose {not\n{\"z\": 1}\n")) + if len(chunks) != 2 || string(chunks[0]) != `{"x": {"y": "}"}}` || string(chunks[1]) != `{"z": 1}` { + t.Errorf("got %q", chunks) + } +} + +func TestNearest(t *testing.T) { + r := newRepo(t) + r.write("web/tsconfig.json", "{}") + r.write("web/src/deep/a.ts", "") + if got := nearest(r.Root, "web/src/deep", "tsconfig.json"); got != "web/tsconfig.json" { + t.Errorf("got %q", got) + } + if got := nearest(r.Root, "web/src/deep", "Cargo.toml"); got != "" { + t.Errorf("got %q, want nothing", got) + } +} + +func TestParseSemgrep(t *testing.T) { + out := []byte(`{"results":[ + {"check_id":"go.lang.security.audit.crypto.math_random","path":"a.go","start":{"line":12},"extra":{"message":"math/rand is not secure\n","severity":"WARNING"}}, + {"check_id":"python.lang.best-practice.open-never-closed","path":"/tree/b.py","start":{"line":3},"extra":{"message":"file never closed","severity":"ERROR"}}, + {"check_id":"generic.note","path":"c.js","start":{"line":1},"extra":{"message":"fyi","severity":"INFO"}} + ],"errors":[]}`) + got, err := parseSemgrep("/tree", out) + if err != nil || len(got) != 3 { + t.Fatalf("got %+v, %v", got, err) + } + if got[0].Severity != Consider || got[0].Code != "go.lang.security.audit.crypto.math_random" || got[0].Message != "math/rand is not secure" { + t.Errorf("warning: %+v", got[0]) + } + if got[1].Severity != MustFix || got[1].File != "b.py" { + t.Errorf("error: %+v", got[1]) + } + if got[2].Severity != Note || got[2].Fault { + t.Errorf("info: %+v", got[2]) + } +} + +func TestSemgrepConfig(t *testing.T) { + dir := t.TempDir() + if got := semgrepConfig(dir); got != "p/default" { + t.Errorf("got %q", got) + } + r := newRepo(t) + r.write(".semgrep.yml", "rules: []\n") + if got := semgrepConfig(r.Root); got != ".semgrep.yml" { + t.Errorf("got %q", got) + } +} diff --git a/assertions_test.go b/assertions_test.go @@ -0,0 +1,98 @@ +package main + +import "testing" + +func TestAssertless(t *testing.T) { + for _, test := range []struct { + fn Function + fires bool + }{ + {Function{Name: "TestX", File: "x_test.go", Body: "func TestX(t *testing.T) {\n\tParse(\"a\")\n}"}, true}, + {Function{Name: "TestX", File: "x_test.go", Body: "func TestX(t *testing.T) {\n\tif Parse(\"a\") == nil {\n\t\tt.Fatal(\"nil\")\n\t}\n}"}, false}, + {Function{Name: "TestX", File: "x_test.go", Body: "func TestX(tc *testing.T) {\n\ttc.Errorf(\"x\")\n}"}, false}, + {Function{Name: "TestX", File: "x_test.go", Body: "func TestX(t *testing.T) {\n\tcheck(t, Parse(\"a\"))\n}"}, false}, + {Function{Name: "TestX", File: "x_test.go", Body: "func TestX(t *testing.T) {\n\tt.Run(\"sub\", func(t *testing.T) {})\n}"}, false}, + {Function{Name: "TestX", File: "x_test.go", Body: "func TestX(t *testing.T) {\n\tt.Skip(\"never\")\n}"}, false}, + {Function{Name: "BenchmarkX", File: "x_test.go", Body: "func BenchmarkX(b *testing.B) {\n\tParse(\"a\")\n}"}, false}, + {Function{Name: "parses", File: "x.test.ts", Body: "test('parses', () => {\n parse('a');\n});"}, true}, + {Function{Name: "parses", File: "x.test.ts", Body: "test('parses', () => {\n expect(parse('a')).toBe(1);\n});"}, false}, + {Function{Name: "test_parse", File: "x.odin", Body: "test_parse :: proc(t: ^testing.T) {\n\tparse(\"a\")\n}"}, true}, + {Function{Name: "test_parse", File: "x.odin", Body: "test_parse :: proc(t: ^testing.T) {\n\ttesting.expect(t, parse(\"a\") == 1)\n}"}, false}, + {Function{Name: "test_parse", File: "x.py", Body: "def test_parse():\n parse('a')\n"}, true}, + {Function{Name: "test_parse", File: "x.py", Body: "def test_parse():\n assert parse('a') == 1\n"}, false}, + {Function{Name: "smoke", File: "x.rs", Body: "fn smoke() { parse(\"a\"); }"}, true}, + {Function{Name: "smoke", File: "x.rs", Body: "fn smoke() { assert_eq!(parse(\"a\"), 1); }"}, false}, + {Function{Name: "test_parse", File: "x.lua", Body: "function test_parse() parse('a') end"}, false}, + } { + if got := assertless(test.fn); got != test.fires { + t.Errorf("%s in %s: assertless %v, want %v\n%s", test.fn.Name, test.fn.File, got, test.fires, test.fn.Body) + } + } +} + +func TestCheckTestAssertions(t *testing.T) { + c := &Change{Tests: []Function{ + {Name: "TestA", File: "x_test.go", Line: 3, Body: "func TestA(t *testing.T) {\n\tParse(\"a\")\n}"}, + {Name: "TestB", File: "x_test.go", Line: 9, Body: "func TestB(t *testing.T) {\n\tt.Fatal()\n}"}, + }} + got := checkTestAssertions(c) + if len(got) != 1 || got[0].Rule != "test-no-assertion" || got[0].Symbol != "TestA" || got[0].Severity != Consider { + t.Errorf("got %v", got) + } +} + +func TestSkipLine(t *testing.T) { + fn := Function{Line: 10, File: "x_test.go", Body: "func TestX(t *testing.T) {\n\tif !have {\n\t\tt.Skip(\"no tool\")\n\t}\n}"} + if got := skipLine(fn); got != 12 { + t.Errorf("got %d, want 12", got) + } + if got := skipLine(Function{Line: 1, Body: "func TestX(t *testing.T) {}"}); got != 0 { + t.Errorf("got %d, want 0", got) + } +} + +func TestTautological(t *testing.T) { + for _, line := range []string{ + `assert.True(t, true)`, + `require.NoError(t, nil)`, + `if got != got {`, + `assert.Equal(t, want, want)`, + `assert True`, + `assert 1 == 1`, + `self.assertTrue(True)`, + `expect(true).toBe(true)`, + `expect(1).toEqual(1)`, + `expect(x.name).toBe(x.name)`, + `assert!(true);`, + `assert_eq!(2, 2);`, + `assert_eq!(a.len(), a.len());`, + `testing.expect(t, true)`, + } { + if tautological(line) == "" { + t.Errorf("%q was not read as tautological", line) + } + } + for _, line := range []string{ + `assert.True(t, ok)`, + `require.NoError(t, err)`, + `if got != want {`, + `assert got == 1`, + `expect(got).toBe(true)`, + `expect(1).toEqual(got)`, + `assert_eq!(a.len(), 2);`, + `// assert.True(t, true) is what not to write`, + `testing.expect(t, ok)`, + } { + if why := tautological(line); why != "" { + t.Errorf("%q was read as %s", line, why) + } + } +} + +func TestCheckTautologies(t *testing.T) { + c := &Change{Tests: []Function{{Name: "TestX", File: "x_test.go", Line: 10, Body: "func TestX(t *testing.T) {\n\tgot := f()\n\tif got != got {\n\t\tt.Fatal()\n\t}\n}"}}} + got := checkTautologies(c) + if len(got) != 1 || got[0].Rule != "assertion-always-true" || got[0].Line != 12 || got[0].Severity != MustFix { + t.Errorf("got %v", got) + } +} diff --git a/bench.go b/bench.go @@ -0,0 +1,106 @@ +package main + +// The deterministic checks cost nothing to run, so their precision can be +// measured over as much history as there is: run them over the last N +// commits and count what fires. A rule that fires on a tenth of ordinary +// commits is not measuring what it claims to. + +import ( + "flag" + "fmt" + "os" + "slices" + "strings" +) + +// bench runs the deterministic checks over a repository's recent commits +// and prints the fire rate per rule. -author narrows the commits to an +// author's, which is how the checks are held against the population they +// exist for. +func bench(args []string) error { + flags := flag.NewFlagSet("bench", flag.ContinueOnError) + n := flags.Int("n", 200, "How many recent commits to measure.") + author := flags.String("author", "", "Only commits whose author matches this pattern, as git log --author takes it.") + rule := flags.String("rule", "", "List the commits one rule fired on, with their subjects.") + if err := flags.Parse(args); err != nil { + return err + } + root := flags.Arg(0) + if root == "" { + var err error + if root, err = repository(); err != nil { + return err + } + } + listing := []string{"rev-list", "--no-merges", fmt.Sprintf("-%d", *n)} + if *author != "" { + listing = append(listing, "--author="+*author) + } + listing = append(listing, "HEAD") + out, err := git(root, listing...) + if err != nil { + return err + } + var commits []string + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + if line != "" { + commits = append(commits, line) + } + } + if len(commits) == 0 { + return fmt.Errorf("no commits to measure") + } + + type fire struct { + commit, subject, message string + } + fires := map[string][]fire{} + measured := 0 + for i, commit := range commits { + fmt.Fprintf(os.Stderr, "\r %d of %d", i+1, len(commits)) + change, err := Gather(commit+"^.."+commit, root) + if err != nil { + continue // A root commit has no parent to diff against. + } + measured++ + findings := runChecks(change) + seen := map[string]bool{} + for _, f := range findings { + if seen[f.Rule] { + continue + } + seen[f.Rule] = true + subject := strings.SplitN(strings.TrimSpace(change.Message), "\n", 2)[0] + fires[f.Rule] = append(fires[f.Rule], fire{commit[:8], subject, f.Message}) + } + closeTrees() // One tree per commit; a bench must not keep them all. + } + fmt.Fprint(os.Stderr, "\r \r") + + if *rule != "" { + for _, f := range fires[*rule] { + fmt.Printf("%s %s\n %s\n", f.commit, f.subject, first(f.message, 160)) + } + fmt.Printf("\n%s fired on %d of %d commits\n", *rule, len(fires[*rule]), measured) + return nil + } + rules := make([]string, 0, len(fires)) + for r := range fires { + rules = append(rules, r) + } + slices.SortFunc(rules, func(a, b string) int { + if c := len(fires[b]) - len(fires[a]); c != 0 { + return c + } + return strings.Compare(a, b) + }) + fmt.Printf("%d commits measured\n\n", measured) + fmt.Printf("%-28s %6s %s\n", "rule", "fires", "rate") + for _, r := range rules { + fmt.Printf("%-28s %6d %4.1f%%\n", r, len(fires[r]), 100*float64(len(fires[r]))/float64(measured)) + } + if len(rules) == 0 { + fmt.Println("nothing fired") + } + return nil +} diff --git a/bench_test.go b/bench_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "strings" + "testing" +) + +// The bench runs the deterministic checks over recent commits and counts +// what fired, per rule, so a rule's precision is a number. +func TestBench(t *testing.T) { + r := newRepo(t) + r.write("x.go", "package x\n") + r.commit("first", "x.go") + r.write("x.go", "package x\n\nvar cfg = 1\n") + r.commit("x: add cfg", "x.go") + r.write("x.go", "package x\n\nvar cfg = 1\n\nvar y = 2\n") + r.commit("oops", "x.go") + t.Chdir(r.Root) + + out := capture(t, func() { + if err := bench([]string{"-n", "10"}); err != nil { + t.Fatal(err) + } + }) + if !strings.Contains(out, "2 commits measured") { + t.Errorf("the root commit was not left out:\n%s", out) + } + for _, want := range []string{"abbreviation", "message-frustration"} { + if !strings.Contains(out, want) { + t.Errorf("%q did not fire:\n%s", want, out) + } + } + one := capture(t, func() { + if err := bench([]string{"-n", "10", "-rule", "message-frustration"}); err != nil { + t.Fatal(err) + } + }) + if !strings.Contains(one, "oops") || !strings.Contains(one, "fired on 1 of 2 commits") { + t.Errorf("got %s", one) + } + none := capture(t, func() { + if err := bench([]string{"-n", "10", "-author", "nobody@example.com"}); err == nil { + t.Error("no commits to measure should be an error") + } + }) + _ = none +} diff --git a/cache_test.go b/cache_test.go @@ -149,3 +149,27 @@ func itoa(n int) string { } return string(digits) } + +// An answer nothing can be read from is not recorded. A model that spent +// its budget and said nothing would otherwise be replayed, and the job +// would fail the same way on every run without asking again. +func TestReviewerDoesNotRecordAnUnreadableAnswer(t *testing.T) { + provider := &relenting{answers: []string{"", "", findingsJSON("cannot-fail")}} + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + cache := openCache(false) + jobs, err := chosen("tests") + if err != nil { + t.Fatal(err) + } + first := Reviewer{Provider: provider, Cache: cache}.Run(context.Background(), verifyChange(), jobs) + if len(first.Failures) != 1 { + t.Fatalf("an empty answer, twice, is a failed job: %+v", first) + } + if len(cache.entries) != 0 { + t.Fatalf("recorded %d unreadable answers", len(cache.entries)) + } + second := Reviewer{Provider: provider, Cache: cache}.Run(context.Background(), verifyChange(), jobs) + if len(second.Findings) != 1 || second.Replayed != 0 { + t.Fatalf("the second run did not ask again: %+v", second) + } +} diff --git a/client.go b/client.go @@ -145,6 +145,13 @@ func (r Reviewer) Run(ctx context.Context, change *Change, jobs []Job) RunResult } result.Findings = append(result.Findings, findings...) } + // Every job's parts are cut once, and the same cut serves the second + // reading, so a verdict is asked against exactly what the finding was + // read from. + cuts := map[string][]*Change{} + for _, job := range jobs { + cuts[job.Name] = parts(job, change) + } read := func(job Job) { subject := job.Subject(change) if strings.TrimSpace(subject) == "" { @@ -156,10 +163,19 @@ func (r Reviewer) Run(ctx context.Context, change *Change, jobs []Job) RunResult } return } - askCtx, cancel := askContext(ctx) - findings, answer, err := r.ask(askCtx, job, subject) - cancel() - record(job, findings, answer, err) + pieces := cuts[job.Name] + if r.Verbose && len(pieces) > 1 { + fmt.Printf(" %-12s asked in %d parts\n", job.Name, len(pieces)) + } + for i, piece := range pieces { + askCtx, cancel := askContext(ctx) + findings, answer, err := r.ask(askCtx, job, job.Subject(piece)) + cancel() + for j := range findings { + findings[j].part = i + } + record(job, findings, answer, err) + } } if os.Getenv("REVIEW_SERIAL") != "" { for _, job := range jobs { @@ -177,14 +193,16 @@ func (r Reviewer) Run(ctx context.Context, change *Change, jobs []Job) RunResult wg.Wait() } if r.Verify { - r.verify(ctx, change, jobs, &result) + r.verify(ctx, cuts, jobs, &result) } return result } -// verifyGroup is one job's findings, for the pass that checks them. +// verifyGroup is one job's findings from one part of its subject, for the +// pass that checks them. type verifyGroup struct { job Job + part int indexes []int } @@ -195,17 +213,22 @@ type verifyGroup struct { // question is whether each finding holds. A verdict that cannot be asked // fails open: the findings stand, marked unverified, and the failure joins // the others. -func (r Reviewer) verify(ctx context.Context, change *Change, jobs []Job, result *RunResult) { +func (r Reviewer) verify(ctx context.Context, cuts map[string][]*Change, jobs []Job, result *RunResult) { var groups []verifyGroup for _, job := range jobs { - var indexes []int - for i, f := range result.Findings { - if f.Job == job.Name { - indexes = append(indexes, i) + for part := range cuts[job.Name] { + var indexes []int + for i, f := range result.Findings { + // A note is never gated on, so a second reading of it + // buys nothing; it stands unverified, and the report + // says so. + if f.Job == job.Name && f.part == part && f.Severity != Note { + indexes = append(indexes, i) + } + } + if len(indexes) > 0 { + groups = append(groups, verifyGroup{job: job, part: part, indexes: indexes}) } - } - if len(indexes) > 0 { - groups = append(groups, verifyGroup{job: job, indexes: indexes}) } } if len(groups) == 0 { @@ -217,7 +240,7 @@ func (r Reviewer) verify(ctx context.Context, change *Change, jobs []Job, result drop = map[int]bool{} ) apply := func(group verifyGroup) { - subject := group.job.Subject(change) + subject := group.job.Subject(cuts[group.job.Name][group.part]) askCtx, cancel := askContext(ctx) held, answer, err := r.verdicts(askCtx, group.job, subject, result.Findings, group.indexes) cancel() @@ -317,7 +340,7 @@ func askContext(ctx context.Context) (context.Context, context.CancelFunc) { // the first's remaining time. func (r Reviewer) ask(ctx context.Context, job Job, subject string) ([]Finding, Answer, error) { system := instruction + "\n\n" + job.Criteria - answer, err := r.answer(ctx, system, subject, r.Provider.Ask) + answer, err := r.answer(ctx, system, subject, r.Provider.Ask, readableFindings) if err != nil { return nil, Answer{}, err } @@ -325,7 +348,7 @@ func (r Reviewer) ask(ctx context.Context, job Job, subject string) ([]Finding, raw, err := object(answer.Text) if err != nil { prose := first(answer.Text, 200) - answer, err = r.answer(ctx, system, subject+"\n\n"+again, r.Provider.Ask) + answer, err = r.answer(ctx, system, subject+"\n\n"+again, r.Provider.Ask, readableFindings) if err != nil { return nil, Answer{}, err } @@ -343,8 +366,11 @@ func (r Reviewer) ask(ctx context.Context, job Job, subject string) ([]Finding, // answer asks through the cache: the same question asked of the same // provider is replayed rather than asked, which is what makes a re-run -// cheap where nothing it judges changed. -func (r Reviewer) answer(ctx context.Context, system, user string, ask func(context.Context, string, string) (Answer, error)) (Answer, error) { +// cheap where nothing it judges changed. Only an answer the caller can read +// is recorded: a model that spent its whole budget and said nothing would +// otherwise be replayed, and the job would fail the same way on every run +// without ever asking again. +func (r Reviewer) answer(ctx context.Context, system, user string, ask func(context.Context, string, string) (Answer, error), readable func(string) bool) (Answer, error) { if r.Cache != nil { if answer, ok := r.Cache.get(r.Provider.Name(), system, user); ok { if r.Verbose { @@ -354,12 +380,32 @@ func (r Reviewer) answer(ctx context.Context, system, user string, ask func(cont } } answer, err := ask(ctx, system, user) - if err == nil && r.Cache != nil { + if err == nil && r.Cache != nil && readable(answer.Text) { r.Cache.put(r.Provider.Name(), system, user, answer) } return answer, err } +// readableFindings is whether an answer holds a findings object. +func readableFindings(text string) bool { + raw, err := object(text) + if err != nil { + return false + } + _, err = decode(raw) + return err == nil +} + +// readableVerdicts is whether an answer holds a verdicts object. +func readableVerdicts(text string) bool { + raw, err := object(text) + if err != nil { + return false + } + _, err = decodeVerdicts(raw) + return err == nil +} + func (r Reviewer) spent(job Job, answer Answer) { if !r.Verbose { return @@ -394,7 +440,7 @@ func (r Reviewer) verdicts(ctx context.Context, job Job, subject string, finding position, f.Rule, f.Severity, where, f.Message, f.Fix) } user := subject + "\n\nThe findings reported against it:\n\n" + listed.String() - answer, err := r.answer(ctx, system, user, verdictAsk(r.Provider)) + answer, err := r.answer(ctx, system, user, verdictAsk(r.Provider), readableVerdicts) if err != nil { return nil, answer, err } @@ -402,7 +448,7 @@ func (r Reviewer) verdicts(ctx context.Context, job Job, subject string, finding raised, err := object(answer.Text) if err != nil { prose := first(answer.Text, 200) - answer, err = r.answer(ctx, system, user+"\n\n"+verdictsAgain, verdictAsk(r.Provider)) + answer, err = r.answer(ctx, system, user+"\n\n"+verdictsAgain, verdictAsk(r.Provider), readableVerdicts) if err != nil { return nil, answer, err } @@ -411,15 +457,9 @@ func (r Reviewer) verdicts(ctx context.Context, job Job, subject string, finding return nil, answer, fmt.Errorf("%w, twice: %s", err, prose) } } - var read struct { - Verdicts []struct { - Index int `json:"index"` - Holds bool `json:"holds"` - Reason string `json:"reason"` - } `json:"verdicts"` - } - if err := json.Unmarshal([]byte(raised), &read); err != nil { - return nil, answer, fmt.Errorf("the verdicts are not the shape asked for: %w", err) + read, err := decodeVerdicts(raised) + if err != nil { + return nil, answer, err } held := map[int]verdict{} for _, v := range read.Verdicts { @@ -430,6 +470,23 @@ func (r Reviewer) verdicts(ctx context.Context, job Job, subject string, finding return held, answer, nil } +// spokenVerdicts is the shape the second reading answers in. +type spokenVerdicts struct { + Verdicts []struct { + Index int `json:"index"` + Holds bool `json:"holds"` + Reason string `json:"reason"` + } `json:"verdicts"` +} + +func decodeVerdicts(raw string) (spokenVerdicts, error) { + var read spokenVerdicts + if err := json.Unmarshal([]byte(raw), &read); err != nil { + return spokenVerdicts{}, fmt.Errorf("the verdicts are not the shape asked for: %w", err) + } + return read, nil +} + // verdictAsk adapts whichever way a provider can be asked for verdicts — // enforced as a tool where it can be, described in the prompt where it // cannot — to one ask. diff --git a/clones.go b/clones.go @@ -0,0 +1,212 @@ +package main + +// Two functions with the same body are one fact stated twice, and telling +// that two bodies are the same needs no judgement: the tokens either match +// or they do not. Whether two different bodies mean the same thing is the +// duplication job's; this is the part it should never have to be asked. + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" +) + +// The floors under which two bodies are not compared. Two one-line +// accessors are alike because accessors are alike, and saying so helps +// nobody; a copy of forty tokens is still a copy, but two small wrappers +// with the same shape — unmarshal, wrap the error, return — are the shape +// of wrappers, so a match in shape alone needs a body twice as long. +const ( + minCloneTokens = 40 + minShapeTokens = 80 +) + +// lexeme matches one lexical token of the C-family languages the tool +// reads: an identifier, a number, a string in any of three quotings, a +// two-character operator, or one character of punctuation. +var lexeme = regexp.MustCompile("[A-Za-z_][A-Za-z0-9_]*|0[xX][0-9A-Fa-f]+|[0-9]+(?:\\.[0-9]+)?|\"(?:\\\\.|[^\"\\\\\n])*\"|'(?:\\\\.|[^'\\\\\n])*'|`[^`]*`|:=|::|==|!=|<=|>=|&&|\\|\\||\\+\\+|--|\\+=|-=|\\*=|/=|->|=>|<<|>>|\\S") + +// comments matches what a tokeniser drops: line comments and block +// comments, in the shapes the tool's languages share. +var comments = regexp.MustCompile(`//[^\n]*|/\*[\s\S]*?\*/|(?m)^\s*#[^\n]*`) + +// keywords are the words a language reserves, per grammar, which a +// structural comparison keeps while it replaces every other identifier. +var keywords = map[string]map[string]bool{ + "go": set(`break case chan const continue default defer else fallthrough for func go goto if import + interface map package range return select struct switch type var nil true false iota + len cap append make new panic recover error string int int8 int16 int32 int64 uint uint8 uint16 + uint32 uint64 byte rune float32 float64 bool any`), + "js": set(`break case catch class const continue debugger default delete do else enum export extends + false finally for function if import in instanceof new null return super switch this throw true + try typeof var void while with yield let static async await of undefined interface type + implements private public protected readonly declare namespace abstract as is keyof never + unknown string number boolean object symbol bigint`), + "py": set(`False None True and as assert async await break class continue def del elif else except + finally for from global if import in is lambda nonlocal not or pass raise return try while with + yield self cls print len range str int float list dict set tuple bool`), + "rs": set(`as async await break const continue crate dyn else enum extern false fn for if impl in let + loop match mod move mut pub ref return self Self static struct super trait true type unsafe use + where while Some None Ok Err Vec String Option Result Box i8 i16 i32 i64 u8 u16 u32 u64 usize + isize f32 f64 bool char str`), + "odin": set(`package import foreign proc struct union enum bit_set map dynamic using if else when for + switch case in not_in defer return break continue fallthrough cast transmute auto_cast distinct + matrix or_else or_return or_break or_continue where do context true false nil int uint bool + string rune byte f32 f64 i8 i16 i32 i64 u8 u16 u32 u64 uintptr rawptr any typeid`), +} + +func set(words string) map[string]bool { + out := map[string]bool{} + for _, w := range strings.Fields(words) { + out[w] = true + } + return out +} + +// grammarFor is which keyword set a file's tokens are read with. +func grammarFor(path string) string { + switch { + case strings.HasSuffix(path, ".go"): + return "go" + case strings.HasSuffix(path, ".odin"): + return "odin" + case strings.HasSuffix(path, ".py"): + return "py" + case strings.HasSuffix(path, ".rs"): + return "rs" + case grammarOf(path) != "": + return "js" + } + return filepath.Ext(path) +} + +// shapes are a body's two normalisations: exact, with only the function's +// own name replaced, and structural, with every identifier and literal +// replaced. Empty when the body is too small to compare. +type shapes struct { + exact, structural string + tokens int +} + +// normalise reads a body into its shapes. Whitespace and comments are +// gone in both; the exact shape keeps every name but the function's own, +// the structural shape keeps only the grammar's keywords and punctuation. +func normalise(body, name, grammar string) shapes { + stripped := comments.ReplaceAllString(body, " ") + toks := lexeme.FindAllString(stripped, -1) + if len(toks) < minCloneTokens { + return shapes{} + } + reserved := keywords[grammar] + exact := make([]string, len(toks)) + structural := make([]string, len(toks)) + for i, t := range toks { + exact[i] = t + if t == name { + exact[i] = "NAME" + } + switch { + case reserved[t]: + structural[i] = t + case t[0] == '_' || (t[0] >= 'a' && t[0] <= 'z') || (t[0] >= 'A' && t[0] <= 'Z'): + structural[i] = "ID" + case t[0] == '"' || t[0] == '\'' || t[0] == '`' || (t[0] >= '0' && t[0] <= '9'): + structural[i] = "LIT" + default: + structural[i] = t + } + } + return shapes{exact: strings.Join(exact, " "), structural: strings.Join(structural, " "), tokens: len(toks)} +} + +// owned is one function with its shapes, and where it is declared. +type owned struct { + name, file string + line int + shape shapes +} + +// checkClones reports a new function whose body already exists: in the +// repository's index, or in another function the same change adds. An +// exact match is one function written twice and is must-fix; a match in +// shape alone, every name changed, is the same procedure over other +// names, and is worth considering. +func checkClones(c *Change) []Finding { + var fresh []owned + for _, s := range c.Symbols { + if s.Kind != "func" || s.Body == "" || isTestFile(s.File) { + continue + } + shape := normalise(s.Body, s.Name, grammarFor(s.File)) + if shape.tokens == 0 { + continue + } + fresh = append(fresh, owned{s.Name, s.File, s.Line, shape}) + } + if len(fresh) == 0 { + return nil + } + var existing []owned + for _, d := range c.index { + if d.Kind != "func" || d.Body == "" { + continue + } + shape := normalise(d.Body, d.Name, grammarFor(d.File)) + if shape.tokens == 0 { + continue + } + existing = append(existing, owned{d.Name, d.File, d.Line, shape}) + } + var out []Finding + reported := map[string]bool{} + report := func(a, b owned, exact bool) { + key := fmt.Sprintf("%s:%d|%s:%d", a.file, a.line, b.file, b.line) + if reported[key] { + return + } + reported[key] = true + how, severity := "the same body, token for token,", MustFix + if !exact { + how, severity = "the same shape of body, every name changed,", Consider + } + out = append(out, Finding{ + Job: "static", Rule: "duplicate-body", Severity: severity, + File: a.file, Line: a.line, Symbol: a.name, + Message: fmt.Sprintf("%s has %s as %s at %s:%d; one procedure written twice drifts into two", a.name, how, b.name, b.file, b.line), + Fix: fmt.Sprintf("call %s, or lift what they share into one function both call", b.name), + }) + } + for _, a := range fresh { + for _, b := range existing { + if a.file == b.file && a.line == b.line { + continue + } + alike(a, b, report) + } + } + // Two new functions alike are reported once, the later against the + // earlier, where the index did not already hold the earlier. + for i, a := range fresh { + for _, b := range fresh[:i] { + key := fmt.Sprintf("%s:%d|%s:%d", a.file, a.line, b.file, b.line) + if reported[key] { + continue + } + alike(a, b, report) + } + } + return out +} + +// alike reports a and b to the caller when their bodies match: exactly at +// any size compared, or in shape when both are long enough for a shape to +// mean something. +func alike(a, b owned, report func(a, b owned, exact bool)) { + switch { + case a.shape.exact == b.shape.exact: + report(a, b, true) + case a.shape.tokens >= minShapeTokens && b.shape.tokens >= minShapeTokens && a.shape.structural == b.shape.structural: + report(a, b, false) + } +} diff --git a/clones_test.go b/clones_test.go @@ -0,0 +1,129 @@ +package main + +import ( + "fmt" + "strings" + "testing" +) + +// body is a Go function long enough to compare, built from a name and the +// names of the two values it works on. +func body(name, a, b string) string { + return fmt.Sprintf(`func %s(%s []int, %s int) int { + total := 0 + for _, v := range %s { + if v > %s { + total += v + } else if v == %s { + total -= v + } else { + total++ + } + } + if total < 0 { + return -total + } + if total > 1000 { + return 1000 + } + for i := 0; i < len(%s); i++ { + total += i * 2 + } + return total +}`, name, a, b, a, b, b, a) +} + +// small is a wrapper of the shape every wrapper has, too short for a match +// in shape to mean anything, though long enough for an exact copy to. +func small(name, kind string) string { + return fmt.Sprintf(`func %s(raw string) (%s, error) { + var r %s + if err := json.Unmarshal([]byte(raw), &r); err != nil { + return %s{}, fmt.Errorf("reading the answer: %%w", err) + } + return r, nil +}`, name, kind, kind, kind) +} + +func TestCheckClonesFindsAnExactCopy(t *testing.T) { + c := &Change{ + Symbols: []Symbol{{Name: "sumAbove", Kind: "func", File: "b.go", Line: 10, Body: body("sumAbove", "xs", "floor")}}, + index: []Declared{{Name: "sumAll", Kind: "func", File: "a.go", Line: 3, Body: body("sumAll", "xs", "floor")}}, + } + got := checkClones(c) + if len(got) != 1 || got[0].Rule != "duplicate-body" || got[0].Severity != MustFix { + t.Fatalf("got %v", got) + } + if !strings.Contains(got[0].Message, "a.go:3") || got[0].Symbol != "sumAbove" { + t.Errorf("got %v", got[0]) + } +} + +func TestCheckClonesFindsTheSameShape(t *testing.T) { + c := &Change{ + Symbols: []Symbol{{Name: "sumAbove", Kind: "func", File: "b.go", Line: 10, Body: body("sumAbove", "rows", "limit")}}, + index: []Declared{{Name: "sumAll", Kind: "func", File: "a.go", Line: 3, Body: body("sumAll", "xs", "floor")}}, + } + got := checkClones(c) + if len(got) != 1 || got[0].Severity != Consider { + t.Fatalf("got %v", got) + } +} + +func TestCheckClonesReadsTwoNewFunctionsOnce(t *testing.T) { + c := &Change{Symbols: []Symbol{ + {Name: "one", Kind: "func", File: "a.go", Line: 3, Body: body("one", "xs", "floor")}, + {Name: "two", Kind: "func", File: "a.go", Line: 30, Body: body("two", "xs", "floor")}, + }} + got := checkClones(c) + if len(got) != 1 || got[0].Symbol != "two" { + t.Fatalf("got %v", got) + } +} + +func TestCheckClonesIgnoresWhatIsTooSmallOrDifferent(t *testing.T) { + c := &Change{ + Symbols: []Symbol{ + {Name: "Size", Kind: "func", File: "b.go", Line: 10, Body: "func (e Entry) Size() int { return e.size }"}, + {Name: "other", Kind: "func", File: "b.go", Line: 20, Body: body("other", "xs", "floor") + "\n// and more\nvar _ = 1"}, + }, + index: []Declared{ + {Name: "Len", Kind: "func", File: "a.go", Line: 3, Body: "func (e Entry) Len() int { return e.size }"}, + {Name: "sumAll", Kind: "func", File: "a.go", Line: 3, Body: body("sumAll", "xs", "floor")}, + }, + } + if got := checkClones(c); len(got) != 0 { + t.Errorf("got %v", got) + } +} + +// A small wrapper matches another only exactly: two wrappers share a shape +// because wrappers do. +func TestCheckClonesAsksMoreOfAShapeThanOfACopy(t *testing.T) { + c := &Change{ + Symbols: []Symbol{{Name: "decodeB", Kind: "func", File: "b.go", Line: 10, Body: small("decodeB", "verdicts")}}, + index: []Declared{{Name: "decodeA", Kind: "func", File: "a.go", Line: 3, Body: small("decodeA", "reported")}}, + } + if got := checkClones(c); len(got) != 0 { + t.Errorf("a small wrapper matched in shape: %v", got) + } + c.Symbols[0].Body = small("decodeB", "reported") + if got := checkClones(c); len(got) != 1 || got[0].Severity != MustFix { + t.Errorf("a small copy was not reported: %v", got) + } +} + +// Comments and whitespace are not part of a body's shape. +func TestNormaliseReadsThroughCommentsAndSpace(t *testing.T) { + a := normalise(body("f", "xs", "n"), "f", "go") + b := normalise(strings.ReplaceAll(body("f", "xs", "n"), "total := 0", "total := 0 // start\n\n"), "f", "go") + if a.exact != b.exact { + t.Errorf("a comment changed the shape:\n%s\n%s", a.exact, b.exact) + } + if !strings.Contains(a.exact, "NAME") || strings.Contains(a.exact, " f ") { + t.Errorf("the function's own name was kept: %s", a.exact) + } + if !strings.Contains(a.structural, "for ID , ID := range ID") { + t.Errorf("keywords were not kept: %s", a.structural) + } +} diff --git a/coverage.go b/coverage.go @@ -0,0 +1,203 @@ +package main + +// Two things about a change are visible only against the whole repository: +// whether anything refers to what it adds, and whether it added tests where +// the repository keeps them. Both are counted over the tree in memory, +// which is the same on every system the tool runs on. + +import ( + "fmt" + "maps" + "slices" + "strings" +) + +// checkUnreferenced reports a new declaration nothing in the repository +// refers to: not the change, not the rest of the tree. It is written and +// waiting, and what waits drifts. The name is searched as a whole word +// over every text file, so a use from a template or a script counts. +func checkUnreferenced(c *Change) []Finding { + var fresh []Symbol + for _, s := range c.Symbols { + if isTestFile(s.File) || s.Kind == "field" || unsearchable[s.Name] || calledByTheRuntime(s) { + continue + } + fresh = append(fresh, s) + } + if len(fresh) == 0 { + return nil + } + tree, err := treeAt(c.root, c.rev) + if err != nil { + return nil + } + sources, err := tree.Sources() + if err != nil { + return nil + } + // One finding per file, naming what it declares and nothing refers + // to: a change that adds twenty unused values is one conversation. + unused := map[string][]Symbol{} + for _, s := range fresh { + if referenced(s, sources) { + continue + } + unused[s.File] = append(unused[s.File], s) + } + var out []Finding + for _, file := range slices.Sorted(maps.Keys(unused)) { + names := make([]string, 0, len(unused[file])) + for _, s := range unused[file] { + names = append(names, s.Name) + } + shown := names + if len(shown) > 8 { + shown = append(shown[:8], fmt.Sprintf("and %d more", len(names)-8)) + } + out = append(out, Finding{ + Job: "static", Rule: "new-symbol-unreferenced", Severity: Consider, + File: file, Line: unused[file][0].Line, Symbol: names[0], + Message: fmt.Sprintf("nothing in the repository refers to %s but the declaration%s in %s; code written for a caller that does not exist yet is a guess about what the caller will need", strings.Join(shown, ", "), plural(len(names)), file), + Fix: "use it, or leave it out until something does", + }) + } + return out +} + +// unsearchable are the names the language calls rather than the code, and +// names too short to search for. +var unsearchable = map[string]bool{"main": true, "init": true, "TestMain": true, "_": true, "default": true} + +// runtimeMethods are the methods the standard library and its encoders call +// through an interface or by reflection, so that nothing in the repository +// names them and they are referenced all the same. Measured: MarshalJSON +// and UnmarshalJSON on a bundle's appearance were the first false report. +var runtimeMethods = set(`String Error Format GoString MarshalJSON UnmarshalJSON MarshalText UnmarshalText + MarshalBinary UnmarshalBinary MarshalYAML UnmarshalYAML GobEncode GobDecode Len Less Swap Read Write + Close Seek ReadFrom WriteTo ServeHTTP Scan Value Is As Unwrap Compare Equal Hash`) + +// calledByTheRuntime is whether a symbol is called by something outside the +// repository's text: a method an encoder or interface reaches for, or a +// function cgo exports to the host under //export. +func calledByTheRuntime(s Symbol) bool { + if s.Kind == "func" && runtimeMethods[s.Name] { + return true + } + // The export directive is the last line of the doc comment, after + // whatever prose the function has. + if strings.HasSuffix(s.File, ".go") { + for _, line := range strings.Split(s.Doc, "\n") { + if strings.TrimSpace(line) == "export "+s.Name { + return true + } + } + } + return false +} + +// referenced is whether the name appears as a whole word anywhere but on +// its own declaration line or in a comment. A Go doc comment opens with +// the name it documents, and a comment is not a caller. +func referenced(s Symbol, sources map[string][]byte) bool { + if len(s.Name) < 2 { + return true // Too short to search for honestly. + } + for file, data := range sources { + text := string(data) + offset := 0 + for { + i := strings.Index(text[offset:], s.Name) + if i < 0 { + break + } + at := offset + i + offset = at + len(s.Name) + if !wordBoundary(text, at, len(s.Name)) { + continue + } + if file == s.File && lineOf(text, at) == s.Line { + continue + } + if inComment(text, at) { + continue + } + return true + } + } + return false +} + +// wordBoundary is whether the match at i of length n is bounded by +// non-identifier characters on both sides. +func wordBoundary(text string, i, n int) bool { + before := i == 0 || !identChar(text[i-1]) + after := i+n >= len(text) || !identChar(text[i+n]) + return before && after +} + +func identChar(b byte) bool { + return b == '_' || (b >= '0' && b <= '9') || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') +} + +// inComment is whether the offset sits on a line that is a comment by the +// shapes the tool's languages share, or after a line comment's opening. +func inComment(text string, offset int) bool { + start := strings.LastIndexByte(text[:offset], '\n') + 1 + line := text[start:offset] + if isCommentLine(strings.TrimSpace(line + text[offset:min(offset+1, len(text))])) { + return true + } + return strings.Contains(line, "//") || strings.Contains(line, "/*") +} + +// lineOf is the 1-based line the offset falls on. +func lineOf(text string, offset int) int { + return 1 + strings.Count(text[:offset], "\n") +} + +// testFloorLines is the size of change, in lines added to code that is not +// a test, from which a change owes a test where the repository keeps them. +const testFloorLines = 50 + +// checkCodeWithoutTests reports a change that adds a body of code to a +// repository that has tests, and touches none of them. History's coupling +// check names the pair when there is a pair; this is the general case. +func checkCodeWithoutTests(c *Change) []Finding { + added, _ := diffSides(c.Diff) + lines := 0 + for file, l := range added { + if isCodeFile(file) && !isTestFile(file) { + lines += len(l) + } + } + if lines < testFloorLines { + return nil + } + for _, f := range c.Files { + if isTestFile(f) { + return nil + } + } + tree, err := treeAt(c.root, c.rev) + if err != nil { + return nil + } + files, err := tree.Files() + if err != nil { + return nil + } + tested := 0 + for _, f := range files { + if isTestFile(f) { + tested++ + } + } + if tested == 0 { + return nil // A repository without tests is not asked to start here. + } + return []Finding{{ + Job: "static", Rule: "code-without-tests", Severity: Consider, + Message: fmt.Sprintf("the change adds %d lines of code and touches no test, in a repository that keeps %d test files; what the change does is asserted nowhere", lines, tested), + Fix: "add or extend the test that would fail without this change", + }} +} diff --git a/coverage_test.go b/coverage_test.go @@ -0,0 +1,151 @@ +package main + +import ( + "strings" + "testing" +) + +// A repository in which one new function is called and one is not. +func unreferencedRepo(t *testing.T) *repo { + t.Helper() + r := newRepo(t) + r.write("go.mod", "module x\n\ngo 1.27.0\n") + r.write("x.go", "package x\n") + r.write("x_test.go", "package x\n") + r.commit("first", "go.mod", "x.go", "x_test.go") + r.write("x.go", `package x + +// Used is called from the template below. +func Used() int { return 1 } + +// Waiting is called from nowhere. +func Waiting() int { return 2 } + +func lonely() int { return 3 } +`) + r.write("page.tmpl", "{{ Used }}\n") + return r +} + +func TestCheckUnreferencedReadsTheWholeTree(t *testing.T) { + r := unreferencedRepo(t) + r.stage("x.go", "page.tmpl") + change, err := Gather("", r.Root) + if err != nil { + t.Fatal(err) + } + got := checkUnreferenced(change) + if len(got) != 1 || got[0].Rule != "new-symbol-unreferenced" || got[0].File != "x.go" { + t.Fatalf("got %v", got) + } + for _, want := range []string{"Waiting", "lonely"} { + if !strings.Contains(got[0].Message, want) { + t.Errorf("%s missing from %s", want, got[0].Message) + } + } + if strings.Contains(got[0].Message, "Used") { + t.Errorf("a name the template uses was reported: %s", got[0].Message) + } +} + +// Against a range the tree is the range's end, not the working directory. +func TestCheckUnreferencedReadsARange(t *testing.T) { + r := unreferencedRepo(t) + rev := r.commit("second", "x.go", "page.tmpl") + r.write("y.go", "package x\n\nvar _ = Waiting()\n") // The working tree, not the range. + change, err := Gather(rev+"^.."+rev, r.Root) + if err != nil { + t.Fatal(err) + } + got := checkUnreferenced(change) + if len(got) != 1 || !strings.Contains(got[0].Message, "Waiting") { + t.Errorf("got %v", got) + } +} + +func TestReferenced(t *testing.T) { + sources := map[string][]byte{ + "a.go": []byte("package x\n\nfunc Waiting() int { return 2 }\n"), + "b.go": []byte("var _ = WaitingRoom\n"), + } + if referenced(Symbol{Name: "Waiting", File: "a.go", Line: 3}, sources) { + t.Error("a prefix of another word counted as a reference") + } + sources["c.go"] = []byte("var _ = Waiting()\n") + if !referenced(Symbol{Name: "Waiting", File: "a.go", Line: 3}, sources) { + t.Error("a call was not counted") + } +} + +func TestCheckCodeWithoutTests(t *testing.T) { + r := newRepo(t) + r.write("x.go", "package x\n") + r.write("x_test.go", "package x\n") + r.commit("first", "x.go", "x_test.go") + parts := []string{"package x\n\n"} + for i := 0; i < 60; i++ { + parts = append(parts, "var v"+itoa(i)+" = "+itoa(i)+"\n") + } + r.write("x.go", strings.Join(parts, "")) + r.stage("x.go") + change, err := Gather("", r.Root) + if err != nil { + t.Fatal(err) + } + got := checkCodeWithoutTests(change) + if len(got) != 1 || got[0].Rule != "code-without-tests" { + t.Fatalf("got %v", got) + } + // Touching a test is enough; the check is about the habit, not the + // coverage. + r.write("x_test.go", "package x\n\n// touched\n") + r.stage("x_test.go") + change, err = Gather("", r.Root) + if err != nil { + t.Fatal(err) + } + if got := checkCodeWithoutTests(change); len(got) != 0 { + t.Errorf("got %v", got) + } +} + +// A repository that keeps no tests is not asked to start with this change. +func TestCheckCodeWithoutTestsSparesAnUntestedRepository(t *testing.T) { + r := newRepo(t) + r.write("x.go", "package x\n") + r.commit("first", "x.go") + parts := []string{"package x\n\n"} + for i := 0; i < 60; i++ { + parts = append(parts, "var v"+itoa(i)+" = "+itoa(i)+"\n") + } + r.write("x.go", strings.Join(parts, "")) + r.stage("x.go") + change, err := Gather("", r.Root) + if err != nil { + t.Fatal(err) + } + if got := checkCodeWithoutTests(change); len(got) != 0 { + t.Errorf("got %v", got) + } +} + +// A method an encoder reaches for by reflection, and a function cgo exports +// to the host, are referenced by something the repository's text does not +// show. +func TestCalledByTheRuntime(t *testing.T) { + for _, test := range []struct { + symbol Symbol + want bool + }{ + {Symbol{Name: "MarshalJSON", Kind: "func", File: "a.go"}, true}, + {Symbol{Name: "String", Kind: "func", File: "a.go"}, true}, + {Symbol{Name: "DllGetClassObject", Kind: "func", File: "a.go", Doc: "DllGetClassObject answers COM.\n\nexport DllGetClassObject"}, true}, + {Symbol{Name: "DllInstall", Kind: "func", File: "a.go", Doc: "export DllGetClassObject"}, false}, + {Symbol{Name: "String", Kind: "value", File: "a.go"}, false}, + {Symbol{Name: "Probe", Kind: "func", File: "a.go", Doc: "Probe reads the header."}, false}, + } { + if got := calledByTheRuntime(test.symbol); got != test.want { + t.Errorf("%s: %v, want %v", test.symbol.Name, got, test.want) + } + } +} diff --git a/criteria/claims.md b/criteria/claims.md @@ -20,4 +20,6 @@ one: a comment gets one finding. A comment that is merely wordy is not a finding. Only judge assertions that could be false. A comment saying what the code in front of it does is never a claim, however -confidently it says it. +confidently it says it. The code beneath each comment is shown so that `stale` can be +judged against what the change actually does, and so that a comment describing that +code is read as description rather than assertion. diff --git a/criteria/namer.md b/criteria/namer.md @@ -10,18 +10,15 @@ Judge each name against these rules. Report only names the change adds or rename function whose whole job is to produce or convert to the thing it is named for is idiomatic Go and is not a finding: `Payload`, `side`, `String`. Go names an accessor for the value, not for the fetching. -- `no-stutter` — a name does not repeat its package: `ico.IcoEntry`, `exe.ExeKind`. - Within a package the package name is already said. -- `no-shadow` — a name does not collide with something already in scope, especially - a standard library package the file imports (`binary`, `path`, `url`, `sort`). - `says-what-not-how` — the name describes what the thing is or does, not the mechanism. `containerSniff` says how; `container` says what. - `matches-neighbours` — the name uses the vocabulary its package already uses. If a package calls them entries, a new one is not a record. This is about the word chosen for a thing, not about two things holding the same value: whether a fact has been stated twice is another reader's job, and you are not the one to report it. -- `abbreviation` — no invented abbreviation. Established ones (`id`, `url`, `png`) - are fine; `cfg`, `mgr`, `hdlr` are not. + +Stutter against the package, a name the language already uses, and the common +invented abbreviations are measured before you read, and are not yours to report. Do not comment on names the change did not touch. Do not propose a rename whose only merit is your preference: if the existing name satisfies the rules, say nothing. diff --git a/criteria/tests.md b/criteria/tests.md @@ -19,6 +19,12 @@ whether they are tidy. The question is whether they can fail. Report the specific assertion at fault, and what to assert instead. Ignore style, naming and table-versus-loop questions entirely. +A test that skips itself is marked with the line it skips on; your question about +it is only whether the absence it skips on is ordinary. A test with no assertion +at all is measured before you read, and is not yours to report unless the rule is +another one. Where the functions the tests call are shown, judge `passes-on-a-stub` +against the function's body, not against the test alone. + ## Reading across languages A test body arrives whole however the language runs it: a Go `Test` function, a diff --git a/finding.go b/finding.go @@ -73,6 +73,9 @@ type Finding struct { // Fix is the concrete change suggested, which is what lets an agent act // on the finding rather than reason about it again. Fix string `json:"fix,omitempty"` + // Snippet is the line the finding points at, as it stands, so that a + // program acting on the finding need not open the file to see it. + Snippet string `json:"snippet,omitempty"` // Verified is what stands behind the finding. The deterministic checks // and staticcheck verify themselves; a model's finding is verified when // a second reading of the same evidence let it stand. A finding no @@ -82,6 +85,10 @@ type Finding struct { // not where it sits, so that a re-run of the same change names the same // finding again and a loop can refer to one by name. ID string `json:"id,omitempty"` + + // part is which part of a split subject the finding came from, so the + // second reading sees the same evidence the first did. + part int } func (f Finding) String() string { @@ -124,13 +131,23 @@ func Sort(findings []Finding) { }) } -// identify gives a finding its short id. The line is left out of the hash: -// lines move under edits that do not touch the finding, and an id that -// changes with every re-run names nothing. +// identify gives a finding its short id: a hash of what it is about, not +// where it sits or how it was worded. The line is left out because lines +// move under edits that do not touch the finding; a model's message is +// left out because a fresh reading may word the same finding differently, +// and an id that changed with the wording would name nothing. A +// finding with no symbol is told from its neighbours by its line, and a +// deterministic check's message is part of what it is about — the +// coupled partner, the measured number — and is stable, so it stays in. func identify(f *Finding) { - h := sha256.Sum256([]byte(strings.Join([]string{ - f.Job, f.Rule, f.File, f.Symbol, f.Message, - }, "\x00"))) + parts := []string{f.Job, f.Rule, f.File, f.Symbol} + switch { + case f.Job == "static": + parts = append(parts, f.Message) + case f.Symbol == "": + parts = append(parts, strconv.Itoa(f.Line)) + } + h := sha256.Sum256([]byte(strings.Join(parts, "\x00"))) f.ID = hex.EncodeToString(h[:])[:12] } diff --git a/finding_test.go b/finding_test.go @@ -204,8 +204,9 @@ func TestAtoi(t *testing.T) { } // A finding's id is what a loop uses to answer it and to check it stayed -// answered, so the same finding names itself the same way across runs, and -// a line that moved under it does not change its name. +// answered, so the same finding names itself the same way across runs: a +// line that moved under it does not change its name, and neither does a +// fresh reading that words the same finding differently. func TestFindingIDIsStable(t *testing.T) { f := Finding{Job: "tests", Rule: "cannot-fail", File: "x_test.go", Line: 4, Symbol: "TestX", Message: "m"} identify(&f) @@ -215,10 +216,45 @@ func TestFindingIDIsStable(t *testing.T) { if moved.ID != f.ID || f.ID == "" { t.Errorf("the id moved with the line: %q then %q", f.ID, moved.ID) } + reworded := f + reworded.Message = "the same finding, said another way" + identify(&reworded) + if reworded.ID != f.ID { + t.Errorf("the id moved with the wording: %q then %q", f.ID, reworded.ID) + } other := f - other.Message = "a different finding" + other.Symbol = "TestY" identify(&other) if other.ID == f.ID { t.Error("two findings share one id") } + // Without a symbol the line is what tells two comments apart. + a := Finding{Job: "claims", Rule: "unsupported-claim", File: "x.go", Line: 4} + b := Finding{Job: "claims", Rule: "unsupported-claim", File: "x.go", Line: 9} + identify(&a) + identify(&b) + if a.ID == b.ID { + t.Error("two comments share one id") + } + // A deterministic check's message is what it is about, and stable. + s1 := Finding{Job: "static", Rule: "history-coupled-file", File: "a.go", Message: "ties a.go to b.go"} + s2 := Finding{Job: "static", Rule: "history-coupled-file", File: "a.go", Message: "ties a.go to c.go"} + identify(&s1) + identify(&s2) + if s1.ID == s2.ID { + t.Error("two partners share one id") + } +} + +// Two findings that hash the same are told apart in the report by a +// counter, so a loop can still answer each by name. +func TestNameFindingsTellsTwinsApart(t *testing.T) { + findings := []Finding{ + {Job: "namer", Rule: "abbreviation", File: "x.go", Symbol: "cfg"}, + {Job: "namer", Rule: "abbreviation", File: "x.go", Symbol: "cfg"}, + } + nameFindings(findings) + if findings[0].ID == findings[1].ID || !strings.HasSuffix(findings[1].ID, "-2") { + t.Errorf("got %q and %q", findings[0].ID, findings[1].ID) + } } diff --git a/formatting.go b/formatting.go @@ -0,0 +1,43 @@ +package main + +// A commit that reformats and changes logic in one diff hides the logic +// among the reformatting, and neither can be reverted alone. Git can count +// which changed lines change only whitespace, and the count is the check. + +import "fmt" + +const ( + // formattingShare is the share of a change's lines that change only + // whitespace, at which the change is a reformatting with logic mixed + // in. Sampled over 1,853 local commits: 0.7% fire at 0.5 with the two + // floors below, and the ones that do are the mixed kind. + formattingShare = 0.5 + // formattingFloor is the fewest whitespace-only lines worth a word. + formattingFloor = 20 + // logicFloor is the fewest lines that change something other than + // whitespace, under which the change is a reformatting and nothing is + // mixed into it. + logicFloor = 10 +) + +// checkFormatting reports a change whose diff is mostly whitespace and yet +// carries logic too: two changes that should be two commits, and the +// deterministic case of the hygiene job's rule. +func checkFormatting(c *Change) []Finding { + if c.Changed == 0 || c.Whitespace < formattingFloor { + return nil + } + logic := c.Changed - c.Whitespace + if logic < logicFloor { + return nil + } + share := float64(c.Whitespace) / float64(c.Changed) + if share < formattingShare { + return nil + } + return []Finding{{ + Job: "static", Rule: "formatting-mixed-in", Severity: Consider, + Message: fmt.Sprintf("%d of the change's %d lines change only whitespace, and %d change something else; a reformatting with logic in it hides the logic, and neither half can be reverted alone", c.Whitespace, c.Changed, logic), + Fix: "commit the reformatting on its own, then the change", + }} +} diff --git a/formatting_test.go b/formatting_test.go @@ -0,0 +1,40 @@ +package main + +import "testing" + +func TestCheckFormatting(t *testing.T) { + for _, test := range []struct { + changed, whitespace int + fires bool + }{ + {100, 60, true}, + {100, 40, false}, // under the share + {30, 20, true}, // just enough logic + {25, 20, false}, // too little logic mixed in + {1000, 995, false}, // a pure reformatting is not a mixture + {20, 10, false}, + {0, 0, false}, + } { + got := checkFormatting(&Change{Changed: test.changed, Whitespace: test.whitespace}) + if (len(got) == 1) != test.fires { + t.Errorf("%d of %d whitespace: got %v", test.whitespace, test.changed, got) + } + } +} + +// Through Gather, git counts the whitespace-only lines. +func TestGatherCountsWhitespaceOnlyLines(t *testing.T) { + r := newRepo(t) + r.write("x.go", "package x\n\nvar a = 1\nvar b = 2\nvar c = 3\n") + r.commit("first", "x.go") + r.write("x.go", "package x\n\nvar a = 1\nvar b = 2\nvar c = 4\n") + r.stage("x.go") + change, err := Gather("", r.Root) + if err != nil { + t.Fatal(err) + } + // Three lines changed on each side; two pairs differ only in spacing. + if change.Changed != 6 || change.Whitespace != 4 { + t.Errorf("changed %d, whitespace %d", change.Changed, change.Whitespace) + } +} diff --git a/frontend.go b/frontend.go @@ -80,7 +80,7 @@ func frontends() []Frontend { // The TypeScript frontend is only offered when ast-grep is installed; its // files fall to the heuristic reader otherwise, which says why. if _, err := exec.LookPath("ast-grep"); err == nil { - readers = append(readers, TSFrontend{}) + readers = append(readers, TSFrontend{}, pythonFrontend(), rustFrontend()) } // Same for the Odin sidecar, which is built from sidecar/odin in this // repository. diff --git a/frontend_test.go b/frontend_test.go @@ -103,10 +103,11 @@ func TestHeuristicLeavesProseAlone(t *testing.T) { func TestHeuristicLogsTheSkips(t *testing.T) { r := newRepo(t) - r.write("f/main.rs", "fn main() {}\n") - r.commit("f: empty", "f/main.rs") - r.write("f/main.rs", "fn main() { }\n") - r.stage("f/main.rs") + // Lua has no frontend, so it is the heuristic's whatever is installed. + r.write("f/main.lua", "function main() end\n") + r.commit("f: empty", "f/main.lua") + r.write("f/main.lua", "function main() end\n") + r.stage("f/main.lua") var logged string change, err := Gather("", r.Root) @@ -116,10 +117,10 @@ func TestHeuristicLogsTheSkips(t *testing.T) { logged = captureStderr(t, func() { change.read(r.Root, "", frontends()) }) - if !strings.Contains(logged, "skipping duplication, namer for .rs") { + if !strings.Contains(logged, "skipping duplication, namer for .lua") { t.Fatalf("logged %q, want the duplication and namer skip", logged) } - if !strings.Contains(logged, "skipping tests for .rs") { + if !strings.Contains(logged, "skipping tests for .lua") { t.Fatalf("logged %q, want the tests skip", logged) } if strings.Contains(logged, "skipping claims") { diff --git a/gaming.go b/gaming.go @@ -85,11 +85,14 @@ var ruleID = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) var goTest = regexp.MustCompile(`^func ((?:Test|Benchmark|Fuzz)[A-Za-z0-9_]+)\(`) // jsTestCall is a test's registration in the JavaScript and TypeScript -// shapes, with the skipping and focusing modifiers read through. -var jsTestCall = regexp.MustCompile(`^(?:test|describe|it)(?:\.(?:skip|only|todo|fails))*\(`) +// shapes — Node, Bun, Deno and the Jest family — with the skipping and +// focusing modifiers read through. +var jsTestCall = regexp.MustCompile(`^(?:Deno\.test|test|describe|it)(?:\.(?:skip|only|todo|fails|failing|ignore|concurrent|serial))*\(`) // isTestFile reports whether a path is one a test runner reads, in any of the -// naming habits the tool's languages have. +// naming habits the tool's languages have: a marker in the name, or a tests +// directory. A Rust unit test sits in the source file beside the code, and +// that file is not a test file; an integration test under tests/ is. func isTestFile(path string) bool { base := filepath.Base(path) for _, marker := range []string{"_test.", ".test.", ".spec."} { @@ -97,9 +100,27 @@ func isTestFile(path string) bool { return true } } + if strings.HasSuffix(base, ".py") && (strings.HasPrefix(base, "test_") || strings.HasSuffix(base, "_test.py")) { + return true + } + for dir := range strings.SplitSeq(filepath.ToSlash(filepath.Dir(path)), "/") { + if dir == "tests" || dir == "__tests__" || dir == "test" { + return true + } + } return false } +// pyTest is a Python test's declaration, as a removed line carries it. +var pyTest = regexp.MustCompile(`^(?:async\s+)?def (test_\w+)\(`) + +// rsTestAttribute is the attribute that makes the Rust function after it a +// test; rsFn is that function's declaration. +var ( + rsTestAttribute = regexp.MustCompile(`^#\[[\w:]*test(\(|\])`) + rsFn = regexp.MustCompile(`^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn (\w+)\(`) +) + // checkDeletedTests reports the tests a change deletes. The tests job reads // the tests a change adds or alters, and a test is never heard from again // once deleted, so deletion is where a test the reading would have failed @@ -112,13 +133,23 @@ func checkDeletedTests(c *Change) []Finding { spoken := addedTestNames(added) var findings []Finding for _, file := range slices.Sorted(maps.Keys(removed)) { - if !isTestFile(file) { + if !isTestFile(file) && !strings.HasSuffix(file, ".rs") { continue } var names []string seen := map[string]bool{} - for _, text := range removed[file] { + for i, text := range removed[file] { name := removedTestName(text) + if strings.HasSuffix(file, ".rs") { + // A Rust test is the function after the attribute, and + // only that; a deleted function without one is not a test. + name = "" + if rsTestAttribute.MatchString(strings.TrimSpace(text)) && i+1 < len(removed[file]) { + if m := rsFn.FindStringSubmatch(strings.TrimSpace(removed[file][i+1])); m != nil { + name = m[1] + } + } + } if name == "" || seen[name] { continue } @@ -160,6 +191,9 @@ func removedTestName(text string) string { if m := goTest.FindStringSubmatch(trimmed); m != nil { return m[1] } + if m := pyTest.FindStringSubmatch(trimmed); m != nil { + return m[1] + } if loc := jsTestCall.FindStringSubmatchIndex(trimmed); loc != nil { return readQuoted(strings.TrimLeft(trimmed[loc[1]:], " \t")) } @@ -192,6 +226,10 @@ func addedTestNames(added map[string][]diffLine) []string { trimmed := strings.TrimLeft(l.Text, " \t") if m := goTest.FindStringSubmatch(trimmed); m != nil { seen[m[1]] = true + } else if m := pyTest.FindStringSubmatch(trimmed); m != nil { + seen[m[1]] = true + } else if m := rsFn.FindStringSubmatch(trimmed); m != nil { + seen[m[1]] = true } else if loc := jsTestCall.FindStringSubmatchIndex(trimmed); loc != nil { if name := readQuoted(strings.TrimLeft(trimmed[loc[1]:], " \t")); name != "" { seen[name] = true diff --git a/gaming_test.go b/gaming_test.go @@ -242,3 +242,21 @@ func TestFilePredicates(t *testing.T) { } } } + +func TestRemovedTestNameReadsEveryRunner(t *testing.T) { + for line, want := range map[string]string{ + `Deno.test("deno string", () => {`: "deno string", + `Deno.test.ignore("deno ignored", () => {`: "deno ignored", + ` test.skip("bun skipped", () => {`: "bun skipped", + `it.only("focused", () => {`: "focused", + `test('node plain', { timeout: 5 }, () => {`: "node plain", + `test.concurrent.only("both", async () => {`: "both", + `describe("suite", () => {`: "suite", + `Deno.test({ name: "object", fn() {`: "", + `const test = 1;`: "", + } { + if got := removedTestName(line); got != want { + t.Errorf("%q: got %q, want %q", line, got, want) + } + } +} diff --git a/gofrontend.go b/gofrontend.go @@ -41,6 +41,9 @@ func (g GoFrontend) Change(root, rev string, c *Change, added map[string][]int) } lines := strings.Split(string(source), "\n") touched := added[name] + for _, imported := range file.Imports { + c.Imports[name] = append(c.Imports[name], importName(imported)) + } ast.Inspect(file, func(n ast.Node) bool { switch decl := n.(type) { @@ -69,6 +72,7 @@ func (g GoFrontend) Change(root, rev string, c *Change, added map[string][]int) Name: name, Kind: "func", Doc: doc(decl.Doc), File: relative(root, path), Line: line, Exported: ast.IsExported(name), Signature: strings.TrimSpace(lines[line-1]), + Body: text(lines, line, end), Package: file.Name.Name, }) case *ast.TypeSpec: line := fset.Position(decl.Pos()).Line @@ -78,6 +82,7 @@ func (g GoFrontend) Change(root, rev string, c *Change, added map[string][]int) c.Symbols = append(c.Symbols, Symbol{ Name: decl.Name.Name, Kind: "type", File: relative(root, path), Line: line, Exported: ast.IsExported(decl.Name.Name), Signature: strings.TrimSpace(lines[line-1]), + Package: file.Name.Name, }) case *ast.ValueSpec: for _, ident := range decl.Names { @@ -88,6 +93,7 @@ func (g GoFrontend) Change(root, rev string, c *Change, added map[string][]int) c.Symbols = append(c.Symbols, Symbol{ Name: ident.Name, Kind: "value", File: relative(root, path), Line: line, Exported: ast.IsExported(ident.Name), Signature: strings.TrimSpace(lines[line-1]), + Package: file.Name.Name, }) } } @@ -114,16 +120,20 @@ func (g GoFrontend) Change(root, rev string, c *Change, added map[string][]int) // rather than by searching: a pattern over lines misses an indented constant // inside a block, which is exactly where a duplicated fact tends to live. func (GoFrontend) Whole(root, rev string) ([]Declared, error) { - out, err := git(root, "ls-files", "*.go") + tree, err := treeAt(root, rev) + if err != nil { + return nil, err + } + tracked, err := tree.Files() if err != nil { return nil, err } var index []Declared - for _, name := range strings.Split(strings.TrimSpace(out), "\n") { - if name == "" || strings.HasSuffix(name, "_test.go") { + for _, name := range tracked { + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { continue } - source, err := at(root, rev, name) + source, err := tree.Read(name) if err != nil { continue } @@ -144,7 +154,8 @@ func (GoFrontend) Whole(root, rev string) ([]Declared, error) { switch d := decl.(type) { case *ast.FuncDecl: line, text := at(d.Pos()) - index = append(index, Declared{Name: d.Name.Name, Kind: "func", File: name, Line: line, Text: text}) + end := fset.Position(d.End()).Line + index = append(index, Declared{Name: d.Name.Name, Kind: "func", File: name, Line: line, Text: text, Body: bodyOf(lines, line, end)}) case *ast.GenDecl: for _, spec := range d.Specs { switch s := spec.(type) { @@ -174,6 +185,25 @@ func (GoFrontend) Whole(root, rev string) ([]Declared, error) { return index, nil } +// bodyOf is text, under a name the closure over a line's text does not +// shadow. +var bodyOf = text + +// importName is the name an import binds in the file: its alias where it +// has one, else the last element of its path. +func importName(spec *ast.ImportSpec) string { + if spec.Name != nil { + return spec.Name.Name + } + path := strings.Trim(spec.Path.Value, `"`) + if i := strings.LastIndex(path, "/"); i >= 0 { + path = path[i+1:] + } + // A versioned module path ends in its major version, which binds the + // element before it. + return path +} + // isTest reports whether a function is one the testing package runs. func isTest(name string) bool { return strings.HasPrefix(name, "Test") || strings.HasPrefix(name, "Fuzz") || strings.HasPrefix(name, "Benchmark") diff --git a/helper_test.go b/helper_test.go @@ -8,6 +8,13 @@ import ( "testing" ) +// TestMain removes the trees the tests materialised, as the command does. +func TestMain(m *testing.M) { + code := m.Run() + closeTrees() + os.Exit(code) +} + // repo builds a git repository in a temporary directory, so that the parts of // this tool that read a revision can be tested against real git output rather // than a transcript of it. diff --git a/heuristic.go b/heuristic.go @@ -15,11 +15,12 @@ func (Heuristic) Name() string { return "heuristic" } // Covers reports the languages whose comment shape it knows. Prose-only // formats (markdown, configs) are left out: their text is not a comment, and -// the claims job would be asked to judge ordinary writing. +// the claims job would be asked to judge ordinary writing. Python and Rust +// are here for when ast-grep is absent; with it, their frontends read first. func (Heuristic) Covers(path string) bool { for _, ext := range []string{ ".odin", ".py", ".rb", ".rs", ".c", ".h", ".cc", ".cpp", ".hpp", - ".js", ".mjs", ".cjs", ".lua", ".zig", ".swift", ".kt", ".java", + ".js", ".jsx", ".mjs", ".cjs", ".lua", ".zig", ".swift", ".kt", ".java", ".php", ".scala", ".cs", } { if strings.HasSuffix(path, ext) { diff --git a/hook.go b/hook.go @@ -0,0 +1,160 @@ +package main + +// The review forces nothing until something runs it and refuses on its +// word. The hooks here are that something: a commit-msg hook, which gates +// every agent that commits through git, and the stanza an agent's own +// harness takes. The text an agent needs in its instructions is printed +// from the binary too, so the tool explains itself wherever it is. + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "strings" +) + +// hook installs or prints the hooks that make the review a gate. +func hook(args []string) error { + flags := flag.NewFlagSet("hook", flag.ContinueOnError) + force := flags.Bool("force", false, "Replace a commit-msg hook that is already there.") + if err := flags.Parse(args); err != nil { + return err + } + switch flags.Arg(0) { + case "install": + return installHook(*force) + case "print", "": + fmt.Print(hookText()) + return nil + } + return fmt.Errorf("hook takes install or print, not %q", flags.Arg(0)) +} + +// installHook writes the commit-msg hook into the repository, naming this +// binary by its absolute path so the hook works wherever the shell's path +// does not reach. A hook already there is not overwritten unasked: it is +// someone's, and it may do more than this. +func installHook(force bool) error { + root, err := repository() + if err != nil { + return err + } + // With core.hooksPath set, git reads hooks from one directory for every + // repository and ignores .git/hooks. Writing there would change every + // repository on the machine, and writing to .git/hooks would change + // nothing; neither is this tool's to do unasked. + if shared, err := git(root, "config", "--get", "core.hooksPath"); err == nil && strings.TrimSpace(shared) != "" { + fmt.Print(commitMsgHook()) + return fmt.Errorf("core.hooksPath is %s, so git reads hooks there and not from .git/hooks; add the exec line above to %s/commit-msg yourself", + strings.TrimSpace(shared), strings.TrimSpace(shared)) + } + dir, err := git(root, "rev-parse", "--git-path", "hooks") + if err != nil { + return err + } + dir = strings.TrimSpace(dir) + if !filepath.IsAbs(dir) { + dir = filepath.Join(root, dir) + } + path := filepath.Join(dir, "commit-msg") + if _, err := os.Stat(path); err == nil && !force { + return fmt.Errorf("%s exists; read it, then pass -force to replace it", path) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + if err := os.WriteFile(path, []byte(commitMsgHook()), 0o755); err != nil { + return err + } + fmt.Printf("wrote %s\n\n", path) + fmt.Print(agentStanza()) + return nil +} + +// self is the absolute path of the running binary, or its bare name where +// that cannot be known. +func self() string { + exe, err := os.Executable() + if err != nil { + return "review" + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + return exe +} + +// commitMsgHook is the hook git runs with the proposed message's file: the +// staged change is reviewed with that message, and a must-fix finding +// refuses the commit. +func commitMsgHook() string { + return fmt.Sprintf(`#!/bin/sh +# Installed by review. The staged change is reviewed with the message being +# committed; a must-fix finding that stands refuses the commit. Dismiss a +# finding where it is wrong, in the source: //review:ignore <rule> <why> +exec %q --message-file "$1" --exit-code +`, self()) +} + +// hookText is everything hook print shows: the git hook and the harness +// stanzas. +func hookText() string { + return "# .git/hooks/commit-msg — or run: review hook install\n" + commitMsgHook() + "\n" + agentStanza() +} + +// agentStanza is the hook an agent's harness takes, for the harnesses whose +// shape is known. Claude Code runs a PreToolUse hook before each tool call; +// the one below reviews the staged change when the call is a git commit, +// and a non-zero exit stops the call with the review's output as the reason. +func agentStanza() string { + return fmt.Sprintf(`# Claude Code: .claude/settings.json (or ~/.claude/settings.json) +# Reviews the staged change before any "git commit" the agent runs. +{ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "if grep -q 'git commit' ; then %s --exit-code; fi" + }] + }] + } +} + +# Any other agent: the commit-msg hook above gates every commit it makes +# through git, whatever harness it runs in. Put the output of +# review agent +# in its instructions so it knows what the refusal means and how to answer it. +`, self()) +} + +// agent prints what an agent's instructions should say about this tool: how +// to run it, how to read it, how to answer it, and when it may stop. +func agent() error { + fmt.Print(agentText()) + return nil +} + +func agentText() string { + return `## Code review + +Before committing, run ` + "`review --json`" + ` on the staged change and act on the report. + +- ` + "`status`" + ` is ` + "`complete`" + ` when every reader read everything. An empty findings list + under any other status is a hole, not a pass. +- Every finding carries a stable ` + "`id`" + `, a ` + "`rule`" + `, a ` + "`severity`" + `, the ` + "`file`" + ` and + ` + "`line`" + `, the ` + "`snippet`" + ` at that line, and a ` + "`fix`" + `: the concrete change to make. + Read the rule with ` + "`review rules <rule>`" + ` when the finding is unclear. +- Fix every ` + "`must-fix`" + `. Weigh each ` + "`consider`" + `. A ` + "`note`" + ` needs no action. +- Where a finding is wrong, dismiss it in the source it concerns, on the line + above, with the reason: ` + "`//review:ignore <rule> <why>`" + `. Never dismiss to pass; + a dismissal added by the change under review is itself a must-fix finding. +- Re-run with ` + "`--baseline previous.json`" + ` to see which ids resolved, persist or + are new. Stop when nothing must-fix persists and the status is complete. +- Never delete a test to pass the review; deleting one is a must-fix finding. +- The commit message is measured too: ` + "`package: explainer`" + `, the explainer in + the imperative, a body saying why for any change over fifty lines, under 150 + words, naming only code that exists. +` +} diff --git a/hook_test.go b/hook_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestHookInstall(t *testing.T) { + r := newRepo(t) + r.write("x.go", "package x\n") + r.commit("first", "x.go") + t.Chdir(r.Root) + // The tool's own git reads the real configuration, and a machine with + // core.hooksPath set would send the install elsewhere. + t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null") + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + out := capture(t, func() { + if err := hook([]string{"install"}); err != nil { + t.Fatal(err) + } + }) + path := filepath.Join(r.Root, ".git", "hooks", "commit-msg") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `--message-file "$1" --exit-code`) || !strings.HasPrefix(string(data), "#!/bin/sh") { + t.Errorf("hook:\n%s", data) + } + if info, _ := os.Stat(path); info.Mode()&0o100 == 0 { + t.Error("the hook is not executable") + } + if !strings.Contains(out, "PreToolUse") { + t.Errorf("the harness stanza was not printed:\n%s", out) + } + // A hook already there is someone's, and is not replaced unasked. + if err := hook([]string{"install"}); err == nil || !strings.Contains(err.Error(), "-force") { + t.Errorf("an existing hook was replaced: %v", err) + } + if err := hook([]string{"-force", "install"}); err != nil { + t.Errorf("force: %v", err) + } + if err := hook([]string{"dance"}); err == nil { + t.Error("an unknown verb was accepted") + } +} + +func TestHookPrintAndAgentText(t *testing.T) { + out := capture(t, func() { + if err := hook(nil); err != nil { + t.Fatal(err) + } + }) + for _, want := range []string{"commit-msg", "--exit-code", "PreToolUse", "review agent"} { + if !strings.Contains(out, want) { + t.Errorf("%q missing from hook print", want) + } + } + text := capture(t, func() { + if err := agent(); err != nil { + t.Fatal(err) + } + }) + for _, want := range []string{"review --json", "`status`", "review rules <rule>", "review:ignore", "--baseline", "Never delete a test"} { + if !strings.Contains(text, want) { + t.Errorf("%q missing from the agent text", want) + } + } +} + +// A machine whose hooks live in one shared directory is not written to: +// the hook is printed, and the refusal says where it goes. +func TestHookInstallRefusesASharedHooksPath(t *testing.T) { + r := newRepo(t) + r.write("x.go", "package x\n") + r.commit("first", "x.go") + r.run("config", "core.hooksPath", filepath.Join(r.Root, "shared-hooks")) + t.Chdir(r.Root) + t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null") + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + var err error + out := capture(t, func() { err = hook([]string{"install"}) }) + if err == nil || !strings.Contains(err.Error(), "core.hooksPath") { + t.Fatalf("installed under a shared hooks path: %v", err) + } + if !strings.Contains(out, "--message-file") { + t.Errorf("the hook was not printed for the user to place:\n%s", out) + } + if _, statErr := os.Stat(filepath.Join(r.Root, ".git", "hooks", "commit-msg")); statErr == nil { + t.Error("a hook git would ignore was written") + } +} diff --git a/index.go b/index.go @@ -1,7 +1,9 @@ package main import ( + "cmp" "fmt" + "slices" "strings" ) @@ -15,43 +17,79 @@ type Declared struct { // Text is the line it was declared on, which is what shows a reader that // two constants hold the same number. Text string + // Body is the whole declaration where it has one, for the checks that + // compare two functions rather than two names. + Body string } func (d Declared) String() string { return fmt.Sprintf("%s:%d: %s", d.File, d.Line, strings.TrimSpace(d.Text)) } -// Resembling returns the declarations whose names share a word with the one -// given, which is the shortlist a reader is asked to judge. Its own +// Resembling returns the declarations whose names share a word with the +// one given, which is the shortlist a reader is asked to judge. Words are +// matched whole, so that cache does not pull in every Cached, and the list +// is ranked: the more words shared the higher, a declaration of the same +// kind above one of another, and the same file above the rest. Its own // declaration is left out, since a name always resembles itself. func Resembling(index []Declared, symbol Symbol, limit int) []string { - words := split(symbol.Name) - var out []string + wanted := map[string]bool{} + for _, word := range split(symbol.Name) { + if len(word) >= 4 { + wanted[depluralise(strings.ToLower(word))] = true + } + } + if len(wanted) == 0 { + return nil + } + type candidate struct { + line string + score int + order int + } + var out []candidate seen := map[string]bool{} - for _, word := range words { - if len(word) < 4 { + for i, declared := range index { + if declared.File == symbol.File && declared.Line == symbol.Line { continue } - lower := strings.ToLower(word) - for _, declared := range index { - if declared.File == symbol.File && declared.Line == symbol.Line { - continue - } - if !strings.Contains(strings.ToLower(declared.Name), lower) { - continue - } - line := declared.String() - if seen[line] { - continue - } - seen[line] = true - out = append(out, line) - if len(out) >= limit { - return out + shared := 0 + for _, word := range split(declared.Name) { + if wanted[depluralise(strings.ToLower(word))] { + shared++ } } + if shared == 0 { + continue + } + line := declared.String() + if seen[line] { + continue + } + seen[line] = true + score := shared * 4 + if declared.Kind == symbol.Kind { + score += 2 + } + if declared.File == symbol.File { + score++ + } + out = append(out, candidate{line, score, i}) + } + slices.SortFunc(out, func(a, b candidate) int { + if c := cmp.Compare(b.score, a.score); c != 0 { + return c + } + return cmp.Compare(a.order, b.order) + }) + if len(out) > limit { + out = out[:limit] + } + lines := make([]string, len(out)) + for i, r := range out { + lines[i] = r.line } - return out + return lines } // same reports whether two declarations hold the same literal, which is the diff --git a/job.go b/job.go @@ -4,6 +4,8 @@ import ( "embed" "encoding/json" "fmt" + "regexp" + "slices" "strings" ) @@ -23,15 +25,19 @@ type Job struct { // means there is nothing here for it and the job is skipped, which is // what keeps a documentation-only commit from paying for a test review. Subject func(*Change) string + // Splittable is whether the job's subject can be read file by file: a + // subject over the packet cap is then asked in parts, each cached on + // its own, rather than as one ask a slow provider cannot finish. + Splittable bool } // Jobs are the readings, in the order their findings are worth having. func Jobs() []Job { return []Job{ - {Name: "duplication", Criteria: read("duplication"), Subject: duplicationSubject}, - {Name: "tests", Criteria: read("tests"), Subject: testsSubject}, - {Name: "namer", Criteria: read("namer"), Subject: namerSubject}, - {Name: "claims", Criteria: read("claims"), Subject: claimsSubject}, + {Name: "duplication", Criteria: read("duplication"), Subject: duplicationSubject, Splittable: true}, + {Name: "tests", Criteria: read("tests"), Subject: testsSubject, Splittable: true}, + {Name: "namer", Criteria: read("namer"), Subject: namerSubject, Splittable: true}, + {Name: "claims", Criteria: read("claims"), Subject: claimsSubject, Splittable: true}, {Name: "hygiene", Criteria: read("hygiene"), Subject: hygieneSubject}, } } @@ -130,23 +136,126 @@ func testsSubject(c *Change) string { var b strings.Builder b.WriteString("Test functions this change adds or alters:\n\n") for _, t := range c.Tests { - fmt.Fprintf(&b, "--- %s:%d %s\n%s\n\n", t.File, t.Line, t.Name, t.Body) + fmt.Fprintf(&b, "--- %s:%d %s", t.File, t.Line, t.Name) + if t.Skips > 0 { + // The skip is pointed at rather than left to be found, so the + // reading spends itself on whether the skip is ordinary. + fmt.Fprintf(&b, " (skips itself at line %d)", t.Skips) + } + fmt.Fprintf(&b, "\n%s\n\n", t.Body) + } + if called := functionsUnderTest(c); called != "" { + b.WriteString("Functions the tests call, as they stand at the end of the change. A test that\n") + b.WriteString("would pass with one of these returning its input or a zero value is the finding.\n\n") + b.WriteString(called) + } + return b.String() +} + +// The bounds on what the tests job is shown of the code under test: how +// many functions, and how long each may be before it is cut. +const ( + calledFunctions = 8 + calledLines = 60 +) + +// functionsUnderTest renders the functions the tests call, found by name in +// the repository's index, so that whether a test would pass on a stub is +// judged against the function rather than guessed from the test. The +// functions are the ones declared outside the tests: a helper a test file +// declares is in the test's own file, and is not the code under test. +func functionsUnderTest(c *Change) string { + if len(c.index) == 0 { + return "" + } + declared := map[string]Declared{} + for _, d := range c.index { + if d.Kind == "func" && d.Body != "" && !isTestFile(d.File) { + if _, taken := declared[d.Name]; !taken { + declared[d.Name] = d + } + } + } + seen := map[string]bool{} + var shown []Declared + for _, t := range c.Tests { + for _, m := range call.FindAllStringSubmatch(t.Body, -1) { + name := m[1] + d, ok := declared[name] + if !ok || seen[name] || name == t.Name { + continue + } + seen[name] = true + shown = append(shown, d) + if len(shown) == calledFunctions { + break + } + } + if len(shown) == calledFunctions { + break + } + } + if len(shown) == 0 { + return "" + } + var b strings.Builder + for _, d := range shown { + body := d.Body + if lines := strings.Split(body, "\n"); len(lines) > calledLines { + body = strings.Join(lines[:calledLines], "\n") + "\n\t… cut at " + fmt.Sprint(calledLines) + " lines" + } + fmt.Fprintf(&b, "--- %s:%d %s\n%s\n\n", d.File, d.Line, d.Name, body) } return b.String() } +// call matches a call by name, which is how a test names what it tests. +var call = regexp.MustCompile(`\b([A-Za-z_][A-Za-z0-9_]*)\(`) + func claimsSubject(c *Change) string { - if len(c.Comments) == 0 { + blocks := commentBlocks(c.Comments) + if len(blocks) == 0 { return "" } var b strings.Builder - b.WriteString("Comment and documentation lines this change adds:\n\n") - for _, comment := range c.Comments { - fmt.Fprintf(&b, "%s:%d %s\n", comment.File, comment.Line, comment.Text) + b.WriteString("Comment and documentation lines this change adds, each with the code beneath it:\n\n") + for _, block := range blocks { + for _, comment := range block { + fmt.Fprintf(&b, "%s:%d %s\n", comment.File, comment.Line, comment.Text) + } + if below := block[len(block)-1].Below; below != "" { + for line := range strings.SplitSeq(below, "\n") { + fmt.Fprintf(&b, " code: %s\n", line) + } + } + b.WriteString("\n") } return b.String() } +// commentBlocks groups the comments into the runs of consecutive lines +// they were written as, so a claim read over three lines is read whole and +// the code below it is shown once. A comment whose words are the code's +// own is left out: it is never a claim, and measured elsewhere. +func commentBlocks(comments []Located) [][]Located { + var blocks [][]Located + for _, comment := range comments { + if restates(comment) { + continue + } + n := len(blocks) + if n > 0 { + last := blocks[n-1][len(blocks[n-1])-1] + if last.File == comment.File && last.Line+1 == comment.Line { + blocks[n-1] = append(blocks[n-1], comment) + continue + } + } + blocks = append(blocks, []Located{comment}) + } + return blocks +} + func hygieneSubject(c *Change) string { if strings.TrimSpace(c.Message) == "" { return "" @@ -222,3 +331,87 @@ func decode(raw string) (reported, error) { } return r, nil } + +// packetCap is the size of subject past which a splittable job is asked in +// parts. The tests packet of one seven-file change measured 22 KB and was +// killed at the ask timeout on a slow gateway; under the cap each part is +// an ask that gateway finishes, and a part whose files did not change +// replays from the cache while the others are asked. +const packetCap = 16000 + +// parts is the subjects a job is asked, as changes: the whole change when +// it fits or cannot be split, else the change cut file by file into runs +// that each render under the cap. A file that alone renders over the cap +// is a part by itself. +func parts(job Job, c *Change) []*Change { + if !job.Splittable || len(job.Subject(c)) <= packetCap { + return []*Change{c} + } + var ( + out []*Change + group []string + ) + for _, file := range c.Files { + if !c.contributes(file) { + continue + } + if len(group) > 0 && len(job.Subject(c.part(append(slices.Clone(group), file)))) > packetCap { + out = append(out, c.part(group)) + group = nil + } + group = append(group, file) + } + if len(group) > 0 { + out = append(out, c.part(group)) + } + return out +} + +// contributes is whether a file has anything a splittable job reads. +func (c *Change) contributes(file string) bool { + for _, s := range c.Symbols { + if s.File == file { + return true + } + } + for _, t := range c.Tests { + if t.File == file { + return true + } + } + for _, comment := range c.Comments { + if comment.File == file { + return true + } + } + return false +} + +// part is the change narrowed to some of its files: the declarations, +// tests and comments in them, with everything the jobs read beside those +// — candidates, twins, the index, the message — shared. +func (c *Change) part(files []string) *Change { + keep := map[string]bool{} + for _, f := range files { + keep[f] = true + } + p := *c + p.Files = slices.Clone(files) + p.Symbols, p.Tests, p.Comments = nil, nil, nil + for _, s := range c.Symbols { + if keep[s.File] { + p.Symbols = append(p.Symbols, s) + } + } + for _, t := range c.Tests { + if keep[t.File] { + p.Tests = append(p.Tests, t) + } + } + for _, comment := range c.Comments { + if keep[comment.File] { + p.Comments = append(p.Comments, comment) + } + } + return &p +} diff --git a/job_test.go b/job_test.go @@ -395,3 +395,45 @@ func (r *relenting) Ask(ctx context.Context, system, user string) (Answer, error } return Answer{Text: r.answers[len(r.asked)-1]}, nil } + +// The tests job is pointed at a skip, and shown the functions the tests +// call, so that whether a test passes on a stub is judged against the +// function. +func TestTestsSubjectShowsTheSkipAndTheCodeUnderTest(t *testing.T) { + change := &Change{ + Tests: []Function{{Name: "TestDecode", File: "x_test.go", Line: 5, Skips: 7, Body: "func TestDecode(t *testing.T) {\n\tif !have {\n\t\tt.Skip()\n\t}\n\tgot := decode(in)\n\tcheck(t, got)\n}"}}, + index: []Declared{ + {Name: "decode", Kind: "func", File: "x.go", Line: 3, Body: "func decode(b []byte) []byte {\n\treturn b\n}"}, + {Name: "check", Kind: "func", File: "x_test.go", Line: 30, Body: "func check(t *testing.T, b []byte) {}"}, + }, + } + got := testsSubject(change) + for _, want := range []string{"(skips itself at line 7)", "Functions the tests call", "--- x.go:3 decode", "return b"} { + if !strings.Contains(got, want) { + t.Errorf("%q missing from:\n%s", want, got) + } + } + if strings.Contains(got, "x_test.go:30") { + t.Errorf("a test file's helper was shown as code under test:\n%s", got) + } +} + +// The claims job reads each comment beside the code it sits above, in the +// blocks the comments were written as, without the ones that only narrate. +func TestClaimsSubjectShowsTheCodeBelow(t *testing.T) { + change := &Change{Comments: []Located{ + {Text: "set the name", File: "x.go", Line: 3, Below: "setName(x)"}, + {Text: "Windows refuses an unordered group, so", File: "x.go", Line: 7, Below: "sort(rows)"}, + {Text: "the rows are sorted first.", File: "x.go", Line: 8, Below: "sort(rows)"}, + }} + got := claimsSubject(change) + if strings.Contains(got, "set the name") { + t.Errorf("a narrating comment reached the claims job:\n%s", got) + } + if strings.Count(got, "code: sort(rows)") != 1 { + t.Errorf("the code below a block is shown once:\n%s", got) + } + if !strings.Contains(got, "x.go:7") || !strings.Contains(got, "x.go:8") { + t.Errorf("the block is incomplete:\n%s", got) + } +} diff --git a/kinds.go b/kinds.go @@ -0,0 +1,234 @@ +package main + +// Python and Rust are read through ast-grep by node kind rather than by +// pattern: a function is whatever the grammar calls a function, wherever +// it sits, and the name is read out of the match. One frontend serves both, +// told which grammar, which kinds, and how a name and its visibility are +// read. + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// kindRule is one declaration shape: the ast-grep rule that matches it, the +// kind the tool reports it as, and the expression that reads its name out +// of the match text. +type kindRule struct { + id string + kind string + rule string + name *regexp.Regexp + test bool +} + +// KindFrontend reads one language by its grammar's node kinds. +type KindFrontend struct { + name string + language string + exts []string + rules []kindRule + // exported reads a declaration's visibility from its text. + exported func(text, name string) bool + // pkg reads the package or module a file declares, or nothing. + pkg func(path string, lines []string) string +} + +func (f *KindFrontend) Name() string { return f.name } + +func (f *KindFrontend) Covers(path string) bool { + for _, ext := range f.exts { + if strings.HasSuffix(path, ext) { + return true + } + } + return false +} + +func (*KindFrontend) Features() Features { + return FeatSymbols | FeatTests | FeatComments | FeatIndex +} + +// pythonFrontend reads Python: module-level functions, classes and +// assignments, and any function named test_. A leading underscore is the +// language's whole notion of private. +func pythonFrontend() *KindFrontend { + top := " not:\n inside:\n any:\n - kind: function_definition\n - kind: class_definition\n stopBy: end\n" + return &KindFrontend{ + name: "python", language: "Python", exts: []string{".py"}, + rules: []kindRule{ + {"py-func", "func", " kind: function_definition\n" + top, regexp.MustCompile(`^\s*(?:async\s+)?def\s+(\w+)`), false}, + {"py-class", "type", " kind: class_definition\n" + top, regexp.MustCompile(`^\s*class\s+(\w+)`), false}, + {"py-value", "value", " kind: assignment\n inside:\n kind: expression_statement\n inside:\n kind: module\n", regexp.MustCompile(`^\s*(\w+)\s*(?::[^=]*)?=`), false}, + {"py-test", "func", " kind: function_definition\n has:\n field: name\n regex: ^test_\n", regexp.MustCompile(`^\s*(?:async\s+)?def\s+(\w+)`), true}, + }, + exported: func(_, name string) bool { return !strings.HasPrefix(name, "_") }, + pkg: func(string, []string) string { return "" }, + } +} + +// rustFrontend reads Rust: functions wherever they are declared, impl +// methods included, the type items, constants and statics, and any function +// a test attribute precedes. pub is the whole notion of exported. +func rustFrontend() *KindFrontend { + return &KindFrontend{ + name: "rust", language: "Rust", exts: []string{".rs"}, + rules: []kindRule{ + {"rs-func", "func", " kind: function_item\n", regexp.MustCompile(`\bfn\s+(\w+)`), false}, + {"rs-type", "type", " any:\n - kind: struct_item\n - kind: enum_item\n - kind: type_item\n - kind: trait_item\n", regexp.MustCompile(`\b(?:struct|enum|type|trait)\s+(\w+)`), false}, + {"rs-value", "value", " any:\n - kind: const_item\n - kind: static_item\n", regexp.MustCompile(`\b(?:const|static)\s+(?:mut\s+)?(\w+)`), false}, + {"rs-test", "func", " kind: function_item\n follows:\n kind: attribute_item\n regex: '^#\\[[\\w:]*test(\\(|\\])'\n", regexp.MustCompile(`\bfn\s+(\w+)`), true}, + }, + exported: func(text, _ string) bool { return strings.HasPrefix(strings.TrimSpace(text), "pub") }, + pkg: func(string, []string) string { return "" }, + } +} + +// writeRules emits the frontend's rules for ast-grep to read. +func (f *KindFrontend) writeRules(dir string) error { + for _, r := range f.rules { + body := fmt.Sprintf("id: %s\nlanguage: %s\nseverity: info\nrule:\n%s", r.id, f.language, r.rule) + if err := os.WriteFile(filepath.Join(dir, r.id+".yml"), []byte(body), 0o644); err != nil { + return err + } + } + return nil +} + +// read maps the matches back onto declarations: one per line, the test +// rule's reading preferred where a function is both. +func (f *KindFrontend) read(matches []grepMatch) []kindMatch { + byLine := map[string]kindMatch{} + for _, m := range matches { + var rule kindRule + for _, r := range f.rules { + if r.id == m.Rule { + rule = r + } + } + if rule.name == nil { + continue + } + sub := rule.name.FindStringSubmatch(m.Text) + if sub == nil { + continue + } + key := fmt.Sprintf("%s:%d", m.File, m.Range.Start.Line) + if old, ok := byLine[key]; ok && old.Test && !rule.test { + continue + } + byLine[key] = kindMatch{File: m.File, Line: m.Range.Start.Line + 1, Name: sub[1], Kind: rule.kind, Test: rule.test, Text: m.Text} + } + out := make([]kindMatch, 0, len(byLine)) + for _, m := range byLine { + out = append(out, m) + } + return out +} + +// kindMatch is one declaration as read out of a match. +type kindMatch struct { + File, Name, Kind, Text string + Line int + Test bool +} + +// Change appends the declarations, tests and prose the added lines of the +// change's files in this language introduce. +func (f *KindFrontend) Change(root, rev string, c *Change, added map[string][]int) error { + var covered []string + for _, name := range c.Files { + if f.Covers(name) { + covered = append(covered, name) + } + } + matches, err := scanWith(covered, root, rev, f.writeRules) + if err != nil { + return err + } + sources := map[string][]string{} + touched := map[string]map[int]bool{} + for _, name := range covered { + source, err := at(root, rev, name) + if err != nil { + continue + } + sources[name] = strings.Split(string(source), "\n") + touched[name] = map[int]bool{} + for _, line := range added[name] { + touched[name][line] = true + } + c.Comments = append(c.Comments, commentProse(source, name, added[name])...) + } + for _, m := range f.read(matches) { + lines := sources[m.File] + if m.Test { + // A test is judged by what it asserts, so a change anywhere + // inside one is a change to the test. + end := m.Line + strings.Count(m.Text, "\n") + if !touchedBetween(added[m.File], m.Line, end) { + continue + } + c.Tests = append(c.Tests, Function{Name: m.Name, File: m.File, Line: m.Line, Body: m.Text}) + continue + } + if !touched[m.File][m.Line] { + continue + } + signature := "" + if m.Line-1 < len(lines) { + signature = strings.TrimSpace(lines[m.Line-1]) + } + symbol := Symbol{ + Name: m.Name, Kind: m.Kind, Doc: docAbove(lines, m.Line), + File: m.File, Line: m.Line, + Exported: f.exported(m.Text, m.Name), Signature: signature, + Package: f.pkg(m.File, lines), + } + if m.Kind == "func" { + symbol.Body = m.Text + } + c.Symbols = append(c.Symbols, symbol) + } + return nil +} + +// Whole reads every declaration in the repository's files in this +// language, tests left out, so a new name can be checked against the ones +// it may duplicate. +func (f *KindFrontend) Whole(root, rev string) ([]Declared, error) { + tree, err := treeAt(root, rev) + if err != nil { + return nil, err + } + tracked, err := tree.Files() + if err != nil { + return nil, err + } + var files []string + for _, name := range tracked { + if f.Covers(name) { + files = append(files, name) + } + } + matches, err := scanWith(files, root, rev, f.writeRules) + if err != nil { + return nil, err + } + var index []Declared + for _, m := range f.read(matches) { + if m.Test { + continue + } + text := strings.SplitN(m.Text, "\n", 2)[0] + declared := Declared{Name: m.Name, Kind: m.Kind, File: m.File, Line: m.Line, Text: text} + if m.Kind == "func" { + declared.Body = m.Text + } + index = append(index, declared) + } + return index, nil +} diff --git a/kinds_test.go b/kinds_test.go @@ -0,0 +1,179 @@ +package main + +import ( + "strings" + "testing" +) + +func TestPythonReadsDeclarations(t *testing.T) { + needAstGrep(t) + r := newRepo(t) + r.write("src/.keep", "") + r.commit("py: begin", "src/.keep") + r.write("src/icons.py", `"""Icons.""" +MAX_ICONS = 12 +_hidden: int = 4 + +# Reads the icons a file holds. +def read_icons(src): + inner = 1 + def nested(): + return inner + return [] + +class Reader: + size = 0 + def method(self): + return 1 +`) + r.write("tests/test_icons.py", `from src.icons import read_icons + +def test_reads_icons(): + assert read_icons("x") == [] + +def test_smoke(): + read_icons("y") +`) + rev := r.commit("py: first", "src/icons.py", "tests/test_icons.py") + + change, err := Gather(rev+"^.."+rev, r.Root) + if err != nil { + t.Fatal(err) + } + got := map[string]Symbol{} + for _, s := range change.Symbols { + got[s.Name] = s + } + for name, want := range map[string]struct { + kind string + exported bool + }{ + "MAX_ICONS": {"value", true}, + "_hidden": {"value", false}, + "read_icons": {"func", true}, + "Reader": {"type", true}, + } { + s, ok := got[name] + if !ok { + t.Errorf("%s not read", name) + continue + } + if s.Kind != want.kind || s.Exported != want.exported { + t.Errorf("%s: got %s exported=%v, want %s exported=%v", name, s.Kind, s.Exported, want.kind, want.exported) + } + } + for _, inner := range []string{"inner", "nested", "size", "method", "test_reads_icons"} { + if _, ok := got[inner]; ok { + t.Errorf("%s is not a module-level declaration", inner) + } + } + if got["read_icons"].Doc != "Reads the icons a file holds." || !strings.Contains(got["read_icons"].Body, "return []") { + t.Errorf("got %+v", got["read_icons"]) + } + names := map[string]bool{} + for _, test := range change.Tests { + names[test.Name] = true + } + if len(change.Tests) != 2 || !names["test_reads_icons"] || !names["test_smoke"] { + t.Errorf("tests: %v", change.Tests) + } + if len(change.Uncovered) != 0 { + t.Errorf("uncovered: %v", change.Uncovered) + } + // The assertion check reads Python's shapes. + got2 := checkTestAssertions(change) + if len(got2) != 1 || got2[0].Symbol != "test_smoke" { + t.Errorf("assertions: %v", got2) + } +} + +func TestRustReadsDeclarations(t *testing.T) { + needAstGrep(t) + r := newRepo(t) + r.write("src/.keep", "") + r.commit("rs: begin", "src/.keep") + r.write("src/icons.rs", `pub const MAX_ICONS: usize = 12; +static HIDDEN: i32 = 4; +/// A reader of icons. +pub struct Reader { size: usize } +enum Kind { Small, Large } +pub type Group = Vec<u8>; +pub fn read_icons(src: &str) -> Vec<u8> { Vec::new() } +fn helper() -> i32 { 3 } +impl Reader { + pub fn new() -> Self { Reader { size: 0 } } +} +#[cfg(test)] +mod tests { + #[test] + fn reads_icons() { assert_eq!(super::read_icons("x").len(), 0); } + #[tokio::test] + async fn smoke() { super::read_icons("y"); } +} +`) + rev := r.commit("rs: first", "src/icons.rs") + + change, err := Gather(rev+"^.."+rev, r.Root) + if err != nil { + t.Fatal(err) + } + got := map[string]Symbol{} + for _, s := range change.Symbols { + got[s.Name] = s + } + for name, want := range map[string]struct { + kind string + exported bool + }{ + "MAX_ICONS": {"value", true}, + "HIDDEN": {"value", false}, + "Reader": {"type", true}, + "Kind": {"type", false}, + "Group": {"type", true}, + "read_icons": {"func", true}, + "helper": {"func", false}, + "new": {"func", true}, + } { + s, ok := got[name] + if !ok { + t.Errorf("%s not read", name) + continue + } + if s.Kind != want.kind || s.Exported != want.exported { + t.Errorf("%s: got %s exported=%v, want %s exported=%v", name, s.Kind, s.Exported, want.kind, want.exported) + } + } + if got["Reader"].Doc != "A reader of icons." { + t.Errorf("doc: %q", got["Reader"].Doc) + } + names := map[string]bool{} + for _, test := range change.Tests { + names[test.Name] = true + } + if len(change.Tests) != 2 || !names["reads_icons"] || !names["smoke"] { + t.Errorf("tests: %v", change.Tests) + } + if _, ok := got["reads_icons"]; ok { + t.Error("a test was read as a declaration") + } + got2 := checkTestAssertions(change) + if len(got2) != 1 || got2[0].Symbol != "smoke" { + t.Errorf("assertions: %v", got2) + } +} + +// A deleted Python or Rust test is read by its own shape. +func TestDeletedTestsInPythonAndRust(t *testing.T) { + diff := "--- a/tests/test_icons.py\n+++ b/tests/test_icons.py\n@@ -1,3 +1,1 @@\n-def test_reads_icons():\n- assert True\n-\n" + + "--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1,4 +1,2 @@\n-#[test]\n-fn reads_icons() {}\n-fn helper() {}\n-\n" + got := checkDeletedTests(&Change{Diff: diff}) + if len(got) != 2 { + t.Fatalf("got %v", got) + } + if got[0].File != "src/lib.rs" || !strings.Contains(got[0].Message, `"reads_icons"`) || strings.Contains(got[0].Message, "helper") { + t.Errorf("rust: %v", got[0]) + } + if got[1].File != "tests/test_icons.py" || !strings.Contains(got[1].Message, `"test_reads_icons"`) { + t.Errorf("python: %v", got[1]) + } +} diff --git a/leftovers.go b/leftovers.go @@ -0,0 +1,207 @@ +package main + +// What a change leaves behind by accident has a shape: a debugger statement, +// a task marker nobody is named on, code kept as a comment, an error caught +// and dropped. Each is a pattern over the lines the change adds, and none +// needs a reading to name. + +import ( + "fmt" + "maps" + "regexp" + "slices" + "strings" +) + +// debugMarkers are the calls and statements that exist to be removed before +// a change is done, by language. Ordinary printing is not here: a command's +// output and a debug print share a function, and only the author knows +// which is which. +var debugMarkers = []struct { + suffixes []string + pattern *regexp.Regexp + severity Severity +}{ + {[]string{".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"}, regexp.MustCompile(`^\s*debugger\s*;?\s*$`), Consider}, + {[]string{".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"}, regexp.MustCompile(`\bconsole\.(log|debug|trace)\(`), Note}, + {[]string{".py"}, regexp.MustCompile(`\b(breakpoint\(\)|pdb\.set_trace\(\)|ipdb\.set_trace\(\))`), Consider}, + {[]string{".rs"}, regexp.MustCompile(`\bdbg!\(`), Consider}, + {[]string{".go"}, regexp.MustCompile(`\b(spew\.Dump|litter\.Dump|pp\.Print)\(`), Consider}, + {[]string{".rb"}, regexp.MustCompile(`\b(binding\.pry|byebug|debugger)\b`), Consider}, + {nil, regexp.MustCompile(`\b(Printf|Println|Print|log|print|debug)\(\s*["'](DEBUG|XXX|HERE|>>>)`), Consider}, +} + +// checkDebugLeftovers reports the debugging a change adds and did not +// remove. +func checkDebugLeftovers(c *Change) []Finding { + added, _ := diffSides(c.Diff) + var out []Finding + for _, file := range slices.Sorted(maps.Keys(added)) { + if !isCodeFile(file) { + continue + } + for _, l := range added[file] { + for _, m := range debugMarkers { + if m.suffixes != nil && !hasSuffix(file, m.suffixes) { + continue + } + if !m.pattern.MatchString(l.Text) { + continue + } + out = append(out, Finding{ + Job: "static", Rule: "debug-leftover", Severity: m.severity, + File: file, Line: l.Line, + Message: fmt.Sprintf("the change adds debugging output: %s", strings.TrimSpace(l.Text)), + Fix: "remove it before the change is done", + }) + break + } + } + } + return out +} + +func hasSuffix(path string, suffixes []string) bool { + for _, s := range suffixes { + if strings.HasSuffix(path, s) { + return true + } + } + return false +} + +var ( + // taskMarker is a comment that names work left undone. + taskMarker = regexp.MustCompile(`\b(TODO|FIXME|XXX|HACK)\b`) + // taskReference is what makes a task marker answerable: an issue + // number, a ticket key, a link, or a name in parentheses. + taskReference = regexp.MustCompile(`#\d+|\b[A-Z][A-Z0-9]+-\d+\b|https?://|\(\w+\)`) +) + +// checkTodos reports a task marker the change adds with nothing to find it +// by again: no issue, no ticket, no name. Unreferenced, it is a promise the +// log will not keep. +func checkTodos(c *Change) []Finding { + var out []Finding + for _, comment := range c.Comments { + if !taskMarker.MatchString(comment.Text) || taskReference.MatchString(comment.Text) { + continue + } + out = append(out, Finding{ + Job: "static", Rule: "todo-without-reference", Severity: Note, + File: comment.File, Line: comment.Line, + Message: fmt.Sprintf("the change adds a task marker nothing refers to: %q", firstLine(comment.Text)), + Fix: "name the issue or the person, or do the work now", + }) + } + return out +} + +var ( + // strongCode is a comment line that is code beyond doubt: an + // assignment operator, a call closed and terminated, a closing brace + // terminated, an arrow function. + strongCode = regexp.MustCompile(`:=|\);\s*$|\};\s*$|=>|^\s*\}\s*else\s*\{`) + // weakCode is a comment line shaped like a statement, which two in a + // row make into commented-out code. + weakCode = regexp.MustCompile(`^(if|for|while|return|func|fn|def|import|const|let|var|switch|case|else|try|catch|package|use|pub|proc|struct|type|class|await|export)\b.*[({=:;]\s*$|^[\w.]+\(.*\)\s*;?\s*$|[;{}]\s*$`) +) + +// codeLike is whether a comment line reads as code, and how surely. +func codeLike(text string) (strong, weak bool) { + trimmed := strings.TrimSpace(text) + if trimmed == "" || directive(trimmed) { + return false, false + } + return strongCode.MatchString(trimmed), weakCode.MatchString(trimmed) +} + +// checkCommentedCode reports code the change keeps as comments. A run of +// consecutive comment lines is one candidate; it is reported when two of +// its lines are shaped like statements, or one is code beyond doubt. +func checkCommentedCode(c *Change) []Finding { + var out []Finding + byFile := map[string][]Located{} + for _, comment := range c.Comments { + byFile[comment.File] = append(byFile[comment.File], comment) + } + for _, file := range slices.Sorted(maps.Keys(byFile)) { + comments := byFile[file] + slices.SortFunc(comments, func(a, b Located) int { return a.Line - b.Line }) + for i := 0; i < len(comments); { + j := i + strong, weak := 0, 0 + for j < len(comments) && (j == i || comments[j].Line == comments[j-1].Line+1) { + s, w := codeLike(comments[j].Text) + if s { + strong++ + } + if w || s { + weak++ + } + j++ + } + if strong > 0 || weak >= 2 { + out = append(out, Finding{ + Job: "static", Rule: "commented-out-code", Severity: Consider, + File: file, Line: comments[i].Line, + Message: fmt.Sprintf("the change adds code as a comment, %d line(s) from %s:%d; the version control has the old code, and a reader cannot tell a comment that was code from one that is meant", j-i, file, comments[i].Line), + Fix: "delete it; git remembers it", + }) + } + i = j + } + } + return out +} + +var ( + goDroppedError = regexp.MustCompile(`^\s*_\s*=\s*err\b`) + emptyCatch = regexp.MustCompile(`\bcatch\s*(\([^)]*\))?\s*\{\s*\}`) + openCatch = regexp.MustCompile(`\bcatch\s*(\([^)]*\))?\s*\{\s*$`) + promiseCatch = regexp.MustCompile(`\.catch\(\s*(\(\s*\w*\s*\)|\w+)?\s*=>\s*\{\s*\}\s*\)`) + exceptPass = regexp.MustCompile(`^\s*except\b[^:]*:\s*pass\s*$`) + exceptOpen = regexp.MustCompile(`^\s*except\b[^:]*:\s*$`) + closingBrace = regexp.MustCompile(`^\s*\}`) + passLine = regexp.MustCompile(`^\s*pass\s*$`) +) + +// checkSwallowedErrors reports an error the change catches and drops: a Go +// error assigned to the blank identifier, an empty catch in JavaScript or +// TypeScript, an except that passes in Python. +func checkSwallowedErrors(c *Change) []Finding { + added, _ := diffSides(c.Diff) + var out []Finding + for _, file := range slices.Sorted(maps.Keys(added)) { + if !isCodeFile(file) { + continue + } + lines := added[file] + for i, l := range lines { + next := "" + if i+1 < len(lines) && lines[i+1].Line == l.Line+1 { + next = lines[i+1].Text + } + hit := false + switch { + case strings.HasSuffix(file, ".go"): + hit = goDroppedError.MatchString(l.Text) + case grammarOf(file) != "": + hit = emptyCatch.MatchString(l.Text) || promiseCatch.MatchString(l.Text) || + (openCatch.MatchString(l.Text) && closingBrace.MatchString(next)) + case strings.HasSuffix(file, ".py"): + hit = exceptPass.MatchString(l.Text) || (exceptOpen.MatchString(l.Text) && passLine.MatchString(next)) + } + if !hit { + continue + } + out = append(out, Finding{ + Job: "static", Rule: "error-swallowed", Severity: Consider, + File: file, Line: l.Line, + Message: fmt.Sprintf("the change catches an error and drops it: %s; a dropped error is a failure the program has decided not to know about", strings.TrimSpace(l.Text)), + Fix: "handle it, return it, or write beside it why it cannot matter", + }) + } + } + return out +} diff --git a/leftovers_test.go b/leftovers_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "strings" + "testing" +) + +// added renders a diff adding the lines to one file, in the shape Gather +// produces, so the line-shaped checks can be fed one file at a time. +func added(file string, lines ...string) string { + var b strings.Builder + b.WriteString("--- /dev/null\n+++ b/" + file + "\n@@ -0,0 +1," + itoa(len(lines)) + " @@\n") + for _, l := range lines { + b.WriteString("+" + l + "\n") + } + return b.String() +} + +func TestCheckDebugLeftovers(t *testing.T) { + for _, test := range []struct { + file, line string + severity Severity + fires bool + }{ + {"a.ts", " debugger;", Consider, true}, + {"a.tsx", " console.log(x)", Note, true}, + {"a.py", "breakpoint()", Consider, true}, + {"a.rs", "let y = dbg!(x);", Consider, true}, + {"a.go", "spew.Dump(x)", Consider, true}, + {"a.go", `fmt.Println("DEBUG", x)`, Consider, true}, + {"a.go", `fmt.Println("done")`, 0, false}, + {"a.go", "debugger := newDebugger()", 0, false}, + {"a.md", " debugger;", 0, false}, + } { + got := checkDebugLeftovers(&Change{Diff: added(test.file, test.line)}) + if (len(got) == 1) != test.fires { + t.Errorf("%s %q: got %v", test.file, test.line, got) + continue + } + if test.fires && got[0].Severity != test.severity { + t.Errorf("%s %q: severity %s", test.file, test.line, got[0].Severity) + } + } +} + +func TestCheckTodos(t *testing.T) { + c := &Change{Comments: []Located{ + {Text: "TODO handle the empty case", File: "a.go", Line: 1}, + {Text: "TODO(jack) handle the empty case", File: "a.go", Line: 2}, + {Text: "FIXME see #42", File: "a.go", Line: 3}, + {Text: "HACK until PROJ-12 lands", File: "a.go", Line: 4}, + {Text: "the todo list is rendered here", File: "a.go", Line: 5}, + }} + got := checkTodos(c) + if len(got) != 1 || got[0].Line != 1 || got[0].Rule != "todo-without-reference" { + t.Errorf("got %v", got) + } +} + +func TestCheckCommentedCode(t *testing.T) { + c := &Change{Comments: []Located{ + // One line beyond doubt. + {Text: "x := parse(input);", File: "a.go", Line: 1}, + // Prose, with a bracket in it. + {Text: "returns the name (see below)", File: "a.go", Line: 5}, + // Two statement-shaped lines in a row. + {Text: "if err != nil {", File: "a.go", Line: 10}, + {Text: "return err", File: "a.go", Line: 11}, + {Text: "}", File: "a.go", Line: 12}, + // One statement-shaped line alone is not enough. + {Text: "for the record:", File: "a.go", Line: 20}, + // A directive is not code. + {Text: "go:generate stringer -type=Kind", File: "a.go", Line: 30}, + }} + got := checkCommentedCode(c) + lines := []int{} + for _, f := range got { + lines = append(lines, f.Line) + } + if len(got) != 2 || lines[0] != 1 || lines[1] != 10 { + t.Errorf("got %v", got) + } +} + +func TestCheckSwallowedErrors(t *testing.T) { + for _, test := range []struct { + file string + lines []string + fires bool + }{ + {"a.go", []string{"_ = err"}, true}, + {"a.go", []string{"_, err := f()", "if err != nil {", "\treturn err", "}"}, false}, + {"a.ts", []string{"try { f() } catch (e) {}"}, true}, + {"a.ts", []string{"} catch (e) {", "}"}, true}, + {"a.ts", []string{"} catch (e) {", " log(e)", "}"}, false}, + {"a.js", []string{"p.catch(() => {})"}, true}, + {"a.py", []string{"except ValueError:", " pass"}, true}, + {"a.py", []string{"except ValueError: pass"}, true}, + {"a.py", []string{"except ValueError:", " raise"}, false}, + } { + got := checkSwallowedErrors(&Change{Diff: added(test.file, test.lines...)}) + if (len(got) == 1) != test.fires { + t.Errorf("%s %q: got %v", test.file, test.lines, got) + } + } +} diff --git a/loop_test.go b/loop_test.go @@ -0,0 +1,176 @@ +package main + +// The loop an agent runs is: review, fix, review again. What it needs from +// the second run is which findings went away, which stayed, and which are +// new; what it needs from every finding is enough to act without opening +// the file. + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCompareNamesFindingsAgainstABaseline(t *testing.T) { + path := filepath.Join(t.TempDir(), "before.json") + before := `{"findings":[{"id":"aaa"},{"id":"bbb"}],"retracted":[{"finding":{"id":"zzz"}}]}` + if err := os.WriteFile(path, []byte(before), 0o644); err != nil { + t.Fatal(err) + } + got, err := compare(path, []Finding{{ID: "bbb"}, {ID: "ccc"}}) + if err != nil { + t.Fatal(err) + } + if strings.Join(got.Resolved, ",") != "aaa" || strings.Join(got.Persisting, ",") != "bbb" || strings.Join(got.New, ",") != "ccc" { + t.Errorf("got %+v", got) + } + // A retracted finding in the baseline was never one to fix. + for _, id := range got.Resolved { + if id == "zzz" { + t.Error("a retracted finding counted as resolved") + } + } + if _, err := compare(filepath.Join(t.TempDir(), "nowhere.json"), nil); err == nil { + t.Error("a missing baseline was read") + } + bad := filepath.Join(t.TempDir(), "bad.json") + os.WriteFile(bad, []byte("not json"), 0o644) + if _, err := compare(bad, nil); err == nil { + t.Error("a baseline that is not a report was read") + } +} + +func TestReportCarriesTheBaseline(t *testing.T) { + out := capture(t, func() { + if err := report(contract{Version: contractVersion, Status: "complete", + Baseline: &Baseline{From: "before.json", Resolved: []string{"aaa"}, Persisting: []string{}, New: []string{}}}); err != nil { + t.Error(err) + } + }) + var env struct { + Baseline struct { + From string `json:"from"` + Resolved []string `json:"resolved"` + New []string `json:"new"` + } `json:"baseline"` + } + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Fatal(err) + } + if env.Baseline.From != "before.json" || len(env.Baseline.Resolved) != 1 || env.Baseline.New == nil { + t.Errorf("got %+v", env.Baseline) + } + // Without a baseline the key is absent rather than null. + plain := capture(t, func() { report(contract{Version: contractVersion, Status: "complete"}) }) + if strings.Contains(plain, "baseline") { + t.Errorf("a report without a baseline mentions one:\n%s", plain) + } +} + +func TestRenderSaysHowFindingsStandAgainstTheBaseline(t *testing.T) { + out := capture(t, func() { + render(contract{Baseline: &Baseline{From: "before.json", Resolved: []string{"a"}, Persisting: []string{"b", "c"}}}, nil, false) + }) + if !strings.Contains(out, "against before.json: 1 resolved, 2 persisting, 0 new") || !strings.Contains(out, "persisting: b, c") { + t.Errorf("got %q", out) + } +} + +func TestTreeLine(t *testing.T) { + r := newRepo(t) + r.write("x.go", "package x\n\n var a = 1 \n") + r.commit("first", "x.go") + tree, err := treeAt(r.Root, "") + if err != nil { + t.Fatal(err) + } + if got := tree.Line("x.go", 3); got != "var a = 1" { + t.Errorf("got %q", got) + } + if tree.Line("x.go", 9) != "" || tree.Line("nowhere.go", 1) != "" || tree.Line("x.go", 0) != "" { + t.Error("a line that is not there was read") + } +} + +// A note is never gated on, so the second reading is not spent on it: the +// finding stands, unverified, and only the other severities are asked about. +func TestVerifySkipsNotes(t *testing.T) { + provider := &relenting{answers: []string{ + `{"findings":[{"rule":"cannot-fail","severity":"note","file":"x_test.go","line":4,"message":"m","fix":"f"},` + + `{"rule":"cannot-fail","severity":"must-fix","file":"x_test.go","line":5,"message":"n","fix":"f"}]}`, + verdictJSON(`{"index":0,"holds":true,"reason":""}`), + }} + jobs, err := chosen("tests") + if err != nil { + t.Fatal(err) + } + result := Reviewer{Provider: provider, Verify: true}.Run(context.Background(), verifyChange(), jobs) + if len(result.Findings) != 2 || len(result.Failures) != 0 { + t.Fatalf("got %+v", result) + } + if !strings.Contains(provider.asked[1], "0. [cannot-fail] must-fix") || strings.Contains(provider.asked[1], "1. [cannot-fail]") { + t.Errorf("the verdict was asked about the note:\n%s", provider.asked[1]) + } + for _, f := range result.Findings { + if (f.Severity == Note) == f.Verified { + t.Errorf("verified %v for %s", f.Verified, f.Severity) + } + } +} + +// A subject over the cap is asked in parts, file by file, each part its +// own ask and its own verdict against its own evidence. +func TestOversizedSubjectIsAskedInParts(t *testing.T) { + change := &Change{Files: []string{"a_test.go", "b_test.go", "c_test.go"}} + for _, file := range change.Files { + change.Tests = append(change.Tests, Function{ + Name: "Test" + strings.ToUpper(file[:1]), File: file, Line: 3, + Body: "func Test(t *testing.T) {\n" + strings.Repeat("\tt.Log(\"padding padding padding padding\")\n", packetCap/80) + "}", + }) + } + job, _ := chosen("tests") + pieces := parts(job[0], change) + if len(pieces) != 3 { + t.Fatalf("cut into %d parts, want one per file", len(pieces)) + } + for i, piece := range pieces { + if len(piece.Tests) != 1 || piece.Tests[0].File != change.Files[i] { + t.Errorf("part %d holds %v", i, piece.Tests) + } + } + // One finding from the second part; the verdict is asked against that + // part alone. + provider := &relenting{answers: []string{ + `{"findings":[]}`, + findingsJSON("cannot-fail"), + `{"findings":[]}`, + verdictJSON(`{"index":0,"holds":true,"reason":""}`), + }} + t.Setenv("REVIEW_SERIAL", "1") + result := Reviewer{Provider: provider, Verify: true}.Run(context.Background(), change, job) + if len(result.Findings) != 1 || !result.Findings[0].Verified || len(result.Failures) != 0 { + t.Fatalf("got %+v", result) + } + if len(provider.asked) != 4 { + t.Fatalf("asked %d times, want three parts and one verdict", len(provider.asked)) + } + if !strings.Contains(provider.asked[3], "b_test.go") || strings.Contains(provider.asked[3], "a_test.go") { + t.Errorf("the verdict was not asked against the part the finding came from:\n%s", first(provider.asked[3], 300)) + } +} + +func TestSmallSubjectIsNotSplit(t *testing.T) { + change := verifyChange() + change.Files = []string{"x_test.go"} + job, _ := chosen("tests") + if pieces := parts(job[0], change); len(pieces) != 1 || pieces[0] != change { + t.Errorf("a small subject was cut: %d parts", len(pieces)) + } + hygiene, _ := chosen("hygiene") + if pieces := parts(hygiene[0], &Change{Message: strings.Repeat("word ", packetCap)}); len(pieces) != 1 { + t.Error("an unsplittable job was cut") + } +} diff --git a/main.go b/main.go @@ -14,8 +14,9 @@ // replays it rather than asking again. // // review the staged change -// review HEAD^ the last commit +// review HEAD^..HEAD the last commit // review --json for a program rather than a person +// review rules <id> what a finding was judged against // // A finding is dismissed where it is wrong, in the source it concerns: // @@ -25,32 +26,50 @@ package main import ( "context" "encoding/json" + "errors" "flag" "fmt" "os" "os/exec" + "slices" "strings" ) func main() { - if err := run(); err != nil { + err := run() + if errors.Is(err, errMustFix) { + // The report has already said which finding; the exit code is the + // whole of what a hook reads. + os.Exit(1) + } + if err != nil { fmt.Fprintln(os.Stderr, "review:", err) os.Exit(1) } } +// errMustFix is the refusal --exit-code asks for. It is returned rather than +// exited on, so that the deferred cleanup and the cache's save still run. +var errMustFix = errors.New("a must-fix finding stands") + func run() error { var ( - asJSON bool - verbose bool - show bool - only string - which string - model string - noVerify bool - fresh bool + asJSON bool + verbose bool + show bool + only string + which string + model string + noVerify bool + fresh bool + messageFile string + exitCode bool + baseline string ) flag.BoolVar(&asJSON, "json", false, "Report findings as JSON, for an agent rather than a person.") + flag.StringVar(&messageFile, "message-file", "", "Read the commit message from this file, as a commit-msg hook is given it; a staged change has no message otherwise.") + flag.BoolVar(&exitCode, "exit-code", false, "Exit 1 when a must-fix finding stands, so a hook can refuse the change.") + flag.StringVar(&baseline, "baseline", "", "A previous --json report; each finding is then reported as new, persisting or resolved against it.") flag.BoolVar(&verbose, "verbose", false, "Show what each job read and what it cost.") flag.BoolVar(&show, "show", false, "Print what each job would be sent and stop, without asking anything.") flag.BoolVar(&noVerify, "no-verify", false, "Skip the second reading that checks what each job reported.") @@ -60,6 +79,18 @@ func run() error { flag.StringVar(&model, "model", os.Getenv("REVIEW_MODEL"), "Which model, in whatever form the provider names them.") flag.Usage = usage flag.Parse() + defer closeTrees() + + switch flag.Arg(0) { + case "rules": + return printRules(flag.Args()[1:]) + case "bench": + return bench(flag.Args()[1:]) + case "hook": + return hook(flag.Args()[1:]) + case "agent": + return agent() + } root, err := repository() if err != nil { @@ -69,6 +100,11 @@ func run() error { if err != nil { return err } + if messageFile != "" { + if change.Message, err = readMessage(messageFile); err != nil { + return err + } + } if strings.TrimSpace(change.Diff) == "" { if asJSON { return report(contract{Version: contractVersion, Status: "empty"}) @@ -81,6 +117,16 @@ func run() error { if err != nil { return err } + // The deterministic checks ask nothing of a provider, so they run before + // one is built and survive a model that cannot answer. + static := runChecks(change) + static = append(static, checkAnalysers(root, flag.Arg(0), change, Analysers()...)...) + for i := range static { + // The deterministic checks are their own verification: what they + // report was measured, not read once. + static[i].Verified = true + } + if show { for _, job := range jobs { subject := job.Subject(change) @@ -91,6 +137,17 @@ func run() error { } fmt.Printf("%s\n", subject) } + // What the deterministic side found costs nothing to show, and it + // is what a reader of the packets is about to act on anyway. + fmt.Println("=== static ===") + kept, _ := filter(root, static) + Sort(kept) + if len(kept) == 0 { + fmt.Println("(no findings)") + } + for _, f := range kept { + fmt.Printf(" %s\n", indent(f.String())) + } return nil } if verbose { @@ -98,11 +155,6 @@ func run() error { len(change.Files), len(change.Symbols), len(change.Tests), len(change.Comments)) } - // The deterministic checks ask nothing of a provider, so they run before - // one is built and survive a model that cannot answer. - static := runChecks(change) - static = append(static, checkStaticcheck(root, flag.Arg(0), change)...) - var provider Provider var providerName string if which == "chain" { @@ -137,14 +189,24 @@ func run() error { reviewer := Reviewer{Provider: provider, Verbose: verbose, Verify: !noVerify, Cache: cache} result := reviewer.Run(context.Background(), change, jobs) - // The deterministic checks are their own verification: what they report - // was measured, not read once. - for i := range static { - static[i].Verified = true - } findings := append(result.Findings, static...) kept, dismissed := filter(root, findings) Sort(kept) + nameFindings(kept) + if tree, treeErr := treeAt(root, flag.Arg(0)); treeErr == nil { + for i := range kept { + if kept[i].File != "" && kept[i].Line > 0 { + kept[i].Snippet = tree.Line(kept[i].File, kept[i].Line) + } + } + } + var against *Baseline + if baseline != "" { + against, err = compare(baseline, kept) + if err != nil { + return err + } + } env := contract{ Version: contractVersion, @@ -157,18 +219,56 @@ func run() error { Uncovered: change.Uncovered, Dismissed: dismissed, Truncated: change.Truncated, + Baseline: against, Usage: metered{ In: result.In, Out: result.Out, Cached: result.Cached, Replayed: result.Replayed, Cost: result.Cost, }, } if asJSON { - return report(env) + if err := report(env); err != nil { + return err + } + } else { + render(env, result.Failures, verbose) + } + // The review is advisory unless asked to gate: then a must-fix finding + // that stands is the one thing it refuses, and the report above says + // which. + if exitCode && mustFix(kept) { + return errMustFix } - render(env, result.Failures, verbose) return nil } +// mustFix is whether any finding is one the review would refuse on. +func mustFix(findings []Finding) bool { + for _, f := range findings { + if f.Severity == MustFix { + return true + } + } + return false +} + +// readMessage reads a commit message the way git will: the lines its +// comment character opens are the template's, not the author's, and a +// message file a hook is given is full of them. +func readMessage(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("reading the message: %w", err) + } + var kept []string + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "#") { + continue + } + kept = append(kept, line) + } + return strings.TrimSpace(strings.Join(kept, "\n")), nil +} + // contractVersion is the shape of the JSON report, so that a program // reading it can tell when the shape moves under it. const contractVersion = 1 @@ -207,7 +307,61 @@ type contract struct { Uncovered []Gap `json:"uncovered"` Dismissed []Dismissed `json:"dismissed"` Truncated bool `json:"truncated,omitempty"` - Usage metered `json:"usage"` + // Baseline is how this run's findings stand against a previous run's, + // when one was given: the answer to "did the fix take". + Baseline *Baseline `json:"baseline,omitempty"` + Usage metered `json:"usage"` +} + +// Baseline is this run's findings named against a previous report's, by +// id. A loop reads resolved to know its fixes took, persisting to know what +// is left, and new to know what the fixes cost. +type Baseline struct { + From string `json:"from"` + Resolved []string `json:"resolved"` + Persisting []string `json:"persisting"` + New []string `json:"new"` +} + +// compare reads the findings of a previous report and names this run's +// against them. Only the report's standing findings count: a finding it +// retracted or dismissed was not one to fix. +func compare(path string, now []Finding) (*Baseline, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading the baseline: %w", err) + } + var previous struct { + Findings []struct { + ID string `json:"id"` + } `json:"findings"` + } + if err := json.Unmarshal(data, &previous); err != nil { + return nil, fmt.Errorf("the baseline is not a review report: %w", err) + } + before := map[string]bool{} + for _, f := range previous.Findings { + if f.ID != "" { + before[f.ID] = true + } + } + b := &Baseline{From: path, Resolved: []string{}, Persisting: []string{}, New: []string{}} + seen := map[string]bool{} + for _, f := range now { + seen[f.ID] = true + if before[f.ID] { + b.Persisting = append(b.Persisting, f.ID) + } else { + b.New = append(b.New, f.ID) + } + } + for id := range before { + if !seen[id] { + b.Resolved = append(b.Resolved, id) + } + } + slices.Sort(b.Resolved) + return b, nil } // statusOf names how complete the measurement was. The parts of the change @@ -281,6 +435,7 @@ func render(env contract, failures []error, verbose bool) { } fmt.Println() retractedNote(env) + baselineNote(env) return } var severity Severity = -1 @@ -297,12 +452,26 @@ func render(env contract, failures []error, verbose bool) { } fmt.Println() retractedNote(env) + baselineNote(env) if verbose { fmt.Println("\nDismiss a finding where it is wrong, in the source it concerns:") fmt.Println(" //review:ignore <rule> <why>") } } +// baselineNote says how the findings stand against the previous report, +// where one was given. +func baselineNote(env contract) { + if env.Baseline == nil { + return + } + b := env.Baseline + fmt.Printf("against %s: %d resolved, %d persisting, %d new\n", b.From, len(b.Resolved), len(b.Persisting), len(b.New)) + if len(b.Persisting) > 0 { + fmt.Printf(" persisting: %s\n", strings.Join(b.Persisting, ", ")) + } +} + // retractedNote says how many findings did not survive the second reading, // because a retraction that went unmentioned would read as the first // reading having been right all along. @@ -344,9 +513,21 @@ func report(env contract) error { env.Dismissed = []Dismissed{} } nameFindings(env.Findings) + retracted := make([]Finding, len(env.Retracted)) for i := range env.Retracted { - env.Retracted[i].Finding.SeverityName = env.Retracted[i].Finding.Severity.String() - identify(&env.Retracted[i].Finding) + retracted[i] = env.Retracted[i].Finding + } + nameFindings(retracted) + for i := range env.Retracted { + env.Retracted[i].Finding = retracted[i] + } + dismissed := make([]Finding, len(env.Dismissed)) + for i := range env.Dismissed { + dismissed[i] = env.Dismissed[i].Finding + } + nameFindings(dismissed) + for i := range env.Dismissed { + env.Dismissed[i].Finding = dismissed[i] } out, err := json.MarshalIndent(env, "", " ") if err != nil { @@ -357,11 +538,18 @@ func report(env contract) error { } // nameFindings fills in what a finding carries only in the report: its -// severity as text, and the id that names it across runs. +// severity as text, and the id that names it across runs. Two findings +// that hash the same — one rule, one file, two comments — are told apart +// by a counter, in the order they are listed. func nameFindings(findings []Finding) { + seen := map[string]int{} for i := range findings { findings[i].SeverityName = findings[i].Severity.String() identify(&findings[i]) + seen[findings[i].ID]++ + if n := seen[findings[i].ID]; n > 1 { + findings[i].ID = fmt.Sprintf("%s-%d", findings[i].ID, n) + } } } @@ -400,9 +588,12 @@ func usage() { Usage: review [flags] [revision] + review rules [job | rule] -With no revision the staged change is read. A revision is anything git diff -takes, such as HEAD^ for the last commit. +With no revision the staged change is read, which has no commit message +unless --message-file names one. A revision is anything git diff takes, +such as HEAD^..HEAD for the last commit. "rules" prints what a finding +was judged against: every rule, one job's criteria, or one rule. Flags: `) @@ -415,8 +606,10 @@ Jobs: claims whether the comments it adds assert what nobody checked hygiene whether the commit message matches the commit -The commit message is also measured before the jobs run, without a model: -see the static checks in the readme. +Before the jobs run, and without a model, the deterministic checks measure +the commit message, the history, the review's own mechanisms, and the code +the change adds: its names, tests, bodies, comments and leftovers. Run +"review rules" for the list. Dismiss a finding where it is wrong, in the source it concerns: //review:ignore <rule> <why> diff --git a/main_test.go b/main_test.go @@ -321,3 +321,55 @@ func TestIndent(t *testing.T) { t.Errorf("got %q", got) } } + +// A commit-msg hook is given a file full of git's own commentary; only the +// author's lines are the message. +func TestReadMessage(t *testing.T) { + path := filepath.Join(t.TempDir(), "COMMIT_EDITMSG") + if err := os.WriteFile(path, []byte("review: measure it\n\nBecause.\n# Please enter the commit message\n#\n# On branch main\n"), 0o644); err != nil { + t.Fatal(err) + } + got, err := readMessage(path) + if err != nil || got != "review: measure it\n\nBecause." { + t.Errorf("got %q, %v", got, err) + } + if _, err := readMessage(filepath.Join(t.TempDir(), "nowhere")); err == nil { + t.Error("a missing file was read") + } +} + +// A dismissed finding is named like the rest, so a loop can read back which +// finding the source answered. +func TestReportNamesDismissedFindings(t *testing.T) { + out := capture(t, func() { + if err := report(contract{ + Version: contractVersion, Status: "complete", + Dismissed: []Dismissed{{Finding: Finding{Job: "static", Rule: "no-stutter", File: "x.go", Severity: Consider}, Why: "on purpose"}}, + }); err != nil { + t.Error(err) + } + }) + var env struct { + Dismissed []struct { + Finding struct { + ID string `json:"id"` + Severity string `json:"severity"` + } `json:"finding"` + } `json:"dismissed"` + } + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Fatal(err) + } + if len(env.Dismissed) != 1 || env.Dismissed[0].Finding.ID == "" || env.Dismissed[0].Finding.Severity != "consider" { + t.Errorf("got %+v", env.Dismissed) + } +} + +func TestMustFix(t *testing.T) { + if mustFix([]Finding{{Severity: Consider}, {Severity: Note}}) { + t.Error("consider and note are not refusals") + } + if !mustFix([]Finding{{Severity: Note}, {Severity: MustFix}}) { + t.Error("a must-fix finding is") + } +} diff --git a/names.go b/names.go @@ -0,0 +1,166 @@ +package main + +// Three of the naming rules need no judgement. Whether a name repeats its +// package, shadows something the language already names, or carries an +// invented abbreviation is a comparison against a list, and a list is +// measured here rather than read by a model. + +import ( + "fmt" + "strings" +) + +// checkNames reports the naming faults a comparison can settle: stutter +// against the package, shadowing of a predeclared or well-known name, and +// abbreviations the discipline rejects. +func checkNames(c *Change) []Finding { + var out []Finding + for _, s := range c.Symbols { + if isTestFile(s.File) { + continue + } + out = append(out, stutter(s)...) + out = append(out, shadow(s, c.Imports[s.File])...) + out = append(out, abbreviated(s)...) + } + return out +} + +// stutter reports a name whose first word is its package. The package's +// name is already said wherever the name is used, so the word is said +// twice: ico.IcoEntry, exe.ExeKind. A type named exactly for its package +// is the language's own idiom — time.Time, context.Context — and is spared, +// as is anything in package main, which nothing qualifies. +func stutter(s Symbol) []Finding { + if s.Package == "" || s.Package == "main" || !s.Exported { + return nil + } + words := split(s.Name) + if len(words) < 2 { + return nil + } + pkg := strings.ToLower(strings.ReplaceAll(s.Package, "_", "")) + if strings.ToLower(words[0]) != pkg { + return nil + } + return []Finding{{ + Job: "static", Rule: "no-stutter", Severity: Consider, + File: s.File, Line: s.Line, Symbol: s.Name, + Message: fmt.Sprintf("%s repeats its package: %s.%s says %s twice", s.Name, s.Package, s.Name, words[0]), + Fix: fmt.Sprintf("drop the package's word: %s.%s", s.Package, strings.Join(words[1:], "")), + }} +} + +// predeclared are Go's universe-block identifiers. A package-level name +// that takes one compiles, and then the builtin is gone for the whole +// package. +var predeclared = map[string]bool{ + "append": true, "bool": true, "byte": true, "cap": true, "clear": true, "close": true, + "complex": true, "complex64": true, "complex128": true, "copy": true, "delete": true, + "error": true, "false": true, "float32": true, "float64": true, "imag": true, + "int": true, "int8": true, "int16": true, "int32": true, "int64": true, "iota": true, + "len": true, "make": true, "max": true, "min": true, "new": true, "nil": true, + "panic": true, "print": true, "println": true, "real": true, "recover": true, + "rune": true, "string": true, "true": true, "uint": true, "uint8": true, + "uint16": true, "uint32": true, "uint64": true, "uintptr": true, "any": true, + "comparable": true, +} + +// stdlib are the standard library packages a Go file is likeliest to +// import. A package-level name that takes one cannot share a file with the +// import, so the next file to need the package renames one or the other. +var stdlib = map[string]bool{ + "bufio": true, "bytes": true, "cmp": true, "context": true, "errors": true, + "fmt": true, "io": true, "log": true, "maps": true, "math": true, "os": true, + "path": true, "reflect": true, "regexp": true, "slices": true, "sort": true, + "strconv": true, "strings": true, "sync": true, "testing": true, "time": true, + "unicode": true, "url": true, "http": true, "json": true, "exec": true, + "filepath": true, "rand": true, "hash": true, "flag": true, "template": true, +} + +// globals are the names a browser or Node runtime already binds. A module +// that declares one at the top level shadows the runtime's for every +// reader of the module. +var globals = map[string]bool{ + "Promise": true, "Map": true, "Set": true, "Array": true, "Object": true, + "Error": true, "JSON": true, "Math": true, "Date": true, "Symbol": true, + "String": true, "Number": true, "Boolean": true, "console": true, + "window": true, "document": true, "process": true, "require": true, + "module": true, "exports": true, "fetch": true, "event": true, + "location": true, "history": true, "navigator": true, +} + +// builtins are Python's, the ones a module-level name is likeliest to take +// by accident: id, type, input, list. +var builtins = set(`abs all any bin bool bytes callable chr dict dir divmod enumerate eval exec filter + float format frozenset getattr hasattr hash help hex id input int isinstance issubclass iter len list + locals map max min next object oct open ord pow print property range repr reversed round set setattr + slice sorted str sum super tuple type vars zip`) + +// shadow reports a name the language or its runtime already means +// something by. Go's predeclared identifiers and the standard library's +// package names are measured for Go; the runtime's globals for TypeScript +// and JavaScript; the builtins for Python. +func shadow(s Symbol, imports []string) []Finding { + var ( + what string + why string + ) + switch { + case strings.HasSuffix(s.File, ".go"): + switch { + case predeclared[s.Name]: + what, why = "a predeclared identifier", "the builtin is gone for the whole package" + case stdlib[s.Name]: + what, why = "a standard library package", "no file in the package can import it beside this name" + } + case grammarOf(s.File) != "": + if globals[s.Name] { + what, why = "a runtime global", "the runtime's is shadowed for every reader of the module" + } + case strings.HasSuffix(s.File, ".py"): + if builtins[s.Name] { + what, why = "a builtin", "the builtin is gone for the whole module" + } + } + if what == "" { + return nil + } + return []Finding{{ + Job: "static", Rule: "no-shadow", Severity: Consider, + File: s.File, Line: s.Line, Symbol: s.Name, + Message: fmt.Sprintf("%s is %s, and %s", s.Name, what, why), + Fix: "name it for what it is here, in a word the language does not already use", + }} +} + +// abbreviations are the shortenings the discipline rejects. Established +// ones — id, url, ctx, msg, err, buf, cmd, tmp — are words in their own +// right and are not here. +var abbreviations = map[string]bool{ + "cfg": true, "mgr": true, "mgmt": true, "hdlr": true, "hndlr": true, "hndl": true, + "svc": true, "ctrl": true, "ctlr": true, "btn": true, "cnt": true, "amt": true, + "qty": true, "calc": true, "tbl": true, "usr": true, "pwd": true, "dflt": true, + "nbr": true, "mdl": true, "srvr": true, "clnt": true, "rslt": true, "chk": true, + "upd": true, +} + +// abbreviated reports a name carrying an invented abbreviation: a word the +// reader has to expand rather than read. +func abbreviated(s Symbol) []Finding { + var hit []string + for _, word := range split(s.Name) { + if abbreviations[strings.ToLower(word)] { + hit = append(hit, word) + } + } + if len(hit) == 0 { + return nil + } + return []Finding{{ + Job: "static", Rule: "abbreviation", Severity: Consider, + File: s.File, Line: s.Line, Symbol: s.Name, + Message: fmt.Sprintf("%s abbreviates %s; an invented abbreviation is a word the reader expands rather than reads", s.Name, strings.Join(hit, ", ")), + Fix: "write the word out", + }} +} diff --git a/names_test.go b/names_test.go @@ -0,0 +1,78 @@ +package main + +import "testing" + +func rulesOf(findings []Finding) []string { + var out []string + for _, f := range findings { + out = append(out, f.Rule) + } + return out +} + +func TestStutter(t *testing.T) { + for _, test := range []struct { + symbol Symbol + fires bool + }{ + {Symbol{Name: "IcoEntry", Package: "ico", Exported: true, File: "ico/ico.go"}, true}, + {Symbol{Name: "exe_kind", Package: "exe", Exported: true, File: "exe/exe.odin"}, true}, + {Symbol{Name: "Time", Package: "time", Exported: true, File: "time/time.go"}, false}, + {Symbol{Name: "Entry", Package: "ico", Exported: true, File: "ico/ico.go"}, false}, + {Symbol{Name: "icoEntry", Package: "ico", Exported: false, File: "ico/ico.go"}, false}, + {Symbol{Name: "MainLoop", Package: "main", Exported: true, File: "main.go"}, false}, + {Symbol{Name: "Iconic", Package: "ico", Exported: true, File: "ico/ico.go"}, false}, + } { + got := stutter(test.symbol) + if (len(got) == 1) != test.fires { + t.Errorf("%s in package %s: got %v", test.symbol.Name, test.symbol.Package, got) + } + } +} + +func TestShadow(t *testing.T) { + for _, test := range []struct { + symbol Symbol + fires bool + }{ + {Symbol{Name: "len", File: "x.go"}, true}, + {Symbol{Name: "url", File: "x.go"}, true}, + {Symbol{Name: "Promise", File: "x.ts"}, true}, + {Symbol{Name: "render", File: "x.go"}, false}, + {Symbol{Name: "len", File: "x.ts"}, false}, + {Symbol{Name: "Promise", File: "x.go"}, false}, + } { + got := shadow(test.symbol, nil) + if (len(got) == 1) != test.fires { + t.Errorf("%s in %s: got %v", test.symbol.Name, test.symbol.File, got) + } + } +} + +func TestAbbreviated(t *testing.T) { + for _, test := range []struct { + name string + fires bool + }{ + {"loadCfg", true}, {"user_mgr", true}, {"BtnLabel", true}, + {"msgCount", false}, {"parseURL", false}, {"ctx", false}, {"configure", false}, + } { + got := abbreviated(Symbol{Name: test.name, File: "x.go"}) + if (len(got) == 1) != test.fires { + t.Errorf("%s: got %v", test.name, got) + } + } +} + +// The check runs over the change's symbols and leaves test files alone. +func TestCheckNames(t *testing.T) { + c := &Change{Symbols: []Symbol{ + {Name: "IcoEntry", Package: "ico", Exported: true, File: "ico/ico.go", Line: 3}, + {Name: "cfg", File: "ico/ico.go", Line: 9}, + {Name: "cfgForTests", File: "ico/ico_test.go", Line: 4}, + }, Imports: map[string][]string{}} + got := rulesOf(checkNames(c)) + if len(got) != 2 || got[0] != "no-stutter" || got[1] != "abbreviation" { + t.Errorf("got %v", got) + } +} diff --git a/odinfrontend.go b/odinfrontend.go @@ -115,51 +115,63 @@ func (g OdinFrontend) Change(root, rev string, c *Change, added map[string][]int for _, line := range added[name] { touched[line] = true } + source, err := at(root, rev, name) + if err != nil { + continue // Deleted by the change, so there is nothing to read. + } + lines := strings.Split(string(source), "\n") + pkg := odinPackage(lines) for _, decl := range byFile[name] { if !touched[decl.Line] { continue } + end := min(decl.EndLine, len(lines)) if decl.Test { - source, err := at(root, rev, name) - if err != nil { - continue - } - lines := strings.Split(string(source), "\n") - end := decl.EndLine - if end > len(lines) { - end = len(lines) - } c.Tests = append(c.Tests, Function{ Name: decl.Name, File: name, Line: decl.Line, Body: text(lines, decl.Line, end), }) continue } - c.Symbols = append(c.Symbols, Symbol{ + symbol := Symbol{ Name: decl.Name, Kind: decl.Kind, Doc: decl.Doc, File: name, Line: decl.Line, - Exported: decl.Exported, Signature: decl.Text, - }) - } - source, err := at(root, rev, name) - if err != nil { - continue + Exported: decl.Exported, Signature: decl.Text, Package: pkg, + } + if decl.Kind == "func" { + symbol.Body = text(lines, decl.Line, end) + } + c.Symbols = append(c.Symbols, symbol) } c.Comments = append(c.Comments, commentProse(source, name, added[name])...) } return nil } +// odinPackage is the package a file declares, read from its package line. +func odinPackage(lines []string) string { + for _, line := range lines { + if rest, ok := strings.CutPrefix(strings.TrimSpace(line), "package "); ok { + return strings.TrimSpace(rest) + } + } + return "" +} + // Whole reads every declaration in the repository's Odin files, so a new // name can be checked against the ones it may duplicate. func (g OdinFrontend) Whole(root, rev string) ([]Declared, error) { - out, err := git(root, "ls-files", "*.odin") + tree, err := treeAt(root, rev) + if err != nil { + return nil, err + } + tracked, err := tree.Files() if err != nil { return nil, err } var files []string - for _, name := range strings.Split(strings.TrimSpace(out), "\n") { - if name != "" && g.Covers(name) { + for _, name := range tracked { + if g.Covers(name) { files = append(files, name) } } @@ -169,11 +181,19 @@ func (g OdinFrontend) Whole(root, rev string) ([]Declared, error) { } var index []Declared for _, name := range files { + var lines []string + if source, err := tree.Read(name); err == nil { + lines = strings.Split(string(source), "\n") + } for _, decl := range byFile[name] { if decl.Test { continue // Tests are not facts with two owners. } - index = append(index, Declared{Name: decl.Name, Kind: decl.Kind, File: name, Line: decl.Line, Text: decl.Text}) + declared := Declared{Name: decl.Name, Kind: decl.Kind, File: name, Line: decl.Line, Text: decl.Text} + if decl.Kind == "func" { + declared.Body = text(lines, decl.Line, min(decl.EndLine, len(lines))) + } + index = append(index, declared) } } return index, nil diff --git a/packet.go b/packet.go @@ -3,8 +3,6 @@ package main import ( "cmp" "fmt" - "maps" - "os" "os/exec" "path/filepath" "regexp" @@ -20,7 +18,8 @@ type Change struct { Diff string // Files are the paths it touches. Files []string - // Message is the commit message under review. + // Message is the commit message under review. Empty for a staged change + // unless the caller supplied one, and then nothing measures it. Message string // Stat is the per-file line counts, which is enough to notice a commit // doing two things without reading either. @@ -56,6 +55,22 @@ type Change struct { // change touches: how many commits touch each, and which other files // those commits also touched. Nil when the history says nothing. Temporal *Temporal + // Imports names, per file, the packages the file imports, for the + // languages whose frontend reads them. A new name that shadows one is + // measured against this. + Imports map[string][]string + // Changed is how many lines the diff adds and removes, counted by git + // over the whole change rather than the capped diff; Whitespace is how + // many of them change nothing but whitespace. The formatting check + // reads both. + Changed, Whitespace int + + // index is every declaration in the repository at the end of the + // change, kept for the readings that judge new work against it. + index []Declared + // root and rev locate the tree the change arrives at, for the checks + // that read the repository rather than the diff. + root, rev string } // Temporal is the counted history of the files a change touches. @@ -84,6 +99,12 @@ type Symbol struct { // Signature is the declaration line, which is what a name has to be // judged against. Signature string `json:"signature,omitempty"` + // Body is the whole declaration where it has a body: a function's, for + // the checks that measure one. + Body string `json:"body,omitempty"` + // Package is the package the file declares, where the language has + // one; a name is measured against it for stutter. + Package string `json:"package,omitempty"` } // Function is a whole function, which the test job needs because a test can @@ -93,6 +114,10 @@ type Function struct { File string `json:"file"` Line int `json:"line"` Body string `json:"body"` + // Skips is the line the test skips itself on, or zero. It is read from + // the body by shape, so the reading that judges the skip is pointed at + // it rather than left to find it. + Skips int `json:"skips,omitempty"` } // Located is a line of prose and where it came from. @@ -100,12 +125,11 @@ type Located struct { Text string `json:"text"` File string `json:"file"` Line int `json:"line"` + // Below is the code the comment sits above, a few lines of it, so + // that a claim about behaviour can be read beside the behaviour. + Below string `json:"below,omitempty"` } -// uncommittedNote is what a message read for a staged change says instead of -// claiming to be one: git has no message for a commit that does not exist. -const uncommittedNote = "(uncommitted; the message below is the previous commit's)\n" - // Gap is a file the deterministic side could not read at all, and // why. It is reported rather than skipped silently, because a job that // read nothing reports nothing, and silence is what an empty findings list @@ -119,31 +143,6 @@ type Gap struct { // parts rather than badly as a whole. const maxDiff = 60000 -// at reads a file as it stands at the end of the change under review, so that -// reviewing an old commit reads the code that commit left behind rather than -// whatever the working tree holds now. -func at(root, rev, path string) ([]byte, error) { - after, ranged := ends(rev) - if !ranged { - // The diff for a bare revision runs from it to the working tree, so - // the working tree is where the change arrived. - return os.ReadFile(filepath.Join(root, path)) - } - cmd := exec.Command("git", "show", after+":"+path) - cmd.Dir = root - return cmd.Output() -} - -// ends reports the revision a range arrives at, and whether it is a range at -// all. Both A..B and A...B are reviewed as B. -func ends(rev string) (string, bool) { - _, after, found := strings.Cut(rev, "..") - if !found { - return rev, false - } - return strings.TrimPrefix(after, "."), true -} - // Gather collects the change at a revision range, or the staged change when // the range is empty. func Gather(rev string, root string) (*Change, error) { @@ -161,11 +160,19 @@ func Gather(rev string, root string) (*Change, error) { statArgs = []string{"diff", rev, "--stat"} } - change := &Change{Candidates: map[string][]string{}, Twins: map[string][]string{}} + change := &Change{Candidates: map[string][]string{}, Twins: map[string][]string{}, Imports: map[string][]string{}, root: root, rev: rev} var err error if change.Diff, err = git(root, diffArgs...); err != nil { return nil, err } + // The whitespace-only lines are what the diff loses when git is asked + // to ignore whitespace; the difference is the formatting mixed in. + if plain, err := git(root, append(numstat(rev), "--numstat")...); err == nil { + if loose, err := git(root, append(numstat(rev), "-w", "--numstat")...); err == nil { + change.Changed = countNumstat(plain) + change.Whitespace = change.Changed - countNumstat(loose) + } + } if len(change.Diff) > maxDiff { change.Diff = change.Diff[:maxDiff] + "\n… diff truncated\n" change.Truncated = true @@ -182,10 +189,11 @@ func Gather(rev string, root string) (*Change, error) { if change.Stat, err = git(root, statArgs...); err != nil { return nil, err } - if rev == "" { - change.Message, _ = git(root, "log", "-1", "--format=%B") - change.Message = uncommittedNote + change.Message - } else { + // A staged change has no message: git has none for a commit that does + // not exist, and the previous commit's would be measured against work + // it never described. The caller may supply one, as a commit-msg hook + // does. + if rev != "" { change.Message, _ = git(root, "log", "-1", "--format=%B", strings.TrimSuffix(rev, "^")) } if subjects, err := git(root, "log", "-12", "--format=%s"); err == nil { @@ -206,9 +214,85 @@ func Gather(rev string, root string) (*Change, error) { change.read(root, rev, frontends()) change.findCandidates(root, rev, frontends()) change.readTemporal(root, rev) + change.annotate(root, rev) return change, nil } +// numstat is the diff command for the change, without its format, so that +// the same change can be counted with and without whitespace. +func numstat(rev string) []string { + if rev == "" { + return []string{"diff", "--cached"} + } + return []string{"diff", rev} +} + +// countNumstat sums the lines added and removed over git's --numstat +// output. A binary file's counts are dashes and count nothing. +func countNumstat(out string) int { + n := 0 + for _, line := range strings.Split(out, "\n") { + fields := strings.Fields(line) + if len(fields) < 3 { + continue + } + n += atoi(fields[0]) + atoi(fields[1]) + } + return n +} + +// annotate adds to what the frontends read the parts that every language +// shares: the code below each comment, and the line a test skips itself +// on. Both are read by shape from the source, once per file. +func (c *Change) annotate(root, rev string) { + sources := map[string][]string{} + lines := func(name string) []string { + if l, ok := sources[name]; ok { + return l + } + data, err := at(root, rev, name) + if err != nil { + sources[name] = nil + return nil + } + sources[name] = strings.Split(string(data), "\n") + return sources[name] + } + for i := range c.Comments { + c.Comments[i].Below = codeBelow(lines(c.Comments[i].File), c.Comments[i].Line) + } + for i := range c.Tests { + c.Tests[i].Skips = skipLine(c.Tests[i]) + } +} + +// belowLines is how much code a comment is shown beside. +const belowLines = 2 + +// codeBelow is the code that follows a comment: the first lines after it +// that are neither blank nor comment, up to belowLines of them. +func codeBelow(lines []string, comment int) string { + var out []string + for i := comment; i < len(lines) && len(out) < belowLines; i++ { + trimmed := strings.TrimSpace(lines[i]) + if trimmed == "" || isCommentLine(trimmed) { + if len(out) > 0 { + break + } + continue + } + out = append(out, trimmed) + } + return strings.Join(out, "\n") +} + +// isCommentLine is whether a trimmed line is a comment by the shapes the +// tool's languages share. +func isCommentLine(trimmed string) bool { + return strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") || + strings.HasPrefix(trimmed, "*") || (strings.HasPrefix(trimmed, "#") && !strings.HasPrefix(trimmed, "#!")) +} + // The counting window and width: the coupling is counted over the last // thousand commits before the change, and a commit listing more than a // hundred files is left out of the count — a sweep touching everything @@ -324,36 +408,27 @@ func (c *Change) readTemporal(root, rev string) { // remaining reports which of the partner paths are still files at the end // of the change: a partner the tree no longer holds is history's partner, -// not this change's. A review of a range asks git once for all of them; -// one of the working tree asks the filesystem. +// not this change's. func (c *Change) remaining(root, rev string, pairs map[string]map[string]int) map[string]bool { - all := map[string]bool{} - for _, ps := range pairs { - for name := range ps { - all[name] = true - } - } out := map[string]bool{} - after, ranged := ends(rev) - if !ranged { - for name := range all { - if _, err := os.Stat(filepath.Join(root, name)); err == nil { - out[name] = true - } - } + tree, err := treeAt(root, rev) + if err != nil { return out } - args := append([]string{"ls-tree", "--name-only", after, "--"}, slices.Collect(maps.Keys(all))...) - if ls, err := git(root, args...); err == nil { - for _, line := range strings.Split(ls, "\n") { - if line = strings.TrimSpace(line); line != "" { - out[line] = true + for _, ps := range pairs { + for name := range ps { + if tree.Exists(name) { + out[name] = true } } } return out } +// shortlist is how many resembling declarations a new name is shown +// beside, ranked; the twins come before them and are not counted. +const shortlist = 12 + // findCandidates gathers, for every new name, the existing declarations that // might already mean the same thing. Finding them is the cheap part; judging // the shortlist is what the job is for. @@ -369,6 +444,7 @@ func (c *Change) findCandidates(root, rev string, frontends []Frontend) { } index = append(index, declared...) } + c.index = index // A constant holding the same literal as a new one is the strongest hint // a fact has been written twice, so it goes first where it exists. for _, symbol := range c.Symbols { @@ -384,7 +460,7 @@ func (c *Change) findCandidates(root, rev string, frontends []Frontend) { if len(twins) > 0 { c.Twins[symbol.Name] = twins } - c.Candidates[symbol.Name] = slices.Concat(twins, Resembling(index, symbol, 16)) + c.Candidates[symbol.Name] = slices.Concat(twins, Resembling(index, symbol, shortlist)) } } @@ -501,3 +577,18 @@ func git(root string, args ...string) (string, error) { } return string(out), nil } + +// skipLine is the line, within the test's body, on which it skips itself: +// the shapes are t.Skip and its variants in Go, test.skip and it.skip in +// TypeScript, and testing.skip in Odin. Zero when it does not. +func skipLine(t Function) int { + for i, line := range strings.Split(t.Body, "\n") { + trimmed := strings.TrimSpace(line) + for _, shape := range []string{"t.Skip(", "t.Skipf(", "t.SkipNow(", "test.skip(", "it.skip(", "describe.skip(", "this.skip(", "testing.skip(", "t.skip(", "pytest.skip(", "pytest.mark.skip", "self.skipTest(", "unittest.skip"} { + if strings.Contains(trimmed, shape) { + return t.Line + i + } + } + } + return 0 +} diff --git a/packet_test.go b/packet_test.go @@ -397,3 +397,44 @@ func TestIsTest(t *testing.T) { } } } + +// The Go frontend reads what the later checks measure: a function's body +// and package, the file's imports, and the code beneath each comment. +func TestGatherReadsBodiesPackagesImportsAndContext(t *testing.T) { + r := newRepo(t) + r.write("x.go", "package x\n") + r.commit("first", "x.go") + r.write("x.go", `package x + +import ( + "fmt" + str "strings" +) + +// Shout says it louder. +func Shout(s string) string { + return str.ToUpper(fmt.Sprint(s)) +} +`) + r.stage("x.go") + change, err := Gather("", r.Root) + if err != nil { + t.Fatal(err) + } + if len(change.Symbols) != 1 { + t.Fatalf("symbols: %v", change.Symbols) + } + s := change.Symbols[0] + if s.Package != "x" || !strings.Contains(s.Body, "return str.ToUpper") || !strings.HasPrefix(s.Body, "func Shout") { + t.Errorf("got %+v", s) + } + if imports := change.Imports["x.go"]; len(imports) != 2 || imports[0] != "fmt" || imports[1] != "str" { + t.Errorf("imports: %v", imports) + } + if len(change.Comments) != 1 || !strings.HasPrefix(change.Comments[0].Below, "func Shout(s string) string {\nreturn str.ToUpper") { + t.Errorf("comments: %+v", change.Comments) + } + if len(change.index) == 0 || change.index[0].Body == "" { + t.Errorf("the index carries no bodies: %+v", change.index) + } +} diff --git a/prose.go b/prose.go @@ -0,0 +1,72 @@ +package main + +// A comment whose words are the code's own words says nothing the code +// does not. It is never a claim, so the claims job should not be paying to +// read it, and it is worth a word of its own: the habit of narrating each +// line is the habit this catches. + +import ( + "fmt" + "strings" +) + +// minRestatedWords is the fewest content words a comment needs before it +// can be said to restate anything: one word is a label, not a sentence. +const minRestatedWords = 2 + +// restates is whether a comment's content words all appear in the code it +// sits above. A directive, a dismissal, a task marker and a link are not +// prose about the code and are never said to restate it. +func restates(comment Located) bool { + text := comment.Text + if comment.Below == "" || directive(text) { + return false + } + words := contentWords(text) + if len(words) < minRestatedWords { + return false + } + code := contentWords(strings.SplitN(comment.Below, "\n", 2)[0]) + for w := range words { + if !code[w] { + return false + } + } + return true +} + +// directive is whether a comment is one the tools read rather than a +// person: a compiler or linter instruction, a dismissal, a task marker, a +// link. +func directive(text string) bool { + trimmed := strings.TrimSpace(text) + for _, prefix := range []string{"go:", "nolint", "eslint", "@ts-", "prettier", "review:ignore", "#!", "+build", "lint:"} { + if strings.HasPrefix(trimmed, prefix) { + return true + } + } + for _, marker := range []string{"TODO", "FIXME", "XXX", "HACK", "http://", "https://"} { + if strings.Contains(trimmed, marker) { + return true + } + } + return false +} + +// checkRestating reports the comments the change adds whose every word the +// code below already says. +func checkRestating(c *Change) []Finding { + var out []Finding + for _, comment := range c.Comments { + if !restates(comment) { + continue + } + out = append(out, Finding{ + Job: "static", Rule: "comment-restates-code", Severity: Note, + File: comment.File, Line: comment.Line, + Message: fmt.Sprintf("the comment %q says only what the line below it says; a comment that narrates the code is read twice and informs once", firstLine(comment.Text)), + Fix: "say why, or say nothing", + }) + } + return out +} diff --git a/prose_test.go b/prose_test.go @@ -0,0 +1,49 @@ +package main + +import "testing" + +func TestRestates(t *testing.T) { + for _, test := range []struct { + comment Located + want bool + }{ + {Located{Text: "set the name", Below: "setName(x)"}, true}, + {Located{Text: "Increment the counter.", Below: "counter++"}, false}, + {Located{Text: "close the file", Below: "file.Close()"}, true}, + {Located{Text: "Windows refuses an unordered group.", Below: "sort(rows)"}, false}, + {Located{Text: "name", Below: "name := x"}, false}, + {Located{Text: "TODO set the name", Below: "setName(x)"}, false}, + {Located{Text: "set the name", Below: ""}, false}, + {Located{Text: "review:ignore no-stutter the name", Below: "IcoEntry"}, false}, + } { + if got := restates(test.comment); got != test.want { + t.Errorf("%q over %q: %v, want %v", test.comment.Text, test.comment.Below, got, test.want) + } + } +} + +func TestCheckRestatingAndTheClaimsPacket(t *testing.T) { + c := &Change{Comments: []Located{ + {Text: "set the name", File: "x.go", Line: 3, Below: "setName(x)"}, + {Text: "Windows refuses an unordered group, so", File: "x.go", Line: 7, Below: "sort(rows)"}, + {Text: "the rows are sorted first.", File: "x.go", Line: 8, Below: "sort(rows)"}, + }} + got := checkRestating(c) + if len(got) != 1 || got[0].Rule != "comment-restates-code" || got[0].Line != 3 || got[0].Severity != Note { + t.Errorf("got %v", got) + } + blocks := commentBlocks(c.Comments) + if len(blocks) != 1 || len(blocks[0]) != 2 { + t.Errorf("got %v, want one block of two lines", blocks) + } +} + +func TestCodeBelow(t *testing.T) { + lines := []string{"// says", "", "// more", "a()", "b()", "", "c()"} + if got := codeBelow(lines, 1); got != "a()\nb()" { + t.Errorf("got %q", got) + } + if got := codeBelow(lines, 7); got != "" { + t.Errorf("got %q", got) + } +} diff --git a/readme.md b/readme.md @@ -2,19 +2,29 @@ Reads a change the way several narrow readers would, and reports what they noticed. -It is **advisory**. It never fails a build and never blocks a commit — the deterministic -checks a repository already runs are what should do that. This looks at what those -checks cannot: whether a name says what the thing is, whether a fact is already stated +It is **advisory** unless asked to gate. By default it never fails a build and never +blocks a commit — the deterministic checks a repository already runs are what should do +that; `--exit-code` lets a hook make it one of them. It looks at what those checks +cannot: whether a name says what the thing is, whether a fact is already stated somewhere else, whether a test can fail, whether a comment claims something nobody -verified. +verified — and, before any of that is asked of a model, at everything about the change +that a comparison can settle. ``` -review the staged change -review HEAD^..HEAD the last commit -review --json for an agent rather than a person -review --show what each job would be sent, without asking anything -review --fresh the change again, asking rather than replaying the cache -review --no-verify skip the second reading that verifies the findings +review the staged change +review HEAD^..HEAD the last commit +review --json for an agent rather than a person +review --show what each job would be sent, and what the static checks found +review --fresh the change again, asking rather than replaying the cache +review --no-verify skip the second reading that verifies the findings +review --message-file "$1" the staged change with the message a commit-msg hook is given +review --exit-code exit 1 when a must-fix finding stands, for a hook +review --baseline last.json which findings resolved, persist, or are new since that report +review rules [job | rule] what a finding was judged against +review rules -dismissed how often each rule is dismissed in this tree, and why +review bench [-n 200] [-author x] [-rule id] fire rate of every static check over recent commits +review hook install the commit-msg hook that makes the review a gate +review agent what an agent's instructions should say about this tool ``` ## The jobs @@ -26,25 +36,69 @@ owns. | job | reads | asks | |---|---|---| | `duplication` | new declarations + existing ones that resemble them | does the repository already state this? | -| `tests` | whole test functions the change adds | could this test fail? | +| `tests` | whole test functions the change adds, with the functions they call and the line each skips on | could this test fail? | | `namer` | new and renamed declarations, with their doc comments | does the name say what the thing is? | -| `claims` | comment lines the change adds | does anything support this assertion? | +| `claims` | comment lines the change adds, in their blocks, with the code beneath each | does anything support this assertion? | | `hygiene` | the commit message, the file statistics, recent subjects | does the message match the commit? | The candidate list the `duplication` job judges is built by parsing the repository, not by searching it. A pattern over lines misses an indented constant inside a block, which -is exactly where a duplicated fact tends to live. +is exactly where a duplicated fact tends to live. The list is ranked: names sharing more +whole words first, then the same kind, then the same file — `cache` no longer pulls in +every `Cached` field — and cut at twelve. The `tests` job is shown the bodies of the +functions its tests call, found by name in the same index, so that whether a test passes +on a stub is judged against the function rather than guessed. The `claims` job is shown +two lines of code beneath each comment block, so that `stale` can be judged against what +the change does; a comment whose every word is in the line below it is never a claim, and +is kept out of the packet and measured by a static check instead. ## The static checks -Eleven checks run before anything is asked of a provider, and whether or not one can be -asked. Seven measure the commit message; one measures the change against the -repository's history; two hold the review to itself. A twelfth runs the repository's own -analyser when it is installed: staticcheck, on the packages the change touches. +Twenty-seven checks run before anything is asked of a provider, and whether or not one can +be asked. Seven measure the commit message; one measures the diff's shape; one measures +the change against the repository's history; two hold the review to itself; sixteen +measure the code the change adds. Then the repository's own compilers and analysers run, +each with its strictest settings and asked for JSON, over the units the change touched. +Static analysis is cheaper than a reading, so wherever a rule turned out to be a +comparison rather than a judgement it was moved here — three naming rules and the +plainest case of a test that cannot fail came out of the criteria that way. + +| analyser | runs | reports | +|---|---|---| +| `go-build` | `go build -json` on the packages the change touched | a compile error, must-fix wherever it lands | +| `go-vet/<analyzer>` | `go vet -json`, with `-vettool=review-vet` when that is installed | vet's default set and `nilness` as must-fix; `shadow` and `unusedwrite` as consider; the `modernize` suite as note | +| `staticcheck/<code>` | `staticcheck -f json` on the same packages | SA as must-fix, S and U as consider, style as note | +| `odin-check` | `odin check <pkg> -vet -strict-style -json-errors -no-entry-point` per package touched | a type error must-fix wherever it lands; `/vet` (unused, shadowing) consider; `/style` note | +| `tsc/<code>` | `tsc --noEmit --pretty false -p tsconfig.json` per project touched, under the project's own strictness | a type error, must-fix wherever it lands; working tree only, since it needs `node_modules` | +| `ruff/<code>` | `ruff check --output-format json` on the changed files | pyflakes codes consider, style codes note | +| `mypy/<code>` | `mypy --output json --ignore-missing-imports` on the changed files | a type error must-fix; working tree only | +| `cargo/<code>` | `cargo clippy --message-format json`, or `cargo check` without clippy, per crate touched, into the repository's own target directory | an error must-fix wherever it lands, a warning consider | +| `semgrep/<rule>` | `semgrep scan --json` under the repository's `.semgrep.yml`, or `--config p/default` without one, on the changed files it can parse | the rule's own severity: ERROR must-fix, WARNING consider, INFO note | + +Each analyser runs only when its binary is on the path and the tree has what it needs, and +says on stderr why it did not. The rule for what belongs to the change is the same for +all of them: a **fault** — the compiler cannot build the unit — is the change's wherever +the error lands, because the tree does not compile until it is answered; an **opinion** +is the change's only on a line the change added. A run that cannot finish inside five +minutes is abandoned rather than holding the review. A range is analysed on its +materialised tree; the two analysers that need the working tree's surroundings say so +and skip. + +`review-vet` is the multichecker in `sidecar/govet`: vet's own analysers plus the ones +from `golang.org/x/tools/go/analysis/passes` that vet leaves out — `nilness` (nil +dereferences and impossible comparisons, from SSA), `atomicalign`, `deepequalerrors`, +`httpmux`, `reflectvaluecompare`, `scannererr`, `sortslice`, `sqlrowserr`, `shadow`, +`unusedwrite`, and the `modernize` suite. Build it and put it on the path: + +``` +go build -o ~/go/bin/review-vet ./sidecar/govet +``` + +Without it, vet runs its default set. `fieldalignment` is left out on purpose: it is +noise on any struct not on a hot path. | check | reads | asks | |---|---|---| -| `staticcheck/<code>` | the analyser's findings, on the lines the change adds | is the code the analyser argues about? SA faults report as must-fix, unused code and simplifications as consider, style as note | | `message-low-entropy` | the Shannon entropy of the message, in bits per byte | is this one phrase repeated rather than a description? | | `message-boilerplate` | the share of its length the message keeps after zlib | is this one block of text pasted whole? | | `message-common-words` | each word's rarity in the repository's own subject history | is this made only of the words this repository says most, naming nothing in the change? | @@ -52,9 +106,26 @@ analyser when it is installed: staticcheck, on the packages the change touches. | `message-not-imperative` | the subject's first word after its package prefix | does the subject open as a command — not past tense, not a gerund, not the author? | | `message-no-body` | the diff's size in changed lines | does a change over 50 lines say anything below the subject at all? | | `message-long-body` | the body's word count | is the body over 150 words, listing what the diff already shows? | +| `message-names-unknown` | each identifier-shaped word of the message — camel or snake case, a path, a call, anything in backticks — against the diff and every file in the tree | does the message name code that is nowhere in the repository? | +| `formatting-mixed-in` | git's count of changed lines with and without whitespace | is half the diff reformatting, with at least ten lines of logic hidden in it? | | `history-coupled-file` | each changed file's co-changes over the last 1,000 commits, as a Jaccard | does history tie this file to a partner the change does not touch — the test beside the code, the header beside the source, the golden file beside the renderer? | | `suppression-added` | dismissal comments the change adds | is the change dismissing what the readers would have found, before the readers ran? | -| `test-deleted` | test functions the change removes, in Go, TypeScript and JavaScript | is the change deleting the tests that would have failed? | +| `test-deleted` | test functions the change removes, in Go, TypeScript, JavaScript, Python and Rust | is the change deleting the tests that would have failed? | +| `no-stutter` | each exported name's first word against its package | does `ico.IcoEntry` say ico twice? `time.Time` is the idiom and is spared | +| `no-shadow` | each new name against Go's predeclared identifiers and standard library packages, the JavaScript runtime's globals, or Python's builtins | does the name take a word the language already uses? | +| `abbreviation` | each word of a new name against a short list | is this `cfg`, `mgr`, `hdlr`, `svc`, `btn`, `cnt`: a word the reader expands rather than reads? | +| `test-no-assertion` | each added or altered test's body | is there any call in it that could fail it — or does it pass whatever the code does, and fail only by crashing? | +| `assertion-always-true` | each assertion in an added or altered test | does it assert a literal true, two literals, or a value against itself? | +| `duplicate-body` | each new function's tokens against every function in the repository and the change | is this body already written — token for token (must-fix), or in shape with every name changed (consider)? | +| `function-too-long` | each new function's line count | is it over 150 lines? 95% of measured functions fit in 109 | +| `nesting-too-deep` | each new function's block depth | does it nest more than 5 deep? 99% of measured functions stay within 6 | +| `comment-restates-code` | each added comment's words against the line below it | does the comment say only what the code says? | +| `todo-without-reference` | each added TODO, FIXME, XXX or HACK | does it name an issue, a ticket, a link or a person? | +| `commented-out-code` | runs of added comment lines | is this code kept as a comment — two statement-shaped lines, or one beyond doubt? | +| `debug-leftover` | added lines against the debugging shapes of each language | is this a `debugger`, a `breakpoint()`, a `dbg!`, a `spew.Dump`, a `console.log`, a `DEBUG` print? | +| `error-swallowed` | added lines against the dropping shapes of each language | is this `_ = err`, an empty `catch`, an `except` that passes? | +| `new-symbol-unreferenced` | each new declaration's name as a whole word over every text file in the tree | does anything refer to it but its own line? A doc comment does not count | +| `code-without-tests` | the added lines by file kind, and the tree's test files | does a change of 50 or more code lines touch no test, in a repository that keeps them? | The first two are calibrated against the 21,000 commit messages on this machine, and fire below every one of them: under 3.2 bits per byte, where ordinary messages measure @@ -62,24 +133,24 @@ fire below every one of them: under 3.2 bits per byte, where ordinary messages m a message under 40 bytes, and zlib nothing about one under 400 — a short subject is low-entropy whatever it says, and framing dominates the ratio below that. -The staticcheck check needs the binary on the path and a `go.mod` at the repository -root; without either it says nothing, and a run that cannot finish inside five minutes -is abandoned rather than holding the review. Findings land only on lines the change -adds — the analyser is free to say what it likes about the rest of the repository, and -the review's contract is the change. A range is analysed on the tree the range arrived -at, materialised to a scratch directory. Dismiss one where it is wrong: +Dismiss an analyser's finding where it is wrong, by its full id: ```go //review:ignore staticcheck/S1002 the comparison states the contract ``` -The third needs a history to measure against, so it says nothing in a repository -younger than a hundred commits. Against 23,956 measured messages it fires on sixteen, -every one of them a message such as `fix`, `fix ci`, `Fix fix.` — and on nothing else. -A message is spared when any word is rarer than a fifth of the history, when it names -anything the diff adds, removes or touches (`Update requests.ts`), when it carries a -number (`Bump to 2.0.26`), or when it is a merge or squash subject, which is git's -prose rather than the commit's. +The common-words check needs a history to measure against, so it says nothing in a +repository younger than a hundred commits. Against 23,956 measured messages it fires on +sixteen, every one of them a message such as `fix`, `fix ci`, `Fix fix.` — and on +nothing else. A message is spared when any word is rarer than a fifth of the history, +when it names anything the diff adds, removes or touches (`Update requests.ts`), when it +carries a number (`Bump to 2.0.26`), or when it is a merge or squash subject, which is +git's prose rather than the commit's. + +The formatting check is git's own count: the change's lines with `--numstat`, and again +with `-w`. Sampled over 1,853 local commits, a whitespace share of one half with at +least twenty whitespace-only lines and ten of logic fires on 0.7%, and the ones it fires +on are the mixed kind. The coupling check is the one that is not about the message: it is counted from `git log` alone, so it works for every language and sees pairs the compiler cannot — @@ -91,33 +162,114 @@ three pairs, and only those that still exist at the end of the change. Measured rate on sampled history: 3.6% of commits at J ≥ 0.7 with at least 5 shared commits — 12% at J ≥ 0.5, which is why the threshold sits where it does. -The last two are about gaming the review itself. `suppression-added` reads every -dismissal comment the change adds — a suppression that the readers have not seen yet, -nameable before they run — and reports it as must-fix; the report carries no file and -so cannot be dismissed itself. The prose that documents the mechanism does not count, -and neither does a comment in a file no reader reads. `test-deleted` reads the test -functions the change removes and reports each file that lost one, must-fix; a test -that was renamed is spared, judged name-word by name-word, and a deleted test that -survived as a new test elsewhere in the change is spared the same way. - -They catch a large change that never explains itself — unless the diff only moves -text around, which its subject describes — a body that explains past its point, -text repeated or pasted — `asdf asdf asdf`, a licence notice, a dumped log -— a message made only of the repository's usual words, and a message that is only an -exclamation. Words that history shows describing code legitimately are not markers: -Odin says `stupid UB` and implements `dumb PtrMap`, and `finally` and `eventually` -belong to real prose. Verbs whose past and imperative share a form — read, -set, split — are spared, as are articles, because an explainer may be a noun -phrase on purpose. They do not catch fluent emptiness: `do the needful` and -`updated the thing` measure like prose and their words are rare precisely because -nobody writes them, so no frequency count will hold them. That is the `hygiene` job's. +The two about gaming the review itself: `suppression-added` reads every dismissal +comment the change adds — a suppression that the readers have not seen yet, nameable +before they run — and reports it as must-fix; the report carries no file and so cannot +be dismissed itself. The prose that documents the mechanism does not count, and neither +does a comment in a file no reader reads. `test-deleted` reads the test functions the +change removes and reports each file that lost one, must-fix; a test that was renamed +is spared, judged name-word by name-word, and a deleted test that survived as a new +test elsewhere in the change is spared the same way. + +The sixteen about the code read what the language frontends read — declarations with +their bodies, tests, comments — and the diff's added lines where a shape is enough. +Their thresholds were measured rather than guessed. Function length and depth were read +off 8,255 functions in the repositories on this machine: the 95th percentile of length +is 109 lines and the 99th is 257, so the cut is 150; the 99th percentile of depth is 6, +so the cut is 5. Two bodies are compared only past forty tokens, and compared in shape — +every identifier and literal replaced, the grammar's keywords kept — only past eighty: +two small wrappers share a shape because wrappers do, and the tool's own `decode` pair +is what taught it that. The reference count skips comment lines, because a Go doc +comment opens with the name it documents and a comment is not a caller; it reads every +text file in the tree, so a use from a template counts. Debugging shapes are the +statements that exist to be removed — `debugger`, `breakpoint()`, `dbg!`, `spew.Dump` +— and not ordinary printing, because a command's output and a debug print share a +function; only `console.log` is reported, and as a note. The code checks report as +consider or note: each is a comparison, and comparisons have exceptions the reader +knows and the tool does not. `duplicate-body`, token for token, and `assertion-always-true` are the must-fixes: a body +copied whole and an assertion that cannot fail have no exceptions. The tautology check +exists because of the assertion check: once a test must assert something, `assert True` +and `expect(true).toBe(true)` are what a reading that wants to pass reaches for. + +Known misses of the message checks, measured against constructed bad messages: `wip` +and `temp` (under every floor — statistically indistinguishable from `coffee!`, a real +subject in the corpus); "fixed the bug by fixing the bug in the file" (43 bytes, 3.53 +bits — lexically repetitive but character-diverse); fluent generic prose ("This commit +modifies the codebase…" at 4.2 bits and 0.73), which is the `hygiene` job's. + +## Gating + +A staged change has no commit message, and nothing measures one: the previous commit's +would be measured against work it never described, and a must-fix about a message the +author did not write is exactly what an agent will try to fix. A commit-msg hook is +given the message before the commit exists, and hands it over: + +```sh +#!/bin/sh +# .git/hooks/commit-msg +exec review --message-file "$1" --exit-code +``` + +`--exit-code` is the one place the tool refuses: exit 1 when a must-fix finding stands +after dismissals, 0 otherwise. Without it the tool is advisory whatever it finds, and +the JSON `status` is what a stricter reader consults. An agent's harness can gate the +same way — a hook before `git commit` that runs `review --exit-code`, or one after that +runs `review HEAD^..HEAD --json` and feeds the findings back. The message file is read +the way git reads it: lines opening with `#` are the template's, not the author's. + +`review hook install` writes that hook, naming the binary by its absolute path, and +prints the stanza a Claude Code harness takes, which reviews the staged change before +any `git commit` the agent runs. A hook already there is not replaced unasked. Where +`core.hooksPath` is set, git reads hooks from one directory for every repository and +ignores `.git/hooks`; the install then prints the hook and says where to put it rather +than writing to a place git will not read or to every repository at once. + +The loop an agent runs is review, fix, review again, and `--baseline last.json` is what +tells it the fixes took: each finding of the new run is named against the previous +report's, and the report says which ids `resolved`, which `persisting`, and which are +`new`. Every finding also carries a `snippet`, the line it points at as it stands, so an +agent acts on most findings without opening the file. + +The rules travel with the binary. `review rules` prints every deterministic check and +every job's criteria; `review rules tests` one job's; `review rules no-stutter` the one +rule a finding cited, with the lines that continue it; `review agent` the paragraph an +agent's instructions should hold — how to run the tool, how to read its report, how to +dismiss a finding, and when it may stop. An agent handed a finding can read what it was +judged against without leaving the terminal. + +## Measuring the checks + +The deterministic checks cost nothing to run, so their precision is measured rather +than assumed. `review bench -n 500` runs them over the last five hundred commits and +prints the fire rate per rule; `-rule message-frustration` lists the commits one rule +fired on with their subjects, which is how a threshold is argued about; `-author` narrows +the commits to one author's, which is how the checks are held against the population +they exist for — agent-authored commits, not the human history they were first +calibrated on. A rule firing on a tenth of ordinary commits is not measuring what it +claims to. + +Measured on the first run of the bench, over 150 commits of the icns corpus: no message +check fired at all; `code-without-tests` fired on 9%, every one a command, a COM shell +extension or a wasm shim that has no test and was never going to; `new-symbol-unreferenced` +on 5%, of which two commits were interface methods an encoder calls by reflection and +functions cgo exports — both exempt since — and the rest exported library API and spec +constant tables, which the check cannot tell from a guess and reports as consider; +`duplicate-body` on 2%, one of them a `Decode` copied whole into a sibling package; +`message-names-unknown` once, on `gRPC`, which is a brand and exempt since. + +`review rules -dismissed` is the other half of that: it counts the `review:ignore` +dismissals in the working tree per rule, with where each is and the reason given. A rule +dismissed everywhere is a rule to rewrite, and the argument against it is already +written in the source. ## Criteria `criteria/*.md` holds the rules, one file per job, each rule with an id. **Every finding must cite one**, and a finding citing anything else is dropped before you see it. That is deliberate: it makes the criteria the thing you tune, rather than the prompt, and it -stops a job inventing a standard on the spot. +stops a job inventing a standard on the spot. A rule that turns out to be a comparison +leaves the criteria for a static check under the same id, and a job's criteria say which +of its former rules are measured before it reads. ## Dismissing a finding @@ -140,7 +292,8 @@ itself: a finding with no file behind it is not dismissible by anyone. After every job has answered, each job with findings is asked once more, with its findings listed and the same evidence it read the first time, and answers for each: it -holds, or it does not, with a reason. A finding that falls is reported as a retraction +holds, or it does not, with a reason. A note is not put back: nothing gates on a note, so +a second reading of one buys nothing, and it stands marked unverified. A finding that falls is reported as a retraction rather than deleted — the first reading's word and the second's are both on the record, with the reason. The pass is advisory like the rest: a job that cannot be asked again leaves its findings standing, marked unverified, and reports why. @@ -154,6 +307,15 @@ Retractions are verified like everything else: each retracted finding carries an an agent can answer it — restore the test, rewrite the comment — and read the next run to confirm the finding stayed retracted. +## Packets that do not fit + +A job's subject over 16 KB is asked in parts, cut file by file into runs that each fit, +and each part is asked, cached and verified on its own, against its own evidence. The +tests packet of one seven-file change measured 22 KB and was killed at the ask timeout on +a slow local gateway; in parts, each ask is one that gateway finishes, and a part whose +files did not change replays from the cache while the others are asked. The hygiene job +reads the message and cannot be cut. + ## The answer cache Every answer is keyed by the provider and the exact prompts that produced it, and @@ -167,7 +329,9 @@ the criteria or the model changes. `--fresh` asks rather than replays, and records what it learned, so the cache is replaced rather than grown stale. The cache holds the 4,000 newest answers; a file it cannot read is replaced on the next save, and a prompt the cache cannot answer is asked -the ordinary way. +the ordinary way. Only an answer the tool can read is recorded: a model that spent its +whole output budget and said nothing was once replayed on every run, and the job failed +the same way each time without asking again. ## The JSON contract @@ -190,11 +354,21 @@ prose: `status` is what keeps an empty findings list from being read as a pass: `empty` (no change), `complete` (every job answered, every file read), or `incomplete` — when a job failed, a file no reader could read, or the diff was cut to fit. A finding carries its -`id` (stable across runs while the finding stands), `verified` (only the verify pass can -say true; static checks are their own word), `severity`, and the `rule` id from the -criteria it cites. A retraction carries the finding it ends and the verdict's `reason`. -`usage` is always present, and counts `replayed` answers rather than tokens when the -answers were cached; `usd` is 0 unless the provider reports a cost. +`id`, `verified` (only the verify pass can say true; static checks are their own word), +`severity`, and the `rule` id from the criteria it cites. A retraction carries the +finding it ends and the verdict's `reason`; a dismissal carries the finding and the +reason written in the source, named like the rest. `usage` is always present, and counts +`replayed` answers rather than tokens when the answers were cached; `usd` is 0 unless +the provider reports a cost. + +The id is a hash of what a finding is about, not where it sits or how it was worded: +job, rule, file and symbol. The line is left out because lines move under edits that do +not touch the finding; a model's message is left out because a fresh reading words the +same finding differently, and an id that changed with the wording would name nothing. +A finding with no symbol is told from its neighbours by its line; a deterministic +check's message is part of what it is about — the coupled partner, the measured number +— and stable, so it stays in. Two findings that still hash the same are told apart by a +counter: `a1b2c3d4e5f6`, `a1b2c3d4e5f6-2`. ## Who answers @@ -258,8 +432,24 @@ A case says what a reading must find and what it must not raise. The silences ma more: the persistent failure is reporting something true and unwanted, and every rule in `criteria/` that reads like an exception was written to stop one. +## Languages + +Go is read with the standard library's parser. TypeScript and JavaScript — `.ts`, +`.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs` — are read through ast-grep when it is on the +path, by pattern; Python and Rust through the same ast-grep, by node kind — a function +is whatever the grammar calls one, and its name is read out of the match. Odin is read +through the `odin-review-extract` sidecar, built from this repository, when it is. +Every other language gets its comments read by shape, the message and history checks, +and the line-shaped code checks; the jobs that need declarations or test bodies are +skipped for its files, with a line on stderr saying so, and the files are named in the +report's `uncovered` list. The repository is read by the +tool itself: git is asked for the diff, the history and one archive of the tree, and +everything after that is a loop over memory, the same on every system. + ## What it is not -It does not check formatting, vet, lint, dead code, or vulnerabilities. Those have -deterministic tools that are better at it, cost nothing to run, and can be trusted to -gate a merge. Point this at what is left over. +It does not format, and it writes no analyser of its own where a language already has +one: the compilers, vet, staticcheck, clippy, ruff, mypy and semgrep are run as they +are, with their strictest settings, and their word is kept to the change. The code +checks here measure what those tools leave alone — a test that asserts nothing, a body +written twice, a name nothing refers to — and hand the rest to the readings. diff --git a/rules.go b/rules.go @@ -0,0 +1,198 @@ +package main + +// The rules are what an agent is handed. A finding cites one by id, and the +// id has to lead somewhere: the criteria a job judges against, or the +// description of the deterministic check that measured it. Printing them +// from the binary is what makes the rules travel with it. + +import ( + "cmp" + "flag" + "fmt" + "maps" + "slices" + "strings" +) + +// staticRule describes one deterministic check, for the reader who met its +// id in a finding. +type staticRule struct { + ID string + Description string +} + +// staticRules are the deterministic checks, in the order the readme lists +// them. Each id here is one a finding can carry with Job "static". +var staticRules = []staticRule{ + {"go-build", "the Go compiler cannot build a package the change touched; must-fix wherever the error lands"}, + {"go-vet/<analyzer>", "go vet's finding, on a line the change adds — with review-vet's extra analysers where it is installed; nilness and vet's own set are must-fix, shadow and unusedwrite consider, modernize note"}, + {"staticcheck/<code>", "the Go analyser's finding, on a line the change adds; SA is must-fix, S and U consider, the rest note"}, + {"odin-check", "odin check -vet -strict-style cannot check a package the change touched: a type error is must-fix wherever it lands, a vet failure consider, a style failure note"}, + {"tsc/<code>", "the TypeScript compiler's error under the project's own tsconfig; must-fix wherever it lands; working tree only"}, + {"ruff/<code>", "ruff's finding on a line the change adds; pyflakes codes consider, style codes note"}, + {"mypy/<code>", "mypy's type error on a line the change adds, missing imports ignored; working tree only"}, + {"cargo/<code>", "cargo check's — or clippy's — diagnostic: an error is must-fix wherever it lands, a warning consider on a line the change adds"}, + {"semgrep/<rule>", "a semgrep match on a line the change adds, under the repository's own .semgrep.yml or the registry's p/default pack; the rule's severity is kept: ERROR must-fix, WARNING consider, INFO note"}, + {"message-low-entropy", "the commit message's Shannon entropy is under 3.2 bits per byte: a phrase repeated rather than a description"}, + {"message-boilerplate", "the commit message keeps under 20% of its length after zlib: a block of text pasted or repeated whole"}, + {"message-common-words", "the commit message is made only of the repository's commonest commit words and names nothing the change touches"}, + {"message-frustration", "the commit message is an exclamation — oops, whoops, damn — where a description should be"}, + {"message-not-imperative", "the subject's first word after its package prefix is past tense, a gerund, or the author"}, + {"message-no-body", "a change over 50 lines carries no body, and the diff does not only move text around"}, + {"message-long-body", "the body is over 150 words, listing what the diff already shows"}, + {"history-coupled-file", "history ties a changed file to a partner the change does not touch: Jaccard at least 0.7 over at least 5 shared commits"}, + {"message-names-unknown", "the commit message names an identifier, path or call that is in neither the diff nor the repository at the end of the change"}, + {"formatting-mixed-in", "half or more of the change's lines change only whitespace, and at least 10 change something else: a reformatting with logic in it"}, + {"suppression-added", "the change adds its own review:ignore dismissal, before the readers have run"}, + {"test-deleted", "the change deletes a test function that no added test renames"}, + {"no-stutter", "an exported name's first word is its package: ico.IcoEntry says ico twice"}, + {"no-shadow", "a new name is a predeclared identifier, a standard library package, or a runtime global"}, + {"abbreviation", "a new name carries an invented abbreviation: cfg, mgr, hdlr, svc, btn, cnt"}, + {"test-no-assertion", "an added or altered test has no call that could fail it; it can only fail by crashing"}, + {"assertion-always-true", "an assertion in an added or altered test holds whatever the code does: a literal true, two literals, or a value against itself"}, + {"duplicate-body", "a new function's body already exists, token for token (must-fix) or in shape with every name changed (consider)"}, + {"function-too-long", "a new function is over 150 lines; 95% of measured functions fit in 109"}, + {"nesting-too-deep", "a new function nests blocks more than 5 deep; 99% of measured functions stay within 6"}, + {"comment-restates-code", "every content word of an added comment is in the line of code below it"}, + {"todo-without-reference", "an added TODO, FIXME, XXX or HACK names no issue, ticket, link or person"}, + {"commented-out-code", "an added comment run holds code: two statement-shaped lines, or one beyond doubt"}, + {"debug-leftover", "an added line is a debugger statement, a breakpoint, dbg!, spew.Dump, console.log, or a DEBUG print"}, + {"error-swallowed", "an added line drops an error: _ = err, an empty catch, an except that passes"}, + {"new-symbol-unreferenced", "nothing in the repository refers to a new declaration but its own line"}, + {"code-without-tests", "the change adds 50 or more lines of code, touches no test, and the repository keeps tests"}, +} + +// Every rule above is one Checks() can report; the list is what review rules +// prints, and the test that every reported rule is described keeps the two +// in step. + +// printRules prints the criteria for a job, the description of a rule, or +// everything, so that an agent given a finding can read what it was judged +// against without leaving the terminal. With -dismissed it prints instead +// how often each rule is dismissed in the working tree: a rule dismissed +// everywhere is a rule to rewrite. +func printRules(args []string) error { + flags := flag.NewFlagSet("rules", flag.ContinueOnError) + dismissed := flags.Bool("dismissed", false, "Count the review:ignore dismissals in the tree, per rule.") + if err := flags.Parse(args); err != nil { + return err + } + if *dismissed { + return printDismissals() + } + which := flags.Arg(0) + if which == "" { + fmt.Print("# Deterministic checks\n\n") + for _, r := range staticRules { + fmt.Printf("- `%s` — %s\n", r.ID, r.Description) + } + for _, job := range Jobs() { + fmt.Printf("\n%s", job.Criteria) + } + return nil + } + for _, job := range Jobs() { + if job.Name == which { + fmt.Print(job.Criteria) + return nil + } + } + if job, text, ok := criterion(which); ok { + fmt.Printf("%s, from the %s criteria:\n\n%s\n", which, job, text) + return nil + } + family, _, _ := strings.Cut(which, "/") + for _, r := range staticRules { + if r.ID == which || (strings.Contains(which, "/") && strings.HasPrefix(r.ID, family+"/")) { + fmt.Printf("%s, a deterministic check:\n\n%s\n", which, r.Description) + return nil + } + } + names := make([]string, 0, len(Jobs())) + for _, job := range Jobs() { + names = append(names, job.Name) + } + slices.Sort(names) + return fmt.Errorf("no job or rule called %q; the jobs are %s", which, strings.Join(names, ", ")) +} + +// printDismissals counts the dismissals in the working tree per rule, with +// where each is and why, so that the rules people argue with are visible. +func printDismissals() error { + root, err := repository() + if err != nil { + return err + } + tree, err := treeAt(root, "") + if err != nil { + return err + } + sources, err := tree.Sources() + if err != nil { + return err + } + type spot struct { + where, why string + } + byRule := map[string][]spot{} + for _, file := range slices.Sorted(maps.Keys(sources)) { + if !isCodeFile(file) { + continue + } + for i, line := range strings.Split(string(sources[file]), "\n") { + m := ignore.FindStringSubmatch(line) + if m == nil || !ruleID.MatchString(m[1]) { + continue + } + // A dismissal quoted inside a string literal, as a test's + // fixture is, ends where the literal's line does. + why := strings.TrimSpace(strings.SplitN(m[2], `\n`, 2)[0]) + why = strings.TrimSpace(strings.TrimSuffix(why, `"`)) + if why == "" { + why = "no reason given" + } + byRule[m[1]] = append(byRule[m[1]], spot{fmt.Sprintf("%s:%d", file, i+1), why}) + } + } + if len(byRule) == 0 { + fmt.Println("no dismissals in the tree") + return nil + } + rules := slices.SortedFunc(maps.Keys(byRule), func(a, b string) int { + if c := cmp.Compare(len(byRule[b]), len(byRule[a])); c != 0 { + return c + } + return cmp.Compare(a, b) + }) + for _, rule := range rules { + spots := byRule[rule] + fmt.Printf("%-28s %d\n", rule, len(spots)) + for _, s := range spots { + fmt.Printf(" %s %s\n", s.where, s.why) + } + } + return nil +} + +// criterion finds the bullet that defines a rule, in whichever job's +// criteria holds it, with the lines that continue it. +func criterion(id string) (job, text string, ok bool) { + marker := "- `" + id + "`" + for _, j := range Jobs() { + lines := strings.Split(j.Criteria, "\n") + for i, line := range lines { + if !strings.HasPrefix(line, marker) { + continue + } + var kept []string + for _, l := range lines[i:] { + if len(kept) > 0 && !strings.HasPrefix(l, " ") { + break + } + kept = append(kept, l) + } + return j.Name, strings.Join(kept, "\n"), true + } + } + return "", "", false +} diff --git a/rules_test.go b/rules_test.go @@ -0,0 +1,90 @@ +package main + +import ( + "strings" + "testing" +) + +func TestPrintRules(t *testing.T) { + all := capture(t, func() { + if err := printRules(nil); err != nil { + t.Error(err) + } + }) + for _, want := range []string{"# Deterministic checks", "`test-deleted`", "# Naming", "# Test integrity", "`cannot-fail`"} { + if !strings.Contains(all, want) { + t.Errorf("%q missing from the whole listing", want) + } + } + job := capture(t, func() { + if err := printRules([]string{"tests"}); err != nil { + t.Error(err) + } + }) + if !strings.Contains(job, "`cannot-fail`") || strings.Contains(job, "# Naming") { + t.Errorf("the tests criteria were not printed alone:\n%s", job) + } + one := capture(t, func() { + if err := printRules([]string{"noun-for-type"}); err != nil { + t.Error(err) + } + }) + if !strings.Contains(one, "from the namer criteria") || !strings.Contains(one, "reads wrong in the plural") { + t.Errorf("the rule was not printed with its continuation:\n%s", one) + } + static := capture(t, func() { + if err := printRules([]string{"duplicate-body"}); err != nil { + t.Error(err) + } + }) + if !strings.Contains(static, "deterministic check") { + t.Errorf("got %s", static) + } + if err := printRules([]string{"nothing-here"}); err == nil || !strings.Contains(err.Error(), "the jobs are") { + t.Errorf("an unknown rule was not refused with the job names: %v", err) + } +} + +// Every deterministic check's rule id is described, so a finding can be +// looked up. +func TestEveryStaticRuleIsDescribed(t *testing.T) { + described := map[string]bool{} + for _, r := range staticRules { + described[r.ID] = true + } + for _, id := range []string{ + "message-low-entropy", "message-boilerplate", "message-common-words", "message-frustration", + "message-not-imperative", "message-no-body", "message-long-body", "message-names-unknown", "formatting-mixed-in", "assertion-always-true", + "history-coupled-file", "suppression-added", "test-deleted", "no-stutter", "no-shadow", + "abbreviation", "test-no-assertion", "duplicate-body", "function-too-long", "nesting-too-deep", + "comment-restates-code", "todo-without-reference", "commented-out-code", "debug-leftover", + "error-swallowed", "new-symbol-unreferenced", "code-without-tests", "go-build", "odin-check", + } { + if !described[id] { + t.Errorf("%s is not described", id) + } + } +} + +func TestPrintDismissals(t *testing.T) { + r := newRepo(t) + r.write("x.go", "package x\n\n//review:ignore no-stutter the idiom\nvar a = 1\n\n//review:ignore abbreviation\nvar cfg = 2\n\n//review:ignore no-stutter again\nvar b = 3\n") + r.write("readme.md", "//review:ignore <rule> <why> is the shape\n") + r.commit("first", "x.go", "readme.md") + t.Chdir(r.Root) + out := capture(t, func() { + if err := printRules([]string{"-dismissed"}); err != nil { + t.Fatal(err) + } + }) + lines := strings.Split(strings.TrimSpace(out), "\n") + if !strings.HasPrefix(lines[0], "no-stutter") || !strings.Contains(lines[0], " 2") { + t.Errorf("the most dismissed rule is not first:\n%s", out) + } + if !strings.Contains(out, "x.go:6 no reason given") || !strings.Contains(out, "x.go:3 the idiom") { + t.Errorf("got:\n%s", out) + } + if strings.Contains(out, "<rule>") { + t.Errorf("prose counted as a dismissal:\n%s", out) + } +} diff --git a/shape.go b/shape.go @@ -0,0 +1,102 @@ +package main + +// A function's size and depth are measured, not judged. The thresholds +// were read off 8,255 functions in the repositories on the author's +// machine: length sits between the 95th percentile (109 lines) and the +// 99th (257); depth at the 99th (6). + +import "fmt" + +const ( + // maxFunctionLines is the length past which a function is several. + maxFunctionLines = 150 + // maxNesting is the block depth past which a reader is holding more + // context than the function's name gave them. + maxNesting = 5 +) + +// checkShape reports a new function that is too long or too deeply +// nested to read as one thing. +func checkShape(c *Change) []Finding { + var out []Finding + for _, s := range c.Symbols { + if s.Kind != "func" || s.Body == "" { + continue + } + if n := lineCount(s.Body); n > maxFunctionLines { + out = append(out, Finding{ + Job: "static", Rule: "function-too-long", Severity: Consider, + File: s.File, Line: s.Line, Symbol: s.Name, + Message: fmt.Sprintf("%s is %d lines, over the %d past which a function is several; 95%% of measured functions fit in 109", s.Name, n, maxFunctionLines), + Fix: "split it at the point where the reader has to remember what came before", + }) + } + if d := nesting(s.Body); d > maxNesting { + out = append(out, Finding{ + Job: "static", Rule: "nesting-too-deep", Severity: Consider, + File: s.File, Line: s.Line, Symbol: s.Name, + Message: fmt.Sprintf("%s nests %d blocks deep, over the %d past which a reader is holding more than the name told them; 99%% of measured functions stay within 6", s.Name, d, maxNesting), + Fix: "return early, or lift the inner blocks into functions of their own", + }) + } + } + return out +} + +func lineCount(body string) int { + n := 1 + for i := 0; i < len(body); i++ { + if body[i] == '\n' { + n++ + } + } + return n +} + +// nesting is the deepest block within a body, counted by braces with +// strings and comments read through, less the body's own pair. +func nesting(body string) int { + depth, deepest := 0, 0 + var ( + inString, inRaw, inLine, inBlock bool + quote byte + ) + for i := 0; i < len(body); i++ { + c := body[i] + switch { + case inLine: + if c == '\n' { + inLine = false + } + case inBlock: + if c == '*' && i+1 < len(body) && body[i+1] == '/' { + inBlock = false + i++ + } + case inRaw: + if c == '`' { + inRaw = false + } + case inString: + if c == '\\' { + i++ + } else if c == quote || c == '\n' { + inString = false + } + case c == '/' && i+1 < len(body) && body[i+1] == '/': + inLine = true + case c == '/' && i+1 < len(body) && body[i+1] == '*': + inBlock = true + case c == '`': + inRaw = true + case c == '"' || c == '\'': + inString, quote = true, c + case c == '{': + depth++ + deepest = max(deepest, depth) + case c == '}': + depth-- + } + } + return max(deepest-1, 0) +} diff --git a/shape_test.go b/shape_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "strings" + "testing" +) + +func TestNesting(t *testing.T) { + for _, test := range []struct { + body string + want int + }{ + {"func f() {}", 0}, + {"func f() {\n\tif a {\n\t}\n}", 1}, + {"func f() {\n\tif a {\n\t\tfor b {\n\t\t\tswitch c {\n\t\t\t}\n\t\t}\n\t}\n}", 3}, + {"func f() {\n\ts := \"{{{\"\n\tr := `{`\n\t// {\n\t/* { */\n}", 0}, + {"func f() {\n\tc := '{'\n\tif a {\n\t}\n}", 1}, + } { + if got := nesting(test.body); got != test.want { + t.Errorf("%q: %d, want %d", test.body, got, test.want) + } + } +} + +func TestCheckShape(t *testing.T) { + long := "func f() {\n" + strings.Repeat("\tx++\n", maxFunctionLines) + "}" + deep := "func g() {\n" + strings.Repeat("\tif a {\n", maxNesting+1) + strings.Repeat("\t}\n", maxNesting+1) + "}" + c := &Change{Symbols: []Symbol{ + {Name: "f", Kind: "func", File: "x.go", Line: 1, Body: long}, + {Name: "g", Kind: "func", File: "x.go", Line: 200, Body: deep}, + {Name: "h", Kind: "func", File: "x.go", Line: 300, Body: "func h() {}"}, + {Name: "T", Kind: "type", File: "x.go", Line: 400}, + }} + got := rulesOf(checkShape(c)) + if len(got) != 2 || got[0] != "function-too-long" || got[1] != "nesting-too-deep" { + t.Errorf("got %v", got) + } +} diff --git a/sidecar/govet/go.mod b/sidecar/govet/go.mod @@ -0,0 +1,10 @@ +module github.com/jackmordaunt/review/sidecar/govet + +go 1.27.0 + +require golang.org/x/tools v0.50.0 + +require ( + golang.org/x/mod v0.41.0 // indirect + golang.org/x/sync v0.23.0 // indirect +) diff --git a/sidecar/govet/go.sum b/sidecar/govet/go.sum @@ -0,0 +1,8 @@ +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= +golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= diff --git a/sidecar/govet/main.go b/sidecar/govet/main.go @@ -0,0 +1,87 @@ +// review-vet is the analysis tool review hands to go vet with -vettool: the +// analysers vet runs by default, and the ones from golang.org/x/tools it +// leaves out — nilness, shadow, unusedwrite, the checks on sort, reflect and +// atomic misuse, and the modernize suite. Build it and put it on the path: +// +// go build -o ~/go/bin/review-vet ./sidecar/govet +// +// Without it, review runs vet's default set. +package main + +import ( + "golang.org/x/tools/go/analysis" + "golang.org/x/tools/go/analysis/multichecker" + "golang.org/x/tools/go/analysis/passes/appends" + "golang.org/x/tools/go/analysis/passes/asmdecl" + "golang.org/x/tools/go/analysis/passes/assign" + "golang.org/x/tools/go/analysis/passes/atomic" + "golang.org/x/tools/go/analysis/passes/atomicalign" + "golang.org/x/tools/go/analysis/passes/bools" + "golang.org/x/tools/go/analysis/passes/buildtag" + "golang.org/x/tools/go/analysis/passes/cgocall" + "golang.org/x/tools/go/analysis/passes/composite" + "golang.org/x/tools/go/analysis/passes/copylock" + "golang.org/x/tools/go/analysis/passes/deepequalerrors" + "golang.org/x/tools/go/analysis/passes/defers" + "golang.org/x/tools/go/analysis/passes/directive" + "golang.org/x/tools/go/analysis/passes/errorsas" + "golang.org/x/tools/go/analysis/passes/framepointer" + "golang.org/x/tools/go/analysis/passes/hostport" + "golang.org/x/tools/go/analysis/passes/httpmux" + "golang.org/x/tools/go/analysis/passes/httpresponse" + "golang.org/x/tools/go/analysis/passes/ifaceassert" + "golang.org/x/tools/go/analysis/passes/loopclosure" + "golang.org/x/tools/go/analysis/passes/lostcancel" + "golang.org/x/tools/go/analysis/passes/modernize" + "golang.org/x/tools/go/analysis/passes/nilfunc" + "golang.org/x/tools/go/analysis/passes/nilness" + "golang.org/x/tools/go/analysis/passes/printf" + "golang.org/x/tools/go/analysis/passes/reflectvaluecompare" + "golang.org/x/tools/go/analysis/passes/scannererr" + "golang.org/x/tools/go/analysis/passes/shadow" + "golang.org/x/tools/go/analysis/passes/shift" + "golang.org/x/tools/go/analysis/passes/sigchanyzer" + "golang.org/x/tools/go/analysis/passes/slog" + "golang.org/x/tools/go/analysis/passes/sortslice" + "golang.org/x/tools/go/analysis/passes/sqlrowserr" + "golang.org/x/tools/go/analysis/passes/stdmethods" + "golang.org/x/tools/go/analysis/passes/stdversion" + "golang.org/x/tools/go/analysis/passes/stringintconv" + "golang.org/x/tools/go/analysis/passes/structtag" + "golang.org/x/tools/go/analysis/passes/testinggoroutine" + "golang.org/x/tools/go/analysis/passes/tests" + "golang.org/x/tools/go/analysis/passes/timeformat" + "golang.org/x/tools/go/analysis/passes/unmarshal" + "golang.org/x/tools/go/analysis/passes/unreachable" + "golang.org/x/tools/go/analysis/passes/unsafeptr" + "golang.org/x/tools/go/analysis/passes/unusedresult" + "golang.org/x/tools/go/analysis/passes/unusedwrite" + "golang.org/x/tools/go/analysis/passes/waitgroup" +) + +func main() { + // Vet's own set first, as vet runs it. + analyzers := []*analysis.Analyzer{ + appends.Analyzer, asmdecl.Analyzer, assign.Analyzer, atomic.Analyzer, bools.Analyzer, + buildtag.Analyzer, cgocall.Analyzer, composite.Analyzer, copylock.Analyzer, defers.Analyzer, + directive.Analyzer, errorsas.Analyzer, framepointer.Analyzer, hostport.Analyzer, + httpresponse.Analyzer, ifaceassert.Analyzer, loopclosure.Analyzer, lostcancel.Analyzer, + nilfunc.Analyzer, printf.Analyzer, shift.Analyzer, sigchanyzer.Analyzer, slog.Analyzer, + stdmethods.Analyzer, stdversion.Analyzer, stringintconv.Analyzer, structtag.Analyzer, + testinggoroutine.Analyzer, tests.Analyzer, timeformat.Analyzer, unmarshal.Analyzer, + unreachable.Analyzer, unsafeptr.Analyzer, unusedresult.Analyzer, waitgroup.Analyzer, + } + // The ones vet leaves out. nilness needs SSA and finds nil dereferences + // and impossible comparisons; the sort, reflect, sql and bufio checks + // find misuse the type checker allows; shadow and unusedwrite are + // judgement calls, reported as such. + analyzers = append(analyzers, + nilness.Analyzer, atomicalign.Analyzer, deepequalerrors.Analyzer, httpmux.Analyzer, + reflectvaluecompare.Analyzer, scannererr.Analyzer, sortslice.Analyzer, sqlrowserr.Analyzer, + shadow.Analyzer, unusedwrite.Analyzer, + ) + // modernize reports an older idiom where a newer one exists: taste, and + // review reports it as a note. + analyzers = append(analyzers, modernize.Suite...) + multichecker.Main(analyzers...) +} diff --git a/static.go b/static.go @@ -11,6 +11,7 @@ import ( "fmt" "math" "path/filepath" + "regexp" "slices" "strings" "unicode" @@ -19,11 +20,18 @@ import ( // Check measures a change and reports what it noticed. type Check func(*Change) []Finding -// Checks are the deterministic readings, each independent of the others. +// Checks are the deterministic readings, each independent of the others: +// the message, the history, the review's own mechanisms, and then the code +// the change adds — its names, tests, bodies, comments and leftovers. func Checks() []Check { - // The last two hold the review to itself: the mechanisms a change could - // use to pass it are measured rather than trusted absent. - return []Check{checkEntropy, checkCompressibility, checkCommon, checkVenting, checkMood, checkBody, checkTemporal, checkSuppressionAdded, checkDeletedTests} + return []Check{ + checkEntropy, checkCompressibility, checkCommon, checkVenting, checkMood, checkBody, checkFormatting, checkNamesUnknown, + checkTemporal, + checkSuppressionAdded, checkDeletedTests, + checkNames, checkTestAssertions, checkTautologies, checkClones, checkShape, + checkRestating, checkTodos, checkCommentedCode, checkDebugLeftovers, checkSwallowedErrors, + checkUnreferenced, checkCodeWithoutTests, + } } const ( @@ -110,7 +118,10 @@ func runChecks(change *Change) []Finding { // checkEntropy reports a message whose characters carry too little entropy: // the shape of a placeholder, a keyboard mash, or one phrase repeated. func checkEntropy(c *Change) []Finding { - msg, note := measured(c) + msg := measured(c) + if msg == "" { + return nil + } if len(msg) < entropyFloor { return nil } @@ -121,8 +132,8 @@ func checkEntropy(c *Change) []Finding { return []Finding{{ Job: "static", Rule: "message-low-entropy", Severity: MustFix, Message: fmt.Sprintf( - "the commit message measures %.1f bits of Shannon entropy per byte, under the %.1f beneath which a message is a phrase repeated rather than a description; ordinary messages measure 3.7 to 4.9%s", - h, minEntropy, note), + "the commit message measures %.1f bits of Shannon entropy per byte, under the %.1f beneath which a message is a phrase repeated rather than a description; ordinary messages measure 3.7 to 4.9", + h, minEntropy), Fix: "write a message that says what the change does and why", }} } @@ -131,7 +142,10 @@ func checkEntropy(c *Change) []Finding { // shape of text pasted or repeated wholesale, such as a licence notice or a // log, rather than prose written for this change. func checkCompressibility(c *Change) []Finding { - msg, note := measured(c) + msg := measured(c) + if msg == "" { + return nil + } if len(msg) < compressionFloor { return nil } @@ -142,8 +156,8 @@ func checkCompressibility(c *Change) []Finding { return []Finding{{ Job: "static", Rule: "message-boilerplate", Severity: MustFix, Message: fmt.Sprintf( - "the commit message compresses to %.0f%% of its length, under the %.0f%% beneath which it is one block of text pasted or repeated rather than prose about the change; no written message measured keeps less than 26%%%s", - 100*ratio, 100*maxCompression, note), + "the commit message compresses to %.0f%% of its length, under the %.0f%% beneath which it is one block of text pasted or repeated rather than prose about the change; no written message measured keeps less than 26%%", + 100*ratio, 100*maxCompression), Fix: "keep only what the reader needs of the quoted text, and write the rest", }} } @@ -154,7 +168,10 @@ func checkCompressibility(c *Change) []Finding { // like this cannot have, which is why a message holding any rarer word is // left to the reader. func checkCommon(c *Change) []Finding { - msg, note := measured(c) + msg := measured(c) + if msg == "" { + return nil + } if len(c.History) < historyFloor { return nil } @@ -187,16 +204,138 @@ func checkCommon(c *Change) []Finding { return []Finding{{ Job: "static", Rule: "message-common-words", Severity: MustFix, Message: fmt.Sprintf( - "the commit message is made of the repository's commonest commit words — %s — with nothing rarer than a fifth of its %d commit subjects, and it names nothing the change touches; a word the history has not used is the one thing a message like this cannot have%s", - strings.Join(listed, ", "), n, note), + "the commit message is made of the repository's commonest commit words — %s — with nothing rarer than a fifth of its %d commit subjects, and it names nothing the change touches; a word the history has not used is the one thing a message like this cannot have", + strings.Join(listed, ", "), n), Fix: "name the part and the fault, in words the change itself uses", }} } +// identifierShaped matches the words of a message that name code rather +// than describe it: a camel-cased or snake-cased word, a dotted or slashed +// path, a call, or anything in backticks. +var identifierShaped = regexp.MustCompile("`([^`]+)`|\\b([a-z][a-z0-9]*[A-Z][A-Za-z0-9]*)\\b|\\b([A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+)\\b|\\b([A-Za-z][A-Za-z0-9_]*\\(\\))|\\b([A-Za-z][A-Za-z0-9_-]*(?:[./][A-Za-z0-9_-]+)+\\.[a-z]{1,5})\\b") + +// links matches a URL in a message. +var links = regexp.MustCompile(`\bhttps?://\S+`) + +// checkNamesUnknown reports a message that names an identifier the +// repository does not hold: not in the diff, not in any file at the end of +// the change, not a path in the tree. A message naming code that is not +// there describes work the diff does not contain — usually a name remembered +// wrong, sometimes a change that was never made. +func checkNamesUnknown(c *Change) []Finding { + msg := measured(c) + if msg == "" || c.root == "" { + return nil + } + // A link's path is not a name the message uses. + msg = links.ReplaceAllString(msg, " ") + var names []string + seen := map[string]bool{} + for _, m := range identifierShaped.FindAllStringSubmatch(msg, -1) { + name := "" + for _, group := range m[1:] { + if group != "" { + name = group + } + } + name = strings.TrimSuffix(strings.TrimSpace(name), "()") + if name == "" || seen[name] || strings.ContainsAny(name, " \t") || strings.Contains(name, "://") || brandShaped(name) { + continue + } + seen[name] = true + names = append(names, name) + } + if len(names) == 0 { + return nil + } + tree, err := treeAt(c.root, c.rev) + if err != nil { + return nil + } + sources, err := tree.Sources() + if err != nil { + return nil + } + files, _ := tree.Files() + var missing []string + for _, name := range names { + if strings.Contains(c.Diff, name) || strings.Contains(c.Stat, name) { + continue + } + found := false + for _, f := range files { + if strings.Contains(f, name) { + found = true + break + } + } + for _, data := range sources { + if found { + break + } + if strings.Contains(string(data), name) { + found = true + } + } + if !found { + missing = append(missing, name) + } + } + if len(missing) == 0 { + return nil + } + return []Finding{{ + Job: "static", Rule: "message-names-unknown", Severity: Consider, + Message: fmt.Sprintf("the commit message names %s, and nothing by that name is in the diff or anywhere in the repository at the end of the change; a message naming code that is not there describes work the diff does not contain", quoted(missing)), + Fix: "name what the change actually touches, as the code spells it", + }} +} + +// brands are the product names written with an inner capital, which the +// identifier shape mistakes for camel case. Measured: gRPC was the first. +var brands = set(`gRPC iOS macOS iPadOS watchOS tvOS iPhone iPad iCloud eBay jQuery + PayPal YouTube GitHub GitLab OpenAI WebAssembly LaTeX TeX`) + +// brandShaped is whether a word is a product name rather than a name from +// the code: on the list, or a short lowercase prefix before a run of +// capitals, which is how gRPC and iOS are spelled and how no identifier is; +// macOS is longer and on the list. +func brandShaped(word string) bool { + if brands[word] { + return true + } + i := 0 + for i < len(word) && word[i] >= 'a' && word[i] <= 'z' { + i++ + } + if i == 0 || i > 2 || i == len(word) { + return false + } + for _, r := range word[i:] { + if r < 'A' || r > 'Z' { + return false + } + } + return true +} + +// quoted joins names for a message, each in quotes. +func quoted(names []string) string { + out := make([]string, len(names)) + for i, n := range names { + out[i] = fmt.Sprintf("%q", n) + } + return strings.Join(out, ", ") +} + // checkVenting reports a message whose words are the author's reaction // rather than the change's description: oops, whoops, damn, profanity. func checkVenting(c *Change) []Finding { - msg, note := measured(c) + msg := measured(c) + if msg == "" { + return nil + } var hit []string for _, piece := range fields(msg) { w := strings.ToLower(piece) @@ -212,8 +351,8 @@ func checkVenting(c *Change) []Finding { return []Finding{{ Job: "static", Rule: "message-frustration", Severity: MustFix, Message: fmt.Sprintf( - "the commit message is an exclamation — %s — where a description should be; the log then records the author's feeling, and the change goes undescribed%s", - strings.Join(hit, ", "), note), + "the commit message is an exclamation — %s — where a description should be; the log then records the author's feeling, and the change goes undescribed", + strings.Join(hit, ", ")), Fix: "describe the change, not the moment", }} } @@ -227,7 +366,10 @@ func checkVenting(c *Change) []Finding { // explainer may be a noun phrase on purpose, as the tool's own subjects // are. func checkMood(c *Change) []Finding { - msg, note := measured(c) + msg := measured(c) + if msg == "" { + return nil + } subject := strings.SplitN(msg, "\n", 2)[0] if strings.HasPrefix(subject, "Merge ") || strings.HasPrefix(subject, "Squashed ") { return nil @@ -254,8 +396,8 @@ func checkMood(c *Change) []Finding { return []Finding{{ Job: "static", Rule: "message-not-imperative", Severity: MustFix, Message: fmt.Sprintf( - "the commit message opens on %s — %q — where the discipline is a command: package: explainer, with the explainer in the imperative mood; a subject that opens in the past tense, on a gerund, or on the author records what was done rather than saying what to do%s", - what, quoted, note), + "the commit message opens on %s — %q — where the discipline is a command: package: explainer, with the explainer in the imperative mood; a subject that opens in the past tense, on a gerund, or on the author records what was done rather than saying what to do", + what, quoted), Fix: "open the subject on its verb, in the imperative", }} } @@ -321,7 +463,10 @@ var nonVerbIng = map[string]bool{ // what it did, owes nothing. Subjects of merges and squashes are git's // words, not the commit's. func checkBody(c *Change) []Finding { - msg, note := measured(c) + msg := measured(c) + if msg == "" { + return nil + } subject := strings.SplitN(msg, "\n", 2)[0] if strings.HasPrefix(subject, "Merge ") || strings.HasPrefix(subject, "Squashed ") { return nil @@ -331,8 +476,8 @@ func checkBody(c *Change) []Finding { return []Finding{{ Job: "static", Rule: "message-long-body", Severity: MustFix, Message: fmt.Sprintf( - "the commit message's body holds %d words, over the %d the discipline allows; past that a body lists what the diff already shows, and the reader of the log stops before the why%s", - n, maxBodyWords, note), + "the commit message's body holds %d words, over the %d the discipline allows; past that a body lists what the diff already shows, and the reader of the log stops before the why", + n, maxBodyWords), Fix: "cut the body to the change's why", }} } @@ -346,8 +491,8 @@ func checkBody(c *Change) []Finding { return []Finding{{ Job: "static", Rule: "message-no-body", Severity: MustFix, Message: fmt.Sprintf( - "the change is %d lines of diff, and the commit message carries no body; the diff records what moved, and the body is the only place the change's why is recorded%s", - changed, note), + "the change is %d lines of diff, and the commit message carries no body; the diff records what moved, and the body is the only place the change's why is recorded", + changed), Fix: "write the body, saying why the change is what it is", }} } @@ -457,8 +602,7 @@ func moved(diff string) bool { return n > 0 } -// bodyWords counts the words below the subject line. The staged note is not -// the commit's words and is already gone from the measured message. +// bodyWords counts the words below the subject line. func bodyWords(msg string) int { parts := strings.SplitN(msg, "\n", 2) if len(parts) == 1 { @@ -589,16 +733,11 @@ func diffWords(c *Change) map[string]bool { return out } -// measured is the message the checks read, and, where the change is staged, -// the note that what was measured is the previous commit's message rather -// than the staged one's. The note itself is kept out of the measurement: it -// is the tool's own words, not the commit's. -func measured(c *Change) (msg, note string) { - msg = strings.TrimSpace(strings.TrimPrefix(c.Message, uncommittedNote)) - if strings.HasPrefix(c.Message, uncommittedNote) { - note = " (measured on the previous commit's message: the change is staged, not committed)" - } - return msg, note +// measured is the message the checks read. It is empty for a staged change +// without a supplied message, and every message check says nothing then: +// there is no message to measure, and the previous commit's is not it. +func measured(c *Change) string { + return strings.TrimSpace(c.Message) } // shannonEntropy is the entropy of s in bits per byte, over its bytes. A diff --git a/static_test.go b/static_test.go @@ -77,16 +77,31 @@ func TestShortMessagesAreNotMeasured(t *testing.T) { } } -// A staged change carries the previous commit's message, so a finding says so -// rather than letting the reader believe the staged work was measured. -func TestStagedChangeMeasuresThePreviousMessage(t *testing.T) { - change := &Change{Message: uncommittedNote + strings.Repeat("asdf asdf ", 6)} - findings := runChecks(change) - if len(findings) != 1 { - t.Fatalf("got %d findings:\n%v", len(findings), findings) +// A staged change has no message of its own, so nothing measures one: the +// previous commit's message described other work, and a finding about it +// would send the author to fix a commit the change did not make. +func TestStagedChangeMeasuresNoMessage(t *testing.T) { + r := newRepo(t) + r.write("seed.txt", "seed\n") + r.commit(strings.Repeat("asdf asdf ", 6), "seed.txt") + parts := []string{"package x\n\n"} + for i := 0; i < 60; i++ { + parts = append(parts, fmt.Sprintf("var v%d = %d\n", i, i)) + } + r.write("x.go", strings.Join(parts, "")) + r.stage("x.go") + + change, err := Gather("", r.Root) + if err != nil { + t.Fatal(err) } - if !strings.Contains(findings[0].Message, "previous commit") { - t.Errorf("does not say what it measured: %s", findings[0].Message) + if change.Message != "" { + t.Fatalf("a staged change carries a message: %q", change.Message) + } + for _, f := range runChecks(change) { + if strings.HasPrefix(f.Rule, "message-") { + t.Errorf("measured a message that does not exist: %v", f) + } } } @@ -392,8 +407,8 @@ func TestBodyIsCapped(t *testing.T) { } } -// A large subject-only staged change is caught through Gather too, where -// the message measured is the previous commit's. +// A large subject-only change is caught through Gather too, with the +// message a commit-msg hook would supply for the staged change. func TestBodyAgainstARealCommit(t *testing.T) { r := newRepo(t) r.write("seed.txt", "seed\n") @@ -410,7 +425,8 @@ func TestBodyAgainstARealCommit(t *testing.T) { if err != nil { t.Fatal(err) } - findings := runChecks(change) + change.Message = "x: add sixty variables" + findings := only(runChecks(change), "message-") if len(findings) != 1 || findings[0].Rule != "message-no-body" { t.Errorf("got %v", findings) } @@ -482,7 +498,7 @@ func TestTemporalAgainstARealCommit(t *testing.T) { if err != nil { t.Fatal(err) } - findings := runChecks(change) + findings := only(runChecks(change), "history-") if len(findings) != 1 || findings[0].Rule != "history-coupled-file" { t.Errorf("got %v", findings) return @@ -499,7 +515,7 @@ func TestTemporalAgainstARealCommit(t *testing.T) { if err != nil { t.Fatal(err) } - if findings := runChecks(change); len(findings) != 0 { + if findings := only(runChecks(change), "history-"); len(findings) != 0 { t.Errorf("got %v", findings) } } @@ -527,13 +543,13 @@ func TestTemporalSparesAGonePartner(t *testing.T) { if err != nil { t.Fatal(err) } - if findings := runChecks(change); len(findings) != 0 { + if findings := only(runChecks(change), "history-"); len(findings) != 0 { t.Errorf("got %v", findings) } } -// Through Gather, the check reads the history git holds, and a staged -// change is measured on the previous commit's message and says so. +// Through Gather, the check reads the history git holds, against the +// message supplied for the staged change. func TestCommonWordsAgainstARealHistory(t *testing.T) { r := newRepo(t) for i := 0; i < 110; i++ { @@ -546,13 +562,11 @@ func TestCommonWordsAgainstARealHistory(t *testing.T) { if err != nil { t.Fatal(err) } - findings := runChecks(change) + change.Message = "fix build" + findings := only(runChecks(change), "message-") if len(findings) != 1 || findings[0].Rule != "message-common-words" { t.Fatalf("got %v", findings) } - if !strings.Contains(findings[0].Message, "previous commit") { - t.Errorf("does not say what it measured: %s", findings[0].Message) - } } const licenceLine = "Copyright 2026 Example Corp. All rights reserved. Licensed under the Apache License, Version 2.0.\n" @@ -595,3 +609,57 @@ func TestChecksHoldTheChangeToTheReview(t *testing.T) { } } } + +// only keeps the findings whose rule opens with the prefix, so a test of +// one check against a real repository is not answered by the others. +func only(findings []Finding, prefix string) []Finding { + var out []Finding + for _, f := range findings { + if strings.HasPrefix(f.Rule, prefix) { + out = append(out, f) + } + } + return out +} + +// A message naming code that is nowhere in the repository describes work +// the diff does not contain. +func TestNamesUnknown(t *testing.T) { + r := newRepo(t) + r.write("x.go", "package x\n\nfunc readConfig() {}\n") + r.commit("first", "x.go") + r.write("x.go", "package x\n\nfunc readConfig() {}\n\nfunc parseFlags() {}\n") + r.write("docs/notes.md", "notes\n") + r.stage("x.go", "docs/notes.md") + change, err := Gather("", r.Root) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + message string + fires bool + }{ + {"x: add parseFlags beside readConfig", false}, // both exist + {"x: add parseConfig()", true}, // remembered wrong + {"x: add `load_settings` for docs/notes.md", true}, // backticked, unknown + {"x: touch docs/notes.md", false}, // a path in the tree + {"x: make the reader faster", false}, // no identifier at all + {"x: see https://example.com/parseConfig", false}, // a link is not a name + } { + change.Message = test.message + got := only(runChecks(change), "message-names-unknown") + if (len(got) == 1) != test.fires { + t.Errorf("%q: got %v", test.message, got) + } + } +} + +func TestBrandShaped(t *testing.T) { + for word, want := range map[string]bool{ + "gRPC": true, "iOS": true, "macOS": true, "eBay": true, "getID": false, "parseConfig": false, "readURL": false, "id": false, + } { + if got := brandShaped(word); got != want { + t.Errorf("%s: %v, want %v", word, got, want) + } + } +} diff --git a/staticcheck.go b/staticcheck.go @@ -1,138 +1,55 @@ package main -// staticcheck is the repository's own Go analyser, and review has nothing to -// teach it about Go. The check below only decides which of its findings -// belong to the change: staticcheck is free to say what it likes about the -// rest of the repository, and review's contract is the change, so a finding -// is kept only when it lands on a line the change adds. +// staticcheck is the Go analyser beyond vet, and review has nothing to teach +// it about Go. It runs as one of the analysers, over the packages the +// change touches, and its findings are kept to the change's added lines +// like every other analyser's opinions. import ( - "bytes" "context" "encoding/json" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" "regexp" - "slices" "strings" - "time" ) -// staticcheckTimeout bounds one run. A cold first analysis of a large module -// can take minutes; past this the check says nothing rather than holding the -// review, and the run warms the build cache for the next one. -const staticcheckTimeout = 5 * time.Minute - // checkCode matches a staticcheck finding code, such as SA4006 or S1002. // Anything else staticcheck emits — compile errors, for instance — is not a -// finding about the code. +// finding about the code, and the build has already reported it. var checkCode = regexp.MustCompile(`^[A-Z]+[0-9]+$`) -// checkStaticcheck runs staticcheck over the packages the change touches and -// reports what it found on the lines the change adds. It is called beside -// the other deterministic checks, so its findings survive a model that -// cannot answer. It says nothing where it has nothing to say: without the -// binary, without Go files, or without a module to analyse. -func checkStaticcheck(root, rev string, c *Change) []Finding { - if !touchesGo(c) { - return nil - } - if _, err := exec.LookPath("staticcheck"); err != nil { - return nil - } - tree := root - after, ranged := ends(rev) - if ranged { - // The change arrived at a tree the working directory may have left - // behind long ago, so the analysis runs on that tree, materialised - // to a scratch directory the way the other frontends do. - tmp, err := os.MkdirTemp("", "review-staticcheck-") - if err != nil { - noteStaticcheck("cannot make a scratch directory: %v", err) - return nil +// staticcheckAnalyser runs staticcheck with JSON output over the packages +// the change touched. +var staticcheckAnalyser = Analyser{ + Name: "staticcheck", + Covers: isGo, + Ready: goReady("staticcheck"), + Run: func(ctx context.Context, tree, root string, files []string) ([]Diagnostic, error) { + pkgs := goPackages(tree, files) + if len(pkgs) == 0 { + return nil, nil } - defer os.RemoveAll(tmp) - archive := filepath.Join(tmp, "tree.tar") - if _, err := git(root, "archive", "-o", archive, after); err != nil { - noteStaticcheck("cannot materialise %s: %v", after, err) - return nil + out, err := execute(ctx, tree, nil, "staticcheck", append([]string{"-f", "json"}, pkgs...)...) + if err != nil { + return nil, err } - if err := exec.Command("tar", "-xf", archive, "-C", tmp).Run(); err != nil { - noteStaticcheck("cannot unpack %s: %v", after, err) - return nil + var diagnostics []Diagnostic + for _, line := range strings.Split(string(out), "\n") { + var p scProblem + if json.Unmarshal([]byte(line), &p) != nil || !checkCode.MatchString(p.Code) { + continue + } + diagnostics = append(diagnostics, Diagnostic{ + File: relative(tree, p.Location.File), Line: p.Location.Line, + Code: p.Code, Message: p.Message, Severity: staticcheckSeverity(p.Code), + }) } - os.Remove(archive) - tree = tmp - } - if _, err := os.Stat(filepath.Join(tree, "go.mod")); err != nil { - fmt.Fprintln(os.Stderr, "skipping staticcheck: no go.mod at the repository root") - return nil - } - args := changedPackages(tree, c) - if len(args) == 0 { - return nil - } - - ctx, cancel := context.WithTimeout(context.Background(), staticcheckTimeout) - defer cancel() - cmd := exec.CommandContext(ctx, "staticcheck", append([]string{"-f", "json"}, args...)...) - cmd.Dir = tree - var stdout, stderr bytes.Buffer - cmd.Stdout, cmd.Stderr = &stdout, &stderr - err := cmd.Run() - if ctx.Err() != nil { - noteStaticcheck("a run past %v is not waited for", staticcheckTimeout) - } - if err != nil && ctx.Err() == nil { - var exit *exec.ExitError - if !errors.As(err, &exit) || exit.ExitCode() != 1 { - // Exit 1 is staticcheck reporting findings; anything else is a - // broken run, and its stderr is the only clue. - noteStaticcheck("%s", tail(stderr.String(), 200)) - } - } + return diagnostics, nil + }, +} - // The diff was capped before it was gathered, so a change larger than - // the cap is judged on the lines that survived, never on ones invented. - added := addedLines(c.Diff) - changed := map[string]bool{} - for _, f := range c.Files { - changed[f] = true - } - var ( - out []Finding - seen = map[string]bool{} - ) - for _, line := range strings.Split(stdout.String(), "\n") { - if line = strings.TrimSpace(line); line == "" { - continue - } - var p scProblem - if json.Unmarshal([]byte(line), &p) != nil || !checkCode.MatchString(p.Code) { - continue - } - file := relative(tree, p.Location.File) - if !changed[file] || !slices.Contains(added[file], p.Location.Line) { - continue - } - key := fmt.Sprintf("%s:%d:%s", file, p.Location.Line, p.Code) - if seen[key] { - continue - } - seen[key] = true - out = append(out, Finding{ - Job: "static", - Rule: "staticcheck/" + p.Code, - Severity: staticcheckSeverity(p.Code), - File: file, - Line: p.Location.Line, - Message: p.Message, - }) - } - return out +// checkStaticcheck is staticcheck alone, for the readers that want only it. +func checkStaticcheck(root, rev string, c *Change) []Finding { + return checkAnalysers(root, rev, c, staticcheckAnalyser) } // scProblem is one finding in staticcheck's -f json output, one JSON object @@ -161,50 +78,6 @@ func staticcheckSeverity(code string) Severity { return Note } -// changedPackages names the packages of the change's Go files, as patterns -// staticcheck takes. The leading ./ is load-bearing: without it a directory -// reads as a module path and matches nothing. -func changedPackages(tree string, c *Change) []string { - dirs := map[string]bool{} - for _, f := range c.Files { - if !strings.HasSuffix(f, ".go") { - continue - } - dir := filepath.Dir(f) - if dir == "vendor" || strings.HasPrefix(dir, "vendor/") { - continue - } - // A package the change deleted stands only in the materialised tree - // where it does; a directory that is nowhere is not a pattern. - if _, err := os.Stat(filepath.Join(tree, dir)); err != nil { - continue - } - dirs[dir] = true - } - var out []string - for dir := range dirs { - out = append(out, "./"+dir) - } - slices.Sort(out) - return out -} - -// touchesGo is whether the change touches a Go file at all. -func touchesGo(c *Change) bool { - for _, f := range c.Files { - if strings.HasSuffix(f, ".go") { - return true - } - } - return false -} - -// noteStaticcheck says why the check said nothing. One line, and only when -// there is a reader who was owed staticcheck's opinion. -func noteStaticcheck(format string, args ...any) { - fmt.Fprintf(os.Stderr, "skipping staticcheck: "+format+"\n", args...) -} - // tail is the last few bytes of a longer text, for an error message. func tail(s string, n int) string { s = strings.TrimSpace(s) diff --git a/tests.go b/tests.go @@ -0,0 +1,162 @@ +package main + +// A test with no assertion in it is the plainest shape of a test that +// cannot fail, and the shape is visible without reading what the test +// means. It is measured here; whether an assertion that is there asserts +// anything is the tests job's. + +import ( + "fmt" + "regexp" + "strings" +) + +// goTestParam reads the name a Go test gives its testing.T, so that a test +// naming it tc or tt is read by the name it uses. +var goTestParam = regexp.MustCompile(`^func \w+\((\w+) \*testing\.T\)`) + +// checkTestAssertions reports an added or altered test whose body asserts +// nothing: no failing call, no subtest, no helper handed the test, nothing +// that could make the run report anything but success. +func checkTestAssertions(c *Change) []Finding { + var out []Finding + for _, t := range c.Tests { + if !assertless(t) { + continue + } + out = append(out, Finding{ + Job: "static", Rule: "test-no-assertion", Severity: Consider, + File: t.File, Line: t.Line, Symbol: t.Name, + Message: fmt.Sprintf("%s asserts nothing: no call in its body can fail it, so it passes whatever the code does, and can only fail by crashing", t.Name), + Fix: "assert the value the test exists to check, or remove the test", + }) + } + return out +} + +// assertless is whether a test body holds nothing that could fail it, in +// the shapes the tool's languages assert in. +func assertless(t Function) bool { + body := t.Body + switch { + case strings.HasSuffix(t.File, ".go"): + if strings.HasPrefix(t.Name, "Benchmark") || strings.HasPrefix(t.Name, "Fuzz") || strings.HasPrefix(t.Name, "Example") || t.Name == "TestMain" { + return false + } + param := "t" + if m := goTestParam.FindStringSubmatch(strings.SplitN(body, "\n", 2)[0]); m != nil { + param = m[1] + } + for _, shape := range []string{".Error", ".Fatal", ".Fail", ".Run(", ".Skip"} { + if strings.Contains(body, param+shape) { + return false + } + } + for _, shape := range []string{"panic(", "require.", "assert.", "is."} { + if strings.Contains(body, shape) { + return false + } + } + // A test that hands its testing.T to a helper may assert through + // it, and the helper is not here to read. + passed := regexp.MustCompile(`[(,]\s*` + regexp.QuoteMeta(param) + `\s*[,)]`) + return !passed.MatchString(body) + case grammarOf(t.File) != "": + for _, shape := range []string{"expect(", "expect.", "assert", "should", "toThrow", "fail(", ".rejects", ".resolves", "throw "} { + if strings.Contains(body, shape) { + return false + } + } + return true + case strings.HasSuffix(t.File, ".odin"): + for _, shape := range []string{"testing.expect", "testing.fail", "expect(", "expectf(", "expect_value(", "assert(", "panic("} { + if strings.Contains(body, shape) { + return false + } + } + return true + case strings.HasSuffix(t.File, ".py"): + for _, shape := range []string{"assert ", "assert(", "self.assert", "pytest.raises", "pytest.fail", "raise ", ".assert_"} { + if strings.Contains(body, shape) { + return false + } + } + return true + case strings.HasSuffix(t.File, ".rs"): + for _, shape := range []string{"assert!", "assert_eq!", "assert_ne!", "panic!", "unwrap()", "expect(", "?;", "should_panic"} { + if strings.Contains(body, shape) { + return false + } + } + return true + } + return false +} + +// tautologies are the assertion shapes that hold whatever the code does: a +// literal true asserted, two literals compared, or an expression compared +// with itself. They are what a test reaches for once a rule says a test +// must assert, and they are visible without reading what the test means. +var tautologies = []*regexp.Regexp{ + // A literal true, or nil where an error is asserted absent. + regexp.MustCompile(`\b(?:assert|require)\.(?:True|NoError|Nil|Empty)\(\s*\w+\s*,\s*(?:true|nil)\s*\)`), + regexp.MustCompile(`\bassert\s+(?:True|1|"[^"]+"|'[^']+')\s*(?:,|$)`), + regexp.MustCompile(`\bassert(?:True|Is)\(\s*True\s*[,)]`), + regexp.MustCompile(`\bexpect\(\s*true\s*\)\.(?:toBe\(\s*true\s*\)|toBeTruthy\(\))`), + regexp.MustCompile(`\bassert!\(\s*true\s*\)`), + regexp.MustCompile(`\b(?:testing\.)?expect\(\s*\w+\s*,\s*true\s*\)`), + // A literal against a literal. + regexp.MustCompile(`\bexpect\(\s*(-?\d+|"[^"]*"|'[^']*')\s*\)\.(?:toBe|toEqual|toStrictEqual)\(\s*(-?\d+|"[^"]*"|'[^']*')\s*\)`), + regexp.MustCompile(`\bassert_eq!\(\s*(-?\d+|"[^"]*")\s*,\s*(-?\d+|"[^"]*")\s*\)`), + regexp.MustCompile(`\b(?:assert|require)\.Equal\(\s*\w+\s*,\s*(-?\d+|"[^"]*")\s*,\s*(-?\d+|"[^"]*")\s*\)`), + regexp.MustCompile(`\bassert\s+(-?\d+|"[^"]*"|'[^']*')\s*==\s*(-?\d+|"[^"]*"|'[^']*')`), +} + +// selfCompare matches an expression compared with itself, in the shapes the +// languages assert with: x == x, expect(x).toBe(x), assert_eq!(x, x), +// assert.Equal(t, x, x). +var selfCompare = []*regexp.Regexp{ + regexp.MustCompile(`\b([\w.]+(?:\([^()]*\))?)\s*(?:==|!=)\s*([\w.]+(?:\([^()]*\))?)`), + regexp.MustCompile(`\bexpect\(\s*([^()]+)\s*\)\.(?:toBe|toEqual|toStrictEqual)\(\s*([^()]+)\s*\)`), + regexp.MustCompile(`\bassert_(?:eq|ne)!\(\s*(.+?)\s*,\s*(.+?)\s*\);?\s*$`), + regexp.MustCompile(`\b(?:assert|require)\.(?:Equal|NotEqual)\(\s*\w+\s*,\s*(.+?)\s*,\s*(.+?)\s*\)\s*$`), +} + +// checkTautologies reports an assertion in an added or altered test that +// holds whatever the code does. +func checkTautologies(c *Change) []Finding { + var out []Finding + for _, t := range c.Tests { + for i, line := range strings.Split(t.Body, "\n") { + if why := tautological(line); why != "" { + out = append(out, Finding{ + Job: "static", Rule: "assertion-always-true", Severity: MustFix, + File: t.File, Line: t.Line + i, Symbol: t.Name, + Message: fmt.Sprintf("%s asserts %s: %s; the assertion holds whatever the code does, so the test cannot fail on it", t.Name, why, strings.TrimSpace(line)), + Fix: "assert the value the code produced against the value it should have", + }) + } + } + } + return out +} + +// tautological says what is tautological about an assertion line, or +// nothing. +func tautological(line string) string { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "#") { + return "" + } + for _, re := range tautologies { + if re.MatchString(trimmed) { + return "a constant" + } + } + for _, re := range selfCompare { + if m := re.FindStringSubmatch(trimmed); m != nil && strings.TrimSpace(m[1]) == strings.TrimSpace(m[2]) { + return "a value against itself" + } + } + return "" +} diff --git a/tree.go b/tree.go @@ -0,0 +1,238 @@ +package main + +// A review reads the repository as it stands at the end of the change. For +// the staged change and a bare revision that is the working tree; for a +// range it is a tree the working directory may have left behind, which git +// holds and the filesystem does not. The tree here makes both readable the +// same way, and materialises a range's end once, in Go, so that every +// reader after the first reads files rather than asking git for each. + +import ( + "archive/tar" + "bytes" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" +) + +// Tree is the repository's files at the end of the change. +type Tree struct { + // dir is where the files are read from: the repository root, or a + // scratch copy of the revision. + dir string + // root is the repository, which is what git is asked about. + root string + // rev is the revision materialised, or empty for the working tree. + rev string + + once sync.Once + files []string + err error + + sourcesOnce sync.Once + sources map[string][]byte + sourcesErr error +} + +var ( + treesMu sync.Mutex + trees = map[string]*Tree{} + scratch []string +) + +// treeAt is the tree the change arrives at. A range's end is materialised on +// the first ask and shared by every reader after it; the working tree is +// read in place. +func treeAt(root, rev string) (*Tree, error) { + after, ranged := ends(rev) + if !ranged { + return &Tree{dir: root, root: root}, nil + } + treesMu.Lock() + defer treesMu.Unlock() + key := root + "\x00" + after + if t, ok := trees[key]; ok { + return t, nil + } + dir, err := materialise(root, after) + if err != nil { + return nil, err + } + t := &Tree{dir: dir, root: root, rev: after} + trees[key] = t + scratch = append(scratch, dir) + return t, nil +} + +// materialise writes the revision's files to a scratch directory, read out +// of git's own archive of it: one ask, however many files, and no tar +// program to find. +func materialise(root, rev string) (string, error) { + cmd := exec.Command("git", "archive", "--format=tar", rev) + cmd.Dir = root + var stderr bytes.Buffer + cmd.Stderr = &stderr + archive, err := cmd.Output() + if err != nil { + return "", errors.New("git archive " + rev + ": " + strings.TrimSpace(stderr.String())) + } + dir, err := os.MkdirTemp("", "review-tree-") + if err != nil { + return "", err + } + reader := tar.NewReader(bytes.NewReader(archive)) + for { + header, err := reader.Next() + if err == io.EOF { + break + } + if err != nil { + os.RemoveAll(dir) + return "", err + } + // Only regular files are read; a link is not a source file, and a + // path that climbs out of the directory is not written anywhere. + name := filepath.Clean(header.Name) + if header.Typeflag != tar.TypeReg || strings.HasPrefix(name, "..") || filepath.IsAbs(name) { + continue + } + full := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + os.RemoveAll(dir) + return "", err + } + data, err := io.ReadAll(reader) + if err != nil { + os.RemoveAll(dir) + return "", err + } + if err := os.WriteFile(full, data, 0o644); err != nil { + os.RemoveAll(dir) + return "", err + } + } + return dir, nil +} + +// closeTrees removes what materialising left behind. It is deferred by the +// command, so a run that fails still cleans up. +func closeTrees() { + treesMu.Lock() + defer treesMu.Unlock() + for _, dir := range scratch { + os.RemoveAll(dir) + } + scratch = nil + trees = map[string]*Tree{} +} + +// Read is one file's content as it stands at the end of the change. +func (t *Tree) Read(path string) ([]byte, error) { + return os.ReadFile(filepath.Join(t.dir, path)) +} + +// Dir is where the tree's files can be read by a program that reads trees +// rather than files, such as an analyser. +func (t *Tree) Dir() string { return t.dir } + +// Exists reports whether the tree holds the path as a file. +func (t *Tree) Exists(path string) bool { + info, err := os.Stat(filepath.Join(t.dir, path)) + return err == nil && !info.IsDir() +} + +// Files lists the tracked paths at the end of the change, asked of git once +// and kept. The staged change is tracked by the index, which is what +// ls-files reads. +func (t *Tree) Files() ([]string, error) { + t.once.Do(func() { + var out string + if t.rev == "" { + out, t.err = git(t.root, "ls-files") + } else { + out, t.err = git(t.root, "ls-tree", "-r", "--name-only", t.rev) + } + if t.err != nil { + return + } + for _, line := range strings.Split(out, "\n") { + if line = strings.TrimSpace(line); line != "" { + t.files = append(t.files, line) + } + } + }) + return t.files, t.err +} + +// Sources reads every tracked file that is text, so that a search over the +// repository is a loop over memory rather than a program run. Binary files +// are left out by the cheapest test there is: a NUL byte in the first +// kilobyte. +func (t *Tree) Sources() (map[string][]byte, error) { + t.sourcesOnce.Do(func() { + files, err := t.Files() + if err != nil { + t.sourcesErr = err + return + } + out := make(map[string][]byte, len(files)) + for _, name := range files { + data, err := t.Read(name) + if err != nil { + continue // A tracked path the tree cannot read is a link or gone. + } + head := data + if len(head) > 1024 { + head = head[:1024] + } + if bytes.IndexByte(head, 0) >= 0 { + continue + } + out[name] = data + } + t.sources = out + }) + return t.sources, t.sourcesErr +} + +// Line is one line of a file at the end of the change, trimmed, or empty +// where the file or the line is not there. +func (t *Tree) Line(path string, line int) string { + if line < 1 { + return "" + } + data, err := t.Read(path) + if err != nil { + return "" + } + lines := strings.Split(string(data), "\n") + if line > len(lines) { + return "" + } + return strings.TrimSpace(lines[line-1]) +} + +// at reads a file as it stands at the end of the change under review, so that +// reviewing an old commit reads the code that commit left behind rather than +// whatever the working tree holds now. +func at(root, rev, path string) ([]byte, error) { + t, err := treeAt(root, rev) + if err != nil { + return nil, err + } + return t.Read(path) +} + +// ends reports the revision a range arrives at, and whether it is a range at +// all. Both A..B and A...B are reviewed as B. +func ends(rev string) (string, bool) { + _, after, found := strings.Cut(rev, "..") + if !found { + return rev, false + } + return strings.TrimPrefix(after, "."), true +} diff --git a/tree_test.go b/tree_test.go @@ -0,0 +1,86 @@ +package main + +import ( + "os" + "slices" + "strings" + "testing" +) + +// A range's end is a tree git holds and the filesystem does not; it is +// materialised once, in Go, and every reader reads files from it. +func TestTreeMaterialisesARange(t *testing.T) { + r := newRepo(t) + r.write("a.go", "package x\n\nvar a = 1\n") + r.write("bin/blob", "\x00\x01\x02") + first := r.commit("first", "a.go", "bin/blob") + r.write("a.go", "package x\n\nvar a = 2\n") + r.write("b.go", "package x\n") + second := r.commit("second", "a.go", "b.go") + r.write("a.go", "package x\n\nvar a = 3 // working tree\n") + + tree, err := treeAt(r.Root, first+".."+second) + if err != nil { + t.Fatal(err) + } + if tree.Dir() == r.Root { + t.Fatal("a range was read from the working tree") + } + data, err := tree.Read("a.go") + if err != nil || !strings.Contains(string(data), "a = 2") { + t.Errorf("read %q, %v; want the range's end", data, err) + } + files, err := tree.Files() + if err != nil || !slices.Equal(files, []string{"a.go", "b.go", "bin/blob"}) { + t.Errorf("files %v, %v", files, err) + } + sources, err := tree.Sources() + if err != nil { + t.Fatal(err) + } + if _, ok := sources["bin/blob"]; ok { + t.Error("a binary file was read as a source") + } + if !tree.Exists("b.go") || tree.Exists("nowhere.go") || tree.Exists("bin") { + t.Error("exists is wrong about a file, a missing file, or a directory") + } + // The same range is the same tree. + again, err := treeAt(r.Root, first+".."+second) + if err != nil || again != tree { + t.Error("the tree was materialised twice") + } + dir := tree.Dir() + closeTrees() + if _, err := os.Stat(dir); err == nil { + t.Error("the scratch tree survived closing") + } +} + +// The working tree is read in place, staged files included. +func TestTreeReadsTheWorkingTreeInPlace(t *testing.T) { + r := newRepo(t) + r.write("a.go", "package x\n") + r.commit("first", "a.go") + r.write("b.go", "package x\n") + r.stage("b.go") + tree, err := treeAt(r.Root, "") + if err != nil { + t.Fatal(err) + } + if tree.Dir() != r.Root { + t.Errorf("read from %s, want the repository", tree.Dir()) + } + files, _ := tree.Files() + if !slices.Contains(files, "b.go") { + t.Errorf("the staged file is not listed: %v", files) + } +} + +func TestTreeRefusesAnUnknownRevision(t *testing.T) { + r := newRepo(t) + r.write("a.go", "package x\n") + r.commit("first", "a.go") + if _, err := treeAt(r.Root, "nowhere..nothing"); err == nil { + t.Error("an unknown revision was materialised") + } +} diff --git a/tsfrontend.go b/tsfrontend.go @@ -10,16 +10,50 @@ import ( "strings" ) -// TSFrontend reads TypeScript through ast-grep, which matches its patterns -// against the tree-sitter syntax tree rather than against lines. A pattern -// over lines misses an indented constant inside a block; a query over syntax -// does not, and the top-level constraint keeps function bodies out. +// TSFrontend reads TypeScript and JavaScript through ast-grep, which +// matches its patterns against the tree-sitter syntax tree rather than +// against lines. A pattern over lines misses an indented constant inside a +// block; a query over syntax does not, and the top-level constraint keeps +// function bodies out. type TSFrontend struct{} func (TSFrontend) Name() string { return "typescript" } func (TSFrontend) Covers(path string) bool { - return strings.HasSuffix(path, ".ts") || strings.HasSuffix(path, ".tsx") + return grammarOf(path) != "" +} + +// grammarOf is the ast-grep grammar a path is parsed with, or empty where +// none of this frontend's grammars reads it. +func grammarOf(path string) string { + switch filepath.Ext(path) { + case ".ts": + return "ts" + case ".tsx": + return "tsx" + case ".js", ".jsx", ".mjs", ".cjs": + return "js" + } + return "" +} + +// scratchExt is the extension a file is materialised under, which is what +// ast-grep infers the grammar from. The module variants of JavaScript are +// the same grammar under another name. +func scratchExt(path string) string { + switch ext := filepath.Ext(path); ext { + case ".mjs", ".cjs": + return ".js" + default: + return ext + } +} + +// typed is whether a pattern needs TypeScript's grammar: an annotation, an +// interface, a type alias or an enum is not JavaScript. +func typed(pattern string) bool { + return strings.Contains(pattern, ": $$$") || strings.HasPrefix(strings.TrimPrefix(pattern, "export "), "type ") || + strings.Contains(pattern, "interface ") || strings.Contains(pattern, "enum ") } func (TSFrontend) Features() Features { @@ -62,10 +96,22 @@ var tsPatterns = []struct { {"func", true, "export default function $N($$$P): $$$R { $$$B }"}, } -// tsTestPatterns bind the runner's calls. A test inside a describe block is -// still a test, so these carry no top-level constraint; they are only ever -// asked for, so a helper named test would not distract them elsewhere. -var tsTestPatterns = []string{"test($S, $$$B)", "it($S, $$$B)"} +// tsTestPatterns bind the runner's calls, in the shapes Node's node:test, +// Bun's bun:test, Deno's Deno.test and the Jest family write them: the bare +// call, a modifier such as skip, only, ignore or fails, a table through +// each, Deno's object and named-function forms. A test inside a describe +// block is still a test, so these carry no top-level constraint; they are +// only ever asked for, so a helper named test would not distract them +// elsewhere. The name is $S when the test is named by a string and $N when +// by a function. +var tsTestPatterns = []string{ + "test($S, $$$B)", "it($S, $$$B)", + "test.$M($S, $$$B)", "it.$M($S, $$$B)", + "test.$M($$$T)($S, $$$B)", "it.$M($$$T)($S, $$$B)", + "Deno.test($S, $$$B)", "Deno.test.$M($S, $$$B)", + "Deno.test({ name: $S, $$$R })", + "Deno.test(function $N($$$P) { $$$B })", +} // grepMatch is one ast-grep finding. type grepMatch struct { @@ -98,6 +144,11 @@ func (m grepMatch) name() string { // writes the rules, and asks for the matches. The returned matches carry the // scratch path in File; the caller maps it back through the order given. func scan(files []string, root, rev string) ([]grepMatch, error) { + return scanWith(files, root, rev, writeRules) +} + +// scanWith is scan for any grammar: the rules written are the caller's. +func scanWith(files []string, root, rev string, rules func(dir string) error) ([]grepMatch, error) { if len(files) == 0 { return nil, nil } @@ -114,7 +165,7 @@ func scan(files []string, root, rev string) ([]grepMatch, error) { if err := os.WriteFile(filepath.Join(dir, "sgconfig.yml"), []byte(config), 0o644); err != nil { return nil, err } - if err := writeRules(filepath.Join(dir, "rules")); err != nil { + if err := rules(filepath.Join(dir, "rules")); err != nil { return nil, err } @@ -125,7 +176,7 @@ func scan(files []string, root, rev string) ([]grepMatch, error) { continue // Deleted by the change, so there is nothing to read. } // The extension is kept: ast-grep infers the grammar from it. - scratch := filepath.Join(dir, fmt.Sprintf("%04d%s", i, filepath.Ext(name))) + scratch := filepath.Join(dir, fmt.Sprintf("%04d%s", i, scratchExt(name))) if err := os.WriteFile(scratch, source, 0o644); err != nil { return nil, err } @@ -164,8 +215,11 @@ func writeRules(dir string) error { exported bool pattern string } - }{{"ts", "TypeScript", tsPatterns}, {"tsx", "TSX", tsPatterns}} { + }{{"ts", "TypeScript", tsPatterns}, {"tsx", "TSX", tsPatterns}, {"js", "JavaScript", tsPatterns}} { for i, p := range table.patterns { + if table.prefix == "js" && typed(p.pattern) { + continue + } status := "plain" if p.exported { status = "export" @@ -180,7 +234,7 @@ func writeRules(dir string) error { for _, table := range []struct { prefix string language string - }{{"ts", "TypeScript"}, {"tsx", "TSX"}} { + }{{"ts", "TypeScript"}, {"tsx", "TSX"}, {"js", "JavaScript"}} { for i, pattern := range tsTestPatterns { id := fmt.Sprintf("%s-test-%d", table.prefix, i) body := fmt.Sprintf("id: %s\nlanguage: %s\nseverity: info\nrule:\n pattern: %q\n", id, table.language, pattern) @@ -251,12 +305,16 @@ func (g TSFrontend) Change(root, rev string, c *Change, added map[string][]int) if line-1 < len(lines[m.File]) { signature = strings.TrimSpace(lines[m.File][line-1]) } - c.Symbols = append(c.Symbols, Symbol{ + symbol := Symbol{ Name: name, Kind: ruleKind(m.Rule), Doc: docAbove(lines[m.File], line), File: m.File, Line: line, Exported: strings.Contains(m.Rule, "export"), Signature: signature, - }) + } + if symbol.Kind == "func" { + symbol.Body = m.Text + } + c.Symbols = append(c.Symbols, symbol) } for _, name := range covered { c.Comments = append(c.Comments, commentProse(sources[name], name, added[name])...) @@ -267,13 +325,17 @@ func (g TSFrontend) Change(root, rev string, c *Change, added map[string][]int) // Whole reads every top-level declaration in the repository's TypeScript // files, so a new name can be checked against the ones it may duplicate. func (g TSFrontend) Whole(root, rev string) ([]Declared, error) { - out, err := git(root, "ls-files", "*.ts", "*.tsx") + tree, err := treeAt(root, rev) + if err != nil { + return nil, err + } + tracked, err := tree.Files() if err != nil { return nil, err } var files []string - for _, name := range strings.Split(strings.TrimSpace(out), "\n") { - if name != "" && g.Covers(name) { + for _, name := range tracked { + if g.Covers(name) { files = append(files, name) } } @@ -303,7 +365,11 @@ func (g TSFrontend) Whole(root, rev string) ([]Declared, error) { text = fileLines[line-1] } } - index = append(index, Declared{Name: name, Kind: ruleKind(m.Rule), File: m.File, Line: line, Text: text}) + declared := Declared{Name: name, Kind: ruleKind(m.Rule), File: m.File, Line: line, Text: text} + if declared.Kind == "func" { + declared.Body = m.Text + } + index = append(index, declared) } return index, nil } @@ -320,14 +386,17 @@ func ruleKind(rule string) string { } // docAbove collects the comment block ending on the line before the -// declaration, as the doc a reader would attach to it. +// declaration, as the doc a reader would attach to it, in the C-family +// shapes and Python's. func docAbove(lines []string, line int) string { var parts []string for i := line - 2; i >= 0; i-- { trimmed := strings.TrimSpace(lines[i]) - if !strings.HasPrefix(trimmed, "//") && !strings.HasPrefix(trimmed, "*") && !strings.HasPrefix(trimmed, "/*") { + if !strings.HasPrefix(trimmed, "//") && !strings.HasPrefix(trimmed, "*") && !strings.HasPrefix(trimmed, "/*") && + (!strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "#!")) { break } + trimmed = strings.TrimPrefix(trimmed, "#") trimmed = strings.TrimPrefix(trimmed, "///") trimmed = strings.TrimPrefix(trimmed, "//") trimmed = strings.TrimPrefix(trimmed, "/**") diff --git a/tsfrontend_test.go b/tsfrontend_test.go @@ -108,6 +108,53 @@ describe('icons', () => { } } +func TestTypeScriptReadsEveryRunnersTests(t *testing.T) { + needAstGrep(t) + r := newRepo(t) + r.write("src/deno_test.ts", `import { assertEquals } from "@std/assert"; +Deno.test("deno string", () => { assertEquals(1, 1); }); +Deno.test({ name: "deno object", fn() { assertEquals(2, 2); } }); +Deno.test(function denoNamed() { assertEquals(3, 3); }); +Deno.test.ignore("deno ignored", () => { assertEquals(4, 4); }); +`) + r.write("src/bun.test.ts", `import { test, it, expect } from "bun:test"; +test.skip("bun skipped", () => { expect(1).toBe(1); }); +it.only("bun focused", () => { expect(1).toBe(1); }); +test.each([[1]])("bun each %i", (a) => { expect(a).toBe(1); }); +test("bun plain", async () => { expect(1).toBe(1); }); +`) + r.write("src/node.test.js", `import { test } from "node:test"; +import assert from "node:assert"; +test("node plain", { timeout: 5 }, () => { assert.equal(1, 1); }); +test.todo("node todo", () => { assert.ok(true); }); +`) + r.write("src/.keep", "") + r.commit("ts: begin", "src/.keep") + rev := r.commit("ts: tests", "src/deno_test.ts", "src/bun.test.ts", "src/node.test.js") + + change, err := Gather(rev+"^.."+rev, r.Root) + if err != nil { + t.Fatal(err) + } + names := []string{} + for _, test := range change.Tests { + names = append(names, test.Name) + } + want := []string{ + "deno string", "deno object", "denoNamed", "deno ignored", + "bun skipped", "bun focused", "bun each %i", "bun plain", + "node plain", "node todo", + } + for _, w := range want { + if !slices.Contains(names, w) { + t.Errorf("tests %v, want %q among them", names, w) + } + } + if len(names) != len(want) { + t.Errorf("tests %v: %d, want %d", names, len(names), len(want)) + } +} + func TestTypeScriptReadsDocComments(t *testing.T) { needAstGrep(t) r := newRepo(t) @@ -188,3 +235,59 @@ function local() {} } } } + +// JavaScript is the same grammar without the types, and reads through the +// same frontend: declarations, tests and bodies. +func TestJavaScriptReadsDeclarations(t *testing.T) { + needAstGrep(t) + r := newRepo(t) + r.write("src/.keep", "") + r.commit("js: begin", "src/.keep") + r.write("src/icons.js", `export const maxIcons = 12; +const hidden = 4; +export function readIcons(src) { return []; } +function helper() { return hidden; } +`) + r.write("src/icons.test.js", `test('reads icons', () => { expect(readIcons('x')).toEqual([]); }); +`) + r.write("src/app.jsx", `export function App() { return <div/>; } +`) + rev := r.commit("js: first", "src/icons.js", "src/icons.test.js", "src/app.jsx") + + change, err := Gather(rev+"^.."+rev, r.Root) + if err != nil { + t.Fatal(err) + } + got := map[string]Symbol{} + for _, s := range change.Symbols { + got[s.Name] = s + } + for name, want := range map[string]struct { + kind string + exported bool + }{ + "maxIcons": {"value", true}, + "hidden": {"value", false}, + "readIcons": {"func", true}, + "helper": {"func", false}, + "App": {"func", true}, + } { + s, ok := got[name] + if !ok { + t.Errorf("%s not read", name) + continue + } + if s.Kind != want.kind || s.Exported != want.exported { + t.Errorf("%s: got %s exported=%v, want %s exported=%v", name, s.Kind, s.Exported, want.kind, want.exported) + } + } + if got["readIcons"].Body == "" { + t.Error("a function's body was not read") + } + if len(change.Tests) != 1 || change.Tests[0].Name != "reads icons" { + t.Errorf("tests: %v", change.Tests) + } + if len(change.Uncovered) != 0 { + t.Errorf("uncovered: %v", change.Uncovered) + } +}