commit 0ae800504c7e48a965970b883f640aa76c8b6d03
parent caa60952b232496abb61fc34c14a1ba583483003
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Wed, 23 Sep 2026 21:24:08 -0300
review: retire the Go implementation for the Odin one
The Odin packages move from odin/ to the repository root and the Go
sources go, once the two had been run side by side with a model and the
Odin hook had gated commits. What stays in Go are the two sidecars, the
parser in sidecar/gofront and vet's multichecker in sidecar/govet, which
the tool drives as programs. The justfile builds review, review-go,
review-vet and odin-review-extract into build/ and installs them beside
each other; the readme, languages.md and handover.md describe the tool
as it now is. The eval harness went with the Go tests it was written
among, and porting it is the open item.
Diffstat:
118 files changed, 613 insertions(+), 15349 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -1,4 +1 @@
-/review
-/review-vet
-/review-go
build/
diff --git a/odin/analyser/analyser.odin b/analyser/analyser.odin
diff --git a/odin/analyser/analyser_test.odin b/analyser/analyser_test.odin
diff --git a/odin/analyser/go.odin b/analyser/go.odin
diff --git a/odin/analyser/odin.odin b/analyser/odin.odin
diff --git a/odin/analyser/others.odin b/analyser/others.odin
diff --git a/analysers.go b/analysers.go
@@ -1,876 +0,0 @@
-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
- }
- // A directory under a go.mod of its own is another module's, and
- // the go command run at the root cannot name it.
- if nestedModule(tree, dir) {
- continue
- }
- dirs[dir] = true
- }
- var out []string
- for dir := range dirs {
- out = append(out, "./"+filepath.ToSlash(dir))
- }
- slices.Sort(out)
- return out
-}
-
-// nestedModule is whether a directory sits under a go.mod below the
-// tree's root, which makes it another module's package.
-func nestedModule(tree, dir string) bool {
- for d := dir; d != "." && d != "" && d != "/"; d = filepath.Dir(d) {
- if _, err := os.Stat(filepath.Join(tree, d, "go.mod")); err == nil {
- return true
- }
- }
- return false
-}
-
-// 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.
- args := append([]string{"check", dir, "-vet", "-strict-style", "-json-errors", "-no-entry-point"}, odinCollections(tree)...)
- stdout, stderr, err := executeBoth(ctx, tree, nil, "odin", args...)
- 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
- },
-}
-
-// odinCollections are the -collection flags the repository's ols.json
-// declares, which is where an Odin project names the collections its
-// imports resolve through; a relative path is relative to the repository.
-// Without them the compiler cannot see past `import "name:pkg"`.
-func odinCollections(tree string) []string {
- data, err := os.ReadFile(filepath.Join(tree, "ols.json"))
- if err != nil {
- return nil
- }
- var config struct {
- Collections []struct {
- Name string `json:"name"`
- Path string `json:"path"`
- } `json:"collections"`
- }
- if err := json.Unmarshal(data, &config); err != nil {
- return nil
- }
- var flags []string
- for _, c := range config.Collections {
- if c.Name == "" || c.Path == "" {
- continue
- }
- path := c.Path
- if !filepath.IsAbs(path) {
- path = filepath.Join(tree, path)
- }
- flags = append(flags, "-collection:"+c.Name+"="+path)
- }
- return flags
-}
-
-// 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
@@ -1,316 +0,0 @@
-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"
- "os/exec"
- "path/filepath"
- "slices"
- "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)
- }
-}
-
-func TestOdinCollectionsReadOlsJSON(t *testing.T) {
- dir := t.TempDir()
- if got := odinCollections(dir); got != nil {
- t.Errorf("no ols.json: %v", got)
- }
- if err := os.WriteFile(filepath.Join(dir, "ols.json"), []byte(`{"collections":[{"name":"jfm","path":"../odin"},{"name":"abs","path":"/opt/abs"},{"name":""}]}`), 0o644); err != nil {
- t.Fatal(err)
- }
- got := odinCollections(dir)
- want := []string{"-collection:jfm=" + filepath.Join(dir, "../odin"), "-collection:abs=/opt/abs"}
- if !slices.Equal(got, want) {
- t.Errorf("got %v, want %v", got, want)
- }
-}
-
-func TestGoPackagesSkipsNestedModules(t *testing.T) {
- dir := t.TempDir()
- for _, path := range []string{"go.mod", "a/a.go", "sidecar/govet/go.mod", "sidecar/govet/main.go", "sidecar/gofront/main.go"} {
- if err := os.MkdirAll(filepath.Join(dir, filepath.Dir(path)), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(dir, path), []byte("x"), 0o644); err != nil {
- t.Fatal(err)
- }
- }
- got := goPackages(dir, []string{"a/a.go", "sidecar/govet/main.go", "sidecar/gofront/main.go", "vendor/x/x.go"})
- if want := []string{"./a", "./sidecar/gofront"}; !slices.Equal(got, want) {
- t.Errorf("got %v, want %v", got, want)
- }
-}
diff --git a/assertions_test.go b/assertions_test.go
@@ -1,98 +0,0 @@
-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
@@ -1,106 +0,0 @@
-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/odin/bench/bench.odin b/bench/bench.odin
diff --git a/odin/bench/bench_test.odin b/bench/bench_test.odin
diff --git a/bench_test.go b/bench_test.go
@@ -1,47 +0,0 @@
-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.go b/cache.go
@@ -1,140 +0,0 @@
-package main
-
-// The cache remembers what a provider answered, so that a re-run of a
-// review whose parts did not change does not ask again. A loop iterates by
-// re-running: one fix, one more reading. Without the cache every re-run
-// re-asks every job, at full price and with a fresh roll of the dice — the
-// same question answered two ways is the flicker that makes a loop chase
-// ghosts. With it, a job whose rendered subject is byte for byte the same
-// replays the recorded answer, which is both free and stable.
-
-import (
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "sync"
- "time"
-)
-
-// cacheVersion is part of every key, so that a change to what an ask holds
-// invalidates the recorded answers instead of replaying stale ones.
-const cacheVersion = "review-answers-v1"
-
-// cacheBound is the entry count past which the oldest answers are dropped.
-const cacheBound = 4000
-
-// AnswerCache is one file of recorded answers, keyed by the exact question.
-type AnswerCache struct {
- mu sync.Mutex
- path string
- fresh bool
- entries map[string]cacheEntry
- dirty bool
- hits int
-}
-
-// cacheEntry is one recorded answer and what it cost when it was asked.
-type cacheEntry struct {
- Text string `json:"text"`
- In int `json:"in"`
- Out int `json:"out"`
- Cached int `json:"cached"`
- Cost float64 `json:"cost"`
- At time.Time `json:"at"`
-}
-
-// openCache reads the answer file from the user's cache directory. A cache
-// that cannot be read or written is no fault of the review: the run asks
-// the provider as it would have. Fresh skips the reads but keeps the
-// writes, so --fresh re-asks everything and leaves the answers behind it.
-func openCache(fresh bool) *AnswerCache {
- dir, err := os.UserCacheDir()
- if err != nil {
- return nil
- }
- c := &AnswerCache{
- path: filepath.Join(dir, "review", "answers.json"),
- fresh: fresh,
- entries: map[string]cacheEntry{},
- }
- data, err := os.ReadFile(c.path)
- if err != nil {
- return c // No cache yet, which is not an error.
- }
- // A corrupt file is answered with whatever parsed. The rest is
- // discarded rather than repaired; the next save rewrites the file.
- json.Unmarshal(data, &c.entries)
- return c
-}
-
-func (c *AnswerCache) key(provider, system, user string) string {
- h := sha256.Sum256([]byte(strings.Join([]string{cacheVersion, provider, system, user}, "\x00")))
- return hex.EncodeToString(h[:])
-}
-
-// get returns the answer recorded for exactly this question, marked as a
-// replay: the run asked nothing, so the replay's usage counts stay at zero.
-func (c *AnswerCache) get(provider, system, user string) (Answer, bool) {
- c.mu.Lock()
- defer c.mu.Unlock()
- if c.fresh {
- return Answer{}, false
- }
- entry, ok := c.entries[c.key(provider, system, user)]
- if !ok {
- return Answer{}, false
- }
- c.hits++
- return Answer{Text: entry.Text, Replayed: true}, true
-}
-
-func (c *AnswerCache) put(provider, system, user string, answer Answer) {
- c.mu.Lock()
- defer c.mu.Unlock()
- c.entries[c.key(provider, system, user)] = cacheEntry{
- Text: answer.Text, In: answer.In, Out: answer.Out,
- Cached: answer.Cached, Cost: answer.Cost, At: time.Now(),
- }
- c.dirty = true
-}
-
-// save writes the file back when this run recorded anything, dropping the
-// oldest entries past the bound. A failed write stays silent: the cache is
-// a saving, not a result.
-func (c *AnswerCache) save() {
- c.mu.Lock()
- defer c.mu.Unlock()
- if !c.dirty {
- return
- }
- if len(c.entries) > cacheBound {
- type aged struct {
- key string
- at time.Time
- }
- ages := make([]aged, 0, len(c.entries))
- for key, entry := range c.entries {
- ages = append(ages, aged{key, entry.At})
- }
- sort.Slice(ages, func(i, j int) bool { return ages[i].at.Before(ages[j].at) })
- for _, a := range ages[:len(ages)-cacheBound] {
- delete(c.entries, a.key)
- }
- }
- data, err := json.Marshal(c.entries)
- if err != nil {
- return
- }
- if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
- return
- }
- temp := c.path + ".tmp"
- if err := os.WriteFile(temp, data, 0o644); err != nil {
- return
- }
- os.Rename(temp, c.path)
-}
diff --git a/odin/cache/cache.odin b/cache/cache.odin
diff --git a/odin/cache/cache_test.odin b/cache/cache_test.odin
diff --git a/cache_test.go b/cache_test.go
@@ -1,175 +0,0 @@
-package main
-
-// The answer cache is what makes a re-run cheap and stable where the part
-// of the change being judged did not change. These tests hold the contract:
-// a recorded question replays and counts no usage, an unrecorded one asks,
-// a fresh cache asks but keeps writing, a corrupt file is survived, and the
-// file is pruned rather than grown without bound.
-
-import (
- "context"
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-func testCache(t *testing.T) *AnswerCache {
- t.Helper()
- t.Setenv("XDG_CACHE_HOME", t.TempDir())
- cache := openCache(false)
- if cache == nil {
- t.Fatal("no cache")
- }
- return cache
-}
-
-func TestCacheReplaysAndRecords(t *testing.T) {
- cache := testCache(t)
- // Nothing is recorded yet.
- if _, ok := cache.get("api/probe", "system", "user"); ok {
- t.Error("an unrecorded question was replayed")
- }
- cache.put("api/probe", "system", "user", Answer{Text: "answer", In: 10, Out: 2, Cost: 0.5})
- // A replay is the same text, marked as a replay, and counts no usage:
- // it cost nothing this run.
- answer, ok := cache.get("api/probe", "system", "user")
- if !ok || answer.Text != "answer" || !answer.Replayed || answer.In != 0 || answer.Cost != 0 {
- t.Errorf("got %+v", answer)
- }
- // A different question is not a replay.
- for _, key := range [][3]string{
- {"api/probe", "system", "other user"},
- {"api/probe", "other system", "user"},
- {"api/other", "system", "user"},
- } {
- if _, ok := cache.get(key[0], key[1], key[2]); ok {
- t.Errorf("replayed %v", key)
- }
- }
- // What was recorded survives the run.
- cache.save()
- again := openCache(false)
- if answer, ok := again.get("api/probe", "system", "user"); !ok || answer.Text != "answer" {
- t.Errorf("got %+v", answer)
- }
-}
-
-// Fresh is for when the answer itself is in doubt: it asks again, and
-// leaves what it learned behind it.
-func TestCacheFreshAsksAndKeepsWriting(t *testing.T) {
- cache := testCache(t)
- cache.put("api/probe", "system", "user", Answer{Text: "stale"})
- fresh := openCache(true)
- if _, ok := fresh.get("api/probe", "system", "user"); ok {
- t.Error("fresh replayed a recorded answer")
- }
- fresh.put("api/probe", "system", "other", Answer{Text: "new"})
- fresh.save()
- // The write is on disk, so it is read back from a new cache.
- after := openCache(false)
- if answer, ok := after.get("api/probe", "system", "other"); !ok || answer.Text != "new" {
- t.Errorf("got %+v", answer)
- }
-}
-
-func TestCacheToleratesCorruption(t *testing.T) {
- dir := t.TempDir()
- t.Setenv("XDG_CACHE_HOME", dir)
- if err := os.MkdirAll(filepath.Join(dir, "review"), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(dir, "review", "answers.json"), []byte("{not json"), 0o644); err != nil {
- t.Fatal(err)
- }
- cache := openCache(false)
- cache.put("api/probe", "system", "user", Answer{Text: "kept"})
- cache.save()
- data, err := os.ReadFile(filepath.Join(dir, "review", "answers.json"))
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(string(data), "kept") {
- t.Errorf("got %s", data)
- }
-}
-
-func TestCachePrunesTheOldest(t *testing.T) {
- cache := testCache(t)
- for i := range cacheBound + 10 {
- cache.put(string(rune('a'+i%26))+itoa(i), "system", "user", Answer{Text: "x"})
- }
- cache.save()
- again := openCache(false)
- if len(again.entries) != cacheBound {
- t.Errorf("kept %d, want %d", len(again.entries), cacheBound)
- }
-}
-
-// A reviewer with a cache asks once and replays from then on, and the
-// replay carries the findings the first ask produced.
-func TestReviewerReplaysWhatItAsked(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.Findings) != 1 || first.Replayed != 0 {
- t.Fatalf("got %+v", first)
- }
- asked := len(provider.asked)
- second := Reviewer{Provider: provider, Cache: cache}.Run(context.Background(), verifyChange(), jobs)
- if len(provider.asked) != asked {
- t.Errorf("asked %d times on the second run", len(provider.asked)-asked)
- }
- if second.Replayed != 1 {
- t.Errorf("got %+v", second)
- }
- if len(second.Findings) != 1 || second.Findings[0].Rule != "cannot-fail" {
- t.Fatalf("got %+v", second.Findings)
- }
- // A fresh cache asks again even where the answer is recorded.
- fresh := openCache(true)
- third := Reviewer{Provider: provider, Cache: fresh}.Run(context.Background(), verifyChange(), jobs)
- if third.Replayed != 0 || len(provider.asked) != asked+1 {
- t.Errorf("fresh replayed: %+v", third)
- }
-}
-
-func itoa(n int) string {
- if n == 0 {
- return "0"
- }
- var digits []byte
- for ; n > 0; n /= 10 {
- digits = append([]byte{byte('0' + n%10)}, digits...)
- }
- 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/chain.go b/chain.go
@@ -1,67 +0,0 @@
-package main
-
-import (
- "context"
- "fmt"
- "os"
- "strings"
- "time"
-)
-
-// probeTimeout bounds one reachability ask. A provider that cannot answer a
-// trivial ask that quickly is unlikely to answer a job in time either, and
-// the jobs are the expensive part.
-const probeTimeout = 45 * time.Second
-
-// Chain is a priority order of providers. The first one that answers a probe
-// serves the whole reading, so a provider over its limit costs one cheap ask
-// rather than a failed review. Which provider is reachable is a property of
-// the hour: a team limit exhausted, a gateway asleep, a key unset — the chain
-// is how the tool rides that out without being told.
-type Chain []entry
-
-type entry struct {
- Name string
- Provider Provider
-}
-
-// defaultChain is the configured preference. The direct API is tried before
-// the coding assistant, because it can enforce the answer's shape and reports
-// usage; the local gateway is last, as the reading of last resort. The model
-// flag re-points the first slot for a one-off, as it always has.
-func defaultChain() Chain {
- return Chain{
- {"api/" + orElse(os.Getenv("REVIEW_MODEL"), "claude-sonnet-5"), API{Model: orElse(os.Getenv("REVIEW_MODEL"), "claude-sonnet-5")}},
- {"pi/maple/glm-5-3-flash", Pi{Model: "glm-5-3-flash", Upstream: "maple"}},
- }
-}
-
-// pick asks every entry a trivial question, in order, and returns the first
-// that answers. Every failure is told to stderr, so a review run on the
-// second choice says why the first was passed over. A reason is one line:
-// a credential error walks the whole SDK ladder before it reaches the user,
-// and the ladder is not what they asked about.
-func (c Chain) pick(ctx context.Context, warn func(string)) (Provider, string, error) {
- first := func(err error) string {
- s := err.Error()
- if i := strings.IndexAny(s, "\r\n"); i >= 0 {
- s = s[:i]
- }
- return s
- }
- var reasons []string
- for _, e := range c {
- pctx, cancel := context.WithTimeout(ctx, probeTimeout)
- answer, err := e.Provider.Ask(pctx, "Answer the user's message.", "Answer with the single word OK.")
- cancel()
- if err == nil && strings.TrimSpace(answer.Text) != "" {
- return e.Provider, e.Name, nil
- }
- if err == nil {
- err = fmt.Errorf("answered nothing")
- }
- warn(fmt.Sprintf("skipping %s: %s", e.Name, first(err)))
- reasons = append(reasons, fmt.Sprintf("%s: %s", e.Name, first(err)))
- }
- return nil, "", fmt.Errorf("no provider answered: %s", strings.Join(reasons, "; "))
-}
diff --git a/chain_test.go b/chain_test.go
@@ -1,98 +0,0 @@
-package main
-
-import (
- "context"
- "fmt"
- "strings"
- "testing"
-)
-
-// stub answers from a script: each ask consumes the next outcome, and the
-// count records how far a chain walked.
-type stub struct {
- outcomes []string // one per ask: "" means error, "ok" means text
- asks int
- label string
-}
-
-func (s *stub) Name() string { return s.label }
-
-func (s *stub) Ask(ctx context.Context, system, user string) (Answer, error) {
- s.asks++
- outcome := "error"
- if s.asks <= len(s.outcomes) {
- outcome = s.outcomes[s.asks-1]
- }
- switch outcome {
- case "ok":
- return Answer{Text: "OK"}, nil
- case "empty":
- return Answer{}, nil
- default:
- return Answer{}, fmt.Errorf("429: too many requests")
- }
-}
-
-func chainOf(entries ...*stub) Chain {
- out := Chain{}
- for _, p := range entries {
- out = append(out, entry{Name: p.label, Provider: p})
- }
- return out
-}
-
-func TestChainPicksTheFirstThatAnswers(t *testing.T) {
- first, second := &stub{label: "first", outcomes: []string{"ok"}}, &stub{label: "second", outcomes: []string{"ok"}}
- provider, name, err := chainOf(first, second).pick(context.Background(), func(string) {})
- if err != nil {
- t.Fatalf("pick: %v", err)
- }
- if name != "first" || provider != Provider(first) {
- t.Fatalf("picked %q, want the first entry", name)
- }
- if first.asks != 1 || second.asks != 0 {
- t.Fatalf("asks: first %d, second %d, want 1 and 0", first.asks, second.asks)
- }
-}
-
-func TestChainSkipsAFailingProvider(t *testing.T) {
- first, second := &stub{label: "first", outcomes: []string{"error"}}, &stub{label: "second", outcomes: []string{"ok"}}
- var warned []string
- provider, name, err := chainOf(first, second).pick(context.Background(), func(s string) { warned = append(warned, s) })
- if err != nil || name != "second" || provider != Provider(second) {
- t.Fatalf("pick: %v, %q", err, name)
- }
- if first.asks != 1 || second.asks != 1 {
- t.Fatalf("asks: first %d, second %d", first.asks, second.asks)
- }
- if len(warned) != 1 || !strings.Contains(warned[0], "too many requests") {
- t.Fatalf("warned %q, want the failure reason", warned)
- }
-}
-
-func TestChainSkipsAnEmptyAnswer(t *testing.T) {
- first, second := &stub{label: "first", outcomes: []string{"empty"}}, &stub{label: "second", outcomes: []string{"ok"}}
- provider, name, err := chainOf(first, second).pick(context.Background(), func(string) {})
- if err != nil {
- t.Fatalf("pick: %v", err)
- }
- if name != "second" || provider != Provider(second) {
- t.Fatalf("picked %q, want the second entry", name)
- }
- if first.asks != 1 || second.asks != 1 {
- t.Fatalf("asks: first %d, second %d", first.asks, second.asks)
- }
-}
-
-func TestChainReportsEveryReasonWhenAllFail(t *testing.T) {
- first, second := &stub{label: "first", outcomes: []string{"error"}}, &stub{label: "second", outcomes: []string{"empty"}}
- _, _, err := chainOf(first, second).pick(context.Background(), func(string) {})
- if err == nil {
- t.Fatal("pick: want an error naming every entry")
- }
- for _, want := range []string{"first", "second", "too many requests", "answered nothing"} {
- if !strings.Contains(err.Error(), want) {
- t.Fatalf("pick: %v, want it to name %q", err, want)
- }
- }
-}
diff --git a/odin/change/change.odin b/change/change.odin
diff --git a/odin/change/change_test.odin b/change/change_test.odin
diff --git a/odin/change/index.odin b/change/index.odin
diff --git a/odin/check/check.odin b/check/check.odin
diff --git a/odin/check/check_test.odin b/check/check_test.odin
diff --git a/odin/check/clones.odin b/check/clones.odin
diff --git a/odin/check/coverage.odin b/check/coverage.odin
diff --git a/odin/check/formatting.odin b/check/formatting.odin
diff --git a/odin/check/gaming.odin b/check/gaming.odin
diff --git a/odin/check/leftovers.odin b/check/leftovers.odin
diff --git a/odin/check/message.odin b/check/message.odin
diff --git a/odin/check/names.odin b/check/names.odin
diff --git a/odin/check/prose.odin b/check/prose.odin
diff --git a/odin/check/rules.odin b/check/rules.odin
diff --git a/odin/check/shape.odin b/check/shape.odin
diff --git a/odin/check/tests.odin b/check/tests.odin
diff --git a/client.go b/client.go
@@ -1,620 +0,0 @@
-package main
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "os"
- "regexp"
- "slices"
- "strings"
- "sync"
- "time"
-
- "github.com/anthropics/anthropic-sdk-go"
-)
-
-// instruction is what every job is told, before its own criteria. It is kept
-// identical across jobs and providers, so the only thing that differs between
-// two readings is the criteria and the subject.
-const instruction = `You are reviewing one narrow aspect of a change to a repository.
-
-Report only what the criteria below cover. Everything else is another reader's
-job: say nothing about formatting, style, performance, or correctness unless a
-criterion names it.
-
-Rules for reporting:
-- Every finding cites one rule id from the criteria, exactly as written. A
- finding citing anything else is discarded.
-- Severity is must-fix when the criterion is plainly broken, consider when it
- is a judgement call, note otherwise.
-- A finding names the file and line it concerns where it has one.
-- The fix is the concrete change to make, not a restatement of the problem.
-- Reporting nothing is the right answer when the criteria are met. Do not
- manufacture findings to appear useful.
-- You are shown only part of the change. Never infer what the rest contains.
-
-Answer with one JSON object and nothing else. No preamble, no explanation, no
-code fence:
-
-{"findings":[{"rule":"","severity":"must-fix|consider|note","file":"","line":0,"symbol":"","message":"","fix":""}]}
-
-Report no findings as {"findings":[]}.`
-
-// verifyInstruction is what the second reading is told. It sees what the
-// first one saw — the same criteria, the same part of the change — and
-// nothing the first one concluded beyond the findings themselves, so that a
-// verdict is a reading of the evidence rather than an agreement with a
-// colleague.
-const verifyInstruction = `You are checking findings another reviewer made against one narrow aspect of a change.
-
-You are shown the criteria that reader judged against, the same part of the
-change it read, and the findings it reported. For each finding, decide
-whether it holds: the code it points at must meet the fault its rule
-describes, judged from the evidence in front of you.
-
-- Judge every finding, by its number. Report no verdicts but those.
-- holds is false for a finding you would not report yourself from this
- evidence; say why in reason, in one sentence.
-- A finding that holds but overstates its case does not hold as written.
-
-Answer with one JSON object and nothing else. No preamble, no explanation, no
-code fence:
-
-{"verdicts":[{"index":0,"holds":true,"reason":""}]}`
-
-// verdictsAgain is what the second reading is told when it answered in
-// prose, in the shape of the retry the findings get.
-const verdictsAgain = `Your previous answer was not a JSON object, so nothing was read from it.
-Answer again, with one verdict per finding, as one JSON object and nothing else.`
-
-// rule matches a rule id where the criteria define one, which is the list a
-// job is allowed to cite.
-var rule = regexp.MustCompile("(?m)^- `([a-z0-9-]+)`")
-
-// rules reads the ids a job may cite out of its own criteria.
-func rules(criteria string) map[string]bool {
- out := map[string]bool{}
- for _, match := range rule.FindAllStringSubmatch(criteria, -1) {
- out[match[1]] = true
- }
- return out
-}
-
-// Retracted is a finding the verification pass did not let stand, and the
-// reason. It is reported rather than dropped, so that a reading that retracts
-// half of what it found says so instead of looking tidy.
-type Retracted struct {
- Finding Finding `json:"finding"`
- Reason string `json:"reason"`
-}
-
-// RunResult is what a set of readings produced. The parts beyond the
-// findings are what a program reading the JSON needs to trust an empty
-// list: which jobs failed, which had nothing to read, and what the asking
-// cost. An empty findings list against a run where every job failed is not
-// a pass, and the result says which.
-type RunResult struct {
- Findings []Finding
- Failures []error
- Retracted []Retracted
- Skipped []string
- In int
- Out int
- Cached int
- Replayed int
- Cost float64
-}
-
-// Reviewer runs jobs against a change, through whichever provider answers.
-// Verify puts each job's findings back to the provider once, against the
-// same evidence; the ones a second reading does not let stand are retracted
-// rather than reported. Cache records what each ask cost and answered, so
-// that a re-run of an unchanged part of the change replays instead of
-// asking.
-type Reviewer struct {
- Provider Provider
- Verify bool
- Verbose bool
- Cache *AnswerCache
-}
-
-// Run works every job that has something to read, at once. One job failing
-// does not stop the others: four readings out of five is worth more than none.
-// REVIEW_SERIAL asks them one at a time instead, for providers that cannot
-// take concurrent reads — the local maple gateway, which was observed to
-// answer the biggest ask empty four runs in a row under parallelism and to
-// answer the same ask fine on its own.
-func (r Reviewer) Run(ctx context.Context, change *Change, jobs []Job) RunResult {
- var (
- mu sync.Mutex
- result RunResult
- )
- record := func(job Job, findings []Finding, answer Answer, err error) {
- mu.Lock()
- defer mu.Unlock()
- result.In += answer.In
- result.Out += answer.Out
- result.Cached += answer.Cached
- result.Cost += answer.Cost
- if answer.Replayed {
- result.Replayed++
- }
- if err != nil {
- result.Failures = append(result.Failures, fmt.Errorf("%s: %w", job.Name, err))
- return
- }
- 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) == "" {
- mu.Lock()
- result.Skipped = append(result.Skipped, job.Name)
- mu.Unlock()
- if r.Verbose {
- fmt.Printf(" %-12s nothing to read\n", job.Name)
- }
- return
- }
- 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 {
- read(job)
- }
- } else {
- var wg sync.WaitGroup
- for _, job := range jobs {
- wg.Add(1)
- go func(job Job) {
- defer wg.Done()
- read(job)
- }(job)
- }
- wg.Wait()
- }
- // The skipped jobs are named in the jobs' own order, whichever
- // goroutine said so first, so the report reads the same on every run.
- order := map[string]int{}
- for i, job := range jobs {
- order[job.Name] = i
- }
- slices.SortFunc(result.Skipped, func(a, b string) int { return order[a] - order[b] })
- if r.Verify {
- r.verify(ctx, cuts, jobs, &result)
- }
- return result
-}
-
-// 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
-}
-
-// verify puts each job's findings back to the provider once, against the
-// same evidence the first reading had, and keeps only the ones it also
-// reports. A reading that misread the shortlist — the measured way a small
-// model reports the first duplicate it finds — answers differently when the
-// 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, cuts map[string][]*Change, jobs []Job, result *RunResult) {
- var groups []verifyGroup
- for _, job := range jobs {
- 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(groups) == 0 {
- return
- }
- var (
- mu sync.Mutex
- wg sync.WaitGroup
- drop = map[int]bool{}
- )
- apply := func(group verifyGroup) {
- 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()
- mu.Lock()
- defer mu.Unlock()
- result.In += answer.In
- result.Out += answer.Out
- result.Cached += answer.Cached
- result.Cost += answer.Cost
- if answer.Replayed {
- result.Replayed++
- }
- if err != nil {
- result.Failures = append(result.Failures, fmt.Errorf("verify/%s: %w", group.job.Name, err))
- for _, i := range group.indexes {
- result.Findings[i].Verified = false
- }
- return
- }
- for position, i := range group.indexes {
- if v, judged := held[position]; judged && !v.Holds {
- result.Retracted = append(result.Retracted, Retracted{
- Finding: result.Findings[i],
- Reason: v.Reason,
- })
- drop[i] = true
- continue
- }
- // A finding the verdict list omits stands rather than
- // falls: a strict pass would let one dropped number
- // retract everything the reading found.
- result.Findings[i].Verified = true
- }
- }
- defer func() {
- // A retracted finding is out of the findings: it is reported as a
- // retraction, with its reason, not twice over.
- if len(drop) == 0 {
- return
- }
- survivors := make([]Finding, 0, len(result.Findings))
- for i, f := range result.Findings {
- if !drop[i] {
- survivors = append(survivors, f)
- }
- }
- result.Findings = survivors
- }()
- if os.Getenv("REVIEW_SERIAL") != "" {
- for _, group := range groups {
- apply(group)
- }
- return
- }
- for _, group := range groups {
- wg.Add(1)
- go func(group verifyGroup) {
- defer wg.Done()
- apply(group)
- }(group)
- }
- wg.Wait()
-}
-
-// again is what a job is told when it answered in prose. Only the api
-// provider can be held to a schema; the rest describe their findings instead
-// often enough that losing the whole reading to it is the worse outcome.
-const again = `Your previous answer was not a JSON object, so nothing was read from it.
-Answer again, with the findings you already made, as one JSON object and nothing else.`
-
-// askTimeout bounds one job's ask. The probe that picks a provider is
-// bounded, and a job should be too: a gateway that accepts a reachability
-// ask and then stalls on a real one must not hold the review forever. A
-// timed-out job is a failed job, so the rest of the readings and the
-// deterministic checks still report. Measured serial readings through the
-// maple gateway run about 80 seconds at the largest; this is four times
-// that, and REVIEW_ASK_TIMEOUT can widen it.
-const askTimeout = 5 * time.Minute
-
-func askTimeoutDuration() time.Duration {
- if s := os.Getenv("REVIEW_ASK_TIMEOUT"); s != "" {
- if d, err := time.ParseDuration(s); err == nil && d > 0 {
- return d
- }
- }
- return askTimeout
-}
-
-// askContext is one ask's deadline, counted from the ask's start.
-func askContext(ctx context.Context) (context.Context, context.CancelFunc) {
- return context.WithTimeout(ctx, askTimeoutDuration())
-}
-
-// ask puts one job's question. The criteria are the system prompt, where a
-// provider that caches anything will cache them; the subject goes last. The
-// retry ask below gets its own deadline: a second chance is not bound by
-// 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, readableFindings)
- if err != nil {
- return nil, Answer{}, err
- }
- r.spent(job, answer)
- 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, readableFindings)
- if err != nil {
- return nil, Answer{}, err
- }
- r.spent(job, answer)
- if raw, err = object(answer.Text); err != nil {
- return nil, answer, fmt.Errorf("%w, twice: %s", err, prose)
- }
- }
- reported, err := decode(raw)
- if err != nil {
- return nil, answer, err
- }
- return reported.findings(job.Name, rules(job.Criteria)), answer, nil
-}
-
-// 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. 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 {
- fmt.Printf(" %-12s replayed\n", r.Provider.Name())
- }
- return answer, nil
- }
- }
- answer, err := ask(ctx, system, user)
- 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
- }
- if answer.Replayed {
- fmt.Printf(" %-12s replayed\n", job.Name)
- return
- }
- fmt.Printf(" %-12s %d in, %d out, %d cached, $%.4f\n",
- job.Name, answer.In, answer.Out, answer.Cached, answer.Cost)
-}
-
-// verdict is what the second reading says about one finding.
-type verdict struct {
- Holds bool
- Reason string
-}
-
-// verdicts puts one job's findings back to the provider, against the same
-// evidence the first reading had. Verdicts are keyed by position in the
-// listing; a position the answer omits stands rather than falls.
-func (r Reviewer) verdicts(ctx context.Context, job Job, subject string, findings []Finding, indexes []int) (map[int]verdict, Answer, error) {
- system := verifyInstruction + "\n\n" + job.Criteria
- var listed strings.Builder
- for position, i := range indexes {
- f := findings[i]
- where := ""
- if f.File != "" {
- where = fmt.Sprintf(" at %s:%d", f.File, f.Line)
- }
- fmt.Fprintf(&listed, "%d. [%s] %s%s: %s\n fix: %s\n",
- 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), readableVerdicts)
- if err != nil {
- return nil, answer, err
- }
- r.spent(job, answer)
- 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), readableVerdicts)
- if err != nil {
- return nil, answer, err
- }
- r.spent(job, answer)
- if raised, err = object(answer.Text); err != nil {
- return nil, answer, fmt.Errorf("%w, twice: %s", err, prose)
- }
- }
- read, err := decodeVerdicts(raised)
- if err != nil {
- return nil, answer, err
- }
- held := map[int]verdict{}
- for _, v := range read.Verdicts {
- if v.Index >= 0 && v.Index < len(indexes) {
- held[v.Index] = verdict{Holds: v.Holds, Reason: v.Reason}
- }
- }
- 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.
-func verdictAsk(provider Provider) func(context.Context, string, string) (Answer, error) {
- if v, ok := provider.(Verdicts); ok {
- return v.AskVerdict
- }
- return provider.Ask
-}
-
-func first(s string, n int) string {
- s = strings.TrimSpace(s)
- if len(s) > n {
- return s[:n] + "…"
- }
- return s
-}
-
-// API asks the console API directly. It is the one provider that can enforce
-// the answer's shape rather than request it, so a malformed answer is
-// impossible here rather than merely unlikely.
-type API struct{ Model string }
-
-func (a API) Name() string { return "api/" + a.Model }
-
-// schema is the shape a finding takes, enforced where a provider can enforce
-// it and described in the prompt where it cannot.
-var schema = anthropic.ToolInputSchemaParam{
- Properties: map[string]any{
- "findings": map[string]any{
- "type": "array",
- "items": map[string]any{
- "type": "object",
- "properties": map[string]any{
- "rule": map[string]any{"type": "string", "description": "the rule id from the criteria"},
- "severity": map[string]any{"type": "string", "enum": []string{"must-fix", "consider", "note"}},
- "file": map[string]any{"type": "string"},
- "line": map[string]any{"type": "integer"},
- "symbol": map[string]any{"type": "string"},
- "message": map[string]any{"type": "string", "description": "one sentence stating the finding"},
- "fix": map[string]any{"type": "string", "description": "the concrete change to make"},
- },
- "required": []string{"rule", "severity", "message", "fix", "file", "line", "symbol"},
- "additionalProperties": false,
- },
- },
- },
- Required: []string{"findings"},
-}
-
-// verdictSchema is the shape the second reading answers in, held to the
-// same strictness where the provider allows it.
-var verdictSchema = anthropic.ToolInputSchemaParam{
- Properties: map[string]any{
- "verdicts": map[string]any{
- "type": "array",
- "items": map[string]any{
- "type": "object",
- "properties": map[string]any{
- "index": map[string]any{"type": "integer", "description": "the number of the finding, as it was listed"},
- "holds": map[string]any{"type": "boolean", "description": "whether the finding stands against the criteria"},
- "reason": map[string]any{"type": "string", "description": "why it holds or falls, one sentence"},
- },
- "required": []string{"index", "holds", "reason"},
- "additionalProperties": false,
- },
- },
- },
- Required: []string{"verdicts"},
-}
-
-func (a API) Ask(ctx context.Context, system, user string) (Answer, error) {
- return a.call(ctx, system, user, "report_findings", "Report what this reading found, or an empty list.", schema)
-}
-
-// Verdicts is what a provider offers when it can hold the second reading to
-// its shape as well.
-type Verdicts interface {
- AskVerdict(ctx context.Context, system, user string) (Answer, error)
-}
-
-func (a API) AskVerdict(ctx context.Context, system, user string) (Answer, error) {
- return a.call(ctx, system, user, "report_verdicts", "Report whether each finding holds.", verdictSchema)
-}
-
-// call asks one question with one strict tool, which is the whole of both
-// asks: the shape differs, the conversation does not.
-func (a API) call(ctx context.Context, system, user, name, description string, shape anthropic.ToolInputSchemaParam) (Answer, error) {
- tool := anthropic.ToolParam{
- Name: name,
- Description: anthropic.String(description),
- InputSchema: shape,
- Strict: anthropic.Bool(true),
- }
- client := anthropic.NewClient()
- resp, err := client.Messages.New(ctx, anthropic.MessageNewParams{
- Model: a.Model,
- MaxTokens: 4096,
- // Pinned, because a loop between two models cannot converge if one of
- // them answers differently each time it is asked.
- Temperature: anthropic.Float(0),
- System: []anthropic.TextBlockParam{{
- Text: system,
- CacheControl: anthropic.NewCacheControlEphemeralParam(),
- }},
- Tools: []anthropic.ToolUnionParam{{OfTool: &tool}},
- Messages: []anthropic.MessageParam{anthropic.NewUserMessage(anthropic.NewTextBlock(user))},
- })
- if err != nil {
- return Answer{}, err
- }
- answer := Answer{
- In: int(resp.Usage.InputTokens), Out: int(resp.Usage.OutputTokens),
- Cached: int(resp.Usage.CacheReadInputTokens),
- }
- for _, block := range resp.Content {
- if use, ok := block.AsAny().(anthropic.ToolUseBlock); ok {
- answer.Text = use.JSON.Input.Raw()
- return answer, nil
- }
- }
- return answer, fmt.Errorf("the model answered without reporting")
-}
diff --git a/clones.go b/clones.go
@@ -1,212 +0,0 @@
-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
@@ -1,129 +0,0 @@
-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
@@ -1,203 +0,0 @@
-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
@@ -1,151 +0,0 @@
-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/eval_test.go b/eval_test.go
@@ -1,383 +0,0 @@
-package main
-
-import (
- "context"
- "flag"
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "sync"
- "testing"
-)
-
-// The eval set is the only thing here that spends money, so it is off unless
-// asked for: go test -run TestEval -eval=1. More than one run per case is
-// worth having, because a single reading walks between the two failure
-// directions rather than sitting at one.
-var (
- evalRuns = flag.Int("eval", 0, "Run the eval set this many times per case. This asks a provider, and costs money.")
- evalCases = flag.String("eval.cases", "", "Run only the eval cases whose names contain this.")
- evalShow = flag.Bool("eval.show", false, "Print every finding, not only the ones that scored.")
-)
-
-// A want is a finding the reading has to produce. The rule and the file are
-// compared where they are given; About is the words that identify which
-// duplicate, name or assertion is meant.
-type want struct {
- Rule string
- File string
- About []string
-}
-
-func (w want) String() string {
- about := strings.Join(w.About, "+")
- if about == "" {
- about = "anything at all"
- }
- if w.File != "" {
- about += " in " + w.File
- }
- if w.Rule == "" {
- return about
- }
- return w.Rule + " about " + about
-}
-
-// matches reports whether a finding is the one wanted. The subject is looked
-// for in what the finding says rather than where it says it, so that a case
-// about one name is not answered by any finding in a file of that name.
-func (w want) matches(f Finding) bool {
- if w.Rule != "" && f.Rule != w.Rule {
- return false
- }
- if w.File != "" && f.File != w.File {
- return false
- }
- text := strings.ToLower(strings.Join([]string{f.Message, f.Fix, f.Symbol}, " "))
- for _, about := range w.About {
- if !strings.Contains(text, strings.ToLower(about)) {
- return false
- }
- }
- return true
-}
-
-// An evalCase is one reading with a known right answer. The silences matter
-// more than the wants: a reading that reports a true and unwanted thing is
-// the failure this set exists to catch.
-type evalCase struct {
- Name string
- // Corpus builds or names the repository to read.
- Corpus func(*testing.T) string
- Rev string
- Jobs string
- Want []want
- // Silent is the subjects no finding may raise.
- Silent []want
- // Most is the largest number of findings the reading may report. Left
- // out, any number is allowed: it is the cases with one right answer that
- // need a ceiling.
- Most int
-}
-
-func evalSet() []evalCase {
- return []evalCase{{
- Name: "duplication/layout-crosses-a-seam",
- Corpus: icns,
- Rev: "ebed95f^..ebed95f",
- Jobs: "duplication",
- Want: []want{
- {Rule: "layout-crosses-a-seam", About: []string{"groupHeaderSize"}},
- {About: []string{"icoEntrySize"}},
- },
- Silent: []want{
- // Per-package error sets are a deliberate convention here.
- {About: []string{"ErrNoIcons"}},
- // A resource type id and a row width are not the same fact
- // however equal they are.
- {About: []string{"typeIconGroup"}},
- },
- }, {
- Name: "namer/adjective-for-a-type",
- Corpus: icns,
- Rev: "6f52a0b^..6f52a0b",
- Jobs: "namer",
- Want: []want{{Rule: "noun-for-type", About: []string{"Stored"}}},
- Silent: []want{
- {About: []string{"maxIcons"}},
- {About: []string{"payload"}},
- {About: []string{"largest"}},
- },
- Most: 1,
- }, {
- // A purely mechanical loop rewrite. Every finding here is noise.
- Name: "quiet/mechanical-rewrite",
- Corpus: icns,
- Rev: "4c2e616^..4c2e616",
- Jobs: "duplication,namer,claims",
- Silent: []want{{About: []string{""}}},
- }, {
- Name: "tests/skips-in-normal-conditions",
- Corpus: synthetic,
- Rev: "",
- Jobs: "tests",
- Want: []want{{Rule: "skips-in-normal-conditions", File: "shell_test.go"}},
- // No ceiling: the skipping test has one fault worth the name, but a
- // second reading of how weakly it asserts is a judgement the criteria
- // allow. The test that is meant to be good is the negative here.
- Silent: []want{{About: []string{"reverse"}}, {About: []string{"TestDecodeReverses"}}},
- }, {
- Name: "claims/unsupported-claim",
- Corpus: synthetic,
- Rev: "",
- Jobs: "claims",
- Want: []want{{Rule: "unsupported-claim", About: []string{"Windows"}}},
- Silent: []want{{File: "shell.go", About: []string{"decode"}}},
- Most: 1,
- }}
-}
-
-// icns is the corpus: a repository with rich history and known problems.
-func icns(t *testing.T) string {
- t.Helper()
- root := os.Getenv("REVIEW_EVAL_CORPUS")
- if root == "" {
- home, err := os.UserHomeDir()
- if err != nil {
- t.Skip("no home directory to find the corpus in")
- }
- root = filepath.Join(home, "Source", "Personal", "icns")
- }
- if _, err := os.Stat(filepath.Join(root, ".git")); err != nil {
- t.Skipf("no corpus at %s; set REVIEW_EVAL_CORPUS", root)
- }
- return root
-}
-
-// synthetic is a repository written for the cases the corpus does not happen
-// to contain.
-func synthetic(t *testing.T) string {
- t.Helper()
- r := newRepo(t)
- r.write("go.mod", "module synthetic\n\ngo 1.27.0\n")
- r.write("shell.go", "package synthetic\n")
- r.write("shell_test.go", "package synthetic\n")
- r.commit("first", "go.mod", "shell.go", "shell_test.go")
-
- r.write("shell.go", `package synthetic
-
-// Windows refuses an icon group whose rows are not ordered largest first, so
-// the rows are sorted before they are written.
-func order(rows []int) []int { return rows }
-
-// decode reads the bitmap an icon holds, turning it the other way up.
-func decode(data []byte) []byte {
- out := make([]byte, len(data))
- for i, b := range data {
- out[len(data)-1-i] = b
- }
- return out
-}
-`)
- r.write("shell_test.go", `package synthetic
-
-import (
- "os/exec"
- "testing"
-)
-
-func TestAgainstIcotool(t *testing.T) {
- if _, err := exec.LookPath("icotool"); err != nil {
- t.Skip("icotool is not installed")
- }
- out, err := exec.Command("icotool", "--list", "testdata/one.ico").Output()
- if err != nil {
- t.Fatal(err)
- }
- const want = "--icon --index=1 --width=16 --height=16 --bit-depth=32\n"
- if string(out) != want {
- t.Fatalf("got %q, want %q", out, want)
- }
-}
-
-func TestDecodeReverses(t *testing.T) {
- got := decode([]byte{1, 2, 3})
- if len(got) != 3 || got[0] != 3 || got[1] != 2 || got[2] != 1 {
- t.Fatalf("got %v, want the bytes the other way up", got)
- }
-}
-`)
- r.stage("shell.go", "shell_test.go")
- return r.Root
-}
-
-// counting wraps a provider so a run can report what it spent.
-type counting struct {
- Provider
- mu sync.Mutex
- cost float64
- asks int
-}
-
-func (c *counting) Ask(ctx context.Context, system, user string) (Answer, error) {
- answer, err := c.Provider.Ask(ctx, system, user)
- c.mu.Lock()
- c.cost += answer.Cost
- c.asks++
- c.mu.Unlock()
- return answer, err
-}
-
-// score is one case's result over however many runs it was given. The
-// subjects keep the order the case lists them in, so two scoreboards can be
-// read side by side.
-type score struct {
- Name string
- // Most is the largest number of findings any run reported, where that
- // was more than the case allows.
- Most int
- Found []tally
- Noise []tally
- Failures int
-}
-
-// tally is how often one subject was raised across the runs.
-type tally struct {
- Subject string
- Count int
-}
-
-func TestEval(t *testing.T) {
- if *evalRuns <= 0 {
- t.Skip("the eval set asks a provider; run it with -eval=1")
- }
- which := orElse(os.Getenv("REVIEW_PROVIDER"), "claude")
- build, known := Providers()[which]
- if !known {
- t.Fatalf("no provider called %q", which)
- }
- provider := &counting{Provider: build(os.Getenv("REVIEW_MODEL"))}
-
- var scores []score
- for _, c := range evalSet() {
- if *evalCases != "" && !strings.Contains(c.Name, *evalCases) {
- continue
- }
- t.Run(c.Name, func(t *testing.T) {
- scores = append(scores, runCase(t, c, provider))
- })
- }
- t.Cleanup(func() {
- board := scoreboard(scores, *evalRuns)
- t.Log("\n" + board)
- fmt.Printf("\n%s\nasked %s %d times for $%.4f\n", board, provider.Name(), provider.asks, provider.cost)
- })
-}
-
-func runCase(t *testing.T, c evalCase, provider Provider) score {
- root := c.Corpus(t)
- change, err := Gather(c.Rev, root)
- if err != nil {
- t.Fatal(err)
- }
- jobs, err := chosen(c.Jobs)
- if err != nil {
- t.Fatal(err)
- }
- s := score{Name: c.Name}
- for _, w := range c.Want {
- s.Found = append(s.Found, tally{Subject: w.String()})
- }
- for _, w := range c.Silent {
- s.Noise = append(s.Noise, tally{Subject: w.String()})
- }
- for range *evalRuns {
- result := Reviewer{Provider: provider}.Run(context.Background(), change, jobs)
- for _, err := range result.Failures {
- t.Errorf("job failed: %v", err)
- s.Failures++
- }
- for i, w := range c.Want {
- if raised(w, result.Findings) {
- s.Found[i].Count++
- }
- }
- for i, w := range c.Silent {
- if raised(w, result.Findings) {
- s.Noise[i].Count++
- }
- }
- if c.Most > 0 && len(result.Findings) > c.Most {
- s.Most = max(s.Most, len(result.Findings))
- }
- if *evalShow {
- for _, f := range result.Findings {
- t.Logf(" %s", strings.ReplaceAll(f.String(), "\n", " "))
- }
- if len(result.Findings) == 0 {
- t.Log(" (nothing reported)")
- }
- }
- }
- for _, found := range s.Found {
- if found.Count < *evalRuns {
- t.Errorf("missed %s in %d of %d runs", found.Subject, *evalRuns-found.Count, *evalRuns)
- }
- }
- for _, noise := range s.Noise {
- if noise.Count > 0 {
- t.Errorf("raised %s in %d of %d runs, which is true but unwanted", noise.Subject, noise.Count, *evalRuns)
- }
- }
- if s.Most > 0 {
- t.Errorf("reported %d findings at most, where %d is the whole right answer", s.Most, c.Most)
- }
- return s
-}
-
-func raised(w want, findings []Finding) bool {
- for _, f := range findings {
- if w.matches(f) {
- return true
- }
- }
- return false
-}
-
-// scoreboard lays the results out so that a change to the criteria can be
-// read as a movement rather than a verdict.
-func scoreboard(scores []score, runs int) string {
- var b strings.Builder
- var found, wanted, noise int
- fmt.Fprintf(&b, "eval over %d run(s) per case\n", runs)
- for _, s := range scores {
- fmt.Fprintf(&b, "\n%s\n", s.Name)
- for _, t := range s.Found {
- label := "found"
- if t.Count < runs {
- label = "MISS "
- }
- fmt.Fprintf(&b, " %s %d/%d %s\n", label, t.Count, runs, t.Subject)
- found += t.Count
- wanted += runs
- }
- for _, t := range s.Noise {
- label := "quiet"
- if t.Count > 0 {
- label = "NOISE"
- }
- fmt.Fprintf(&b, " %s %d/%d %s\n", label, t.Count, runs, t.Subject)
- noise += t.Count
- }
- if s.Most > 0 {
- fmt.Fprintf(&b, " LOUD reported as many as %d findings\n", s.Most)
- noise++
- }
- if s.Failures > 0 {
- fmt.Fprintf(&b, " failed %d time(s)\n", s.Failures)
- }
- }
- fmt.Fprintf(&b, "\nfound %d of %d wanted, %d unwanted\n", found, wanted, noise)
- return b.String()
-}
diff --git a/finding.go b/finding.go
@@ -1,211 +0,0 @@
-package main
-
-import (
- "bufio"
- "cmp"
- "crypto/sha256"
- "encoding/hex"
- "fmt"
- "os"
- "regexp"
- "slices"
- "strconv"
- "strings"
-)
-
-// Severity is what a finding means for the change.
-type Severity int
-
-// The severities, most serious first. Only MustFix is worth blocking on; the
-// rest are reported and left to judgement, because a loop that treats taste
-// as an error never finishes.
-const (
- MustFix Severity = iota
- Consider
- Note
-)
-
-func (s Severity) String() string {
- switch s {
- case MustFix:
- return "must-fix"
- case Consider:
- return "consider"
- case Note:
- return "note"
- }
- return "unknown"
-}
-
-// ParseSeverity reads a severity the way a job reports it, defaulting to the
-// least serious rather than failing: a job that invents a word should not
-// stop the run.
-func ParseSeverity(s string) Severity {
- switch strings.ToLower(strings.TrimSpace(s)) {
- case "must-fix", "must fix", "mustfix", "error":
- return MustFix
- case "consider", "warning":
- return Consider
- }
- return Note
-}
-
-// Finding is one thing a job noticed.
-type Finding struct {
- // Job is the job that reported it.
- Job string `json:"job"`
- // Rule is the criterion it was judged against. A finding that cites no
- // rule is dropped, so that the criteria are what gets tuned rather than
- // the prompt.
- Rule string `json:"rule"`
- // Severity is how seriously to take it.
- Severity Severity `json:"-"`
- // SeverityName is how it travels in and out of JSON.
- SeverityName string `json:"severity"`
- // File and Line locate it, where it has a location.
- File string `json:"file,omitempty"`
- Line int `json:"line,omitempty"`
- // Symbol names what it concerns, for the findings that are about a name
- // rather than a place.
- Symbol string `json:"symbol,omitempty"`
- // Message states the finding.
- Message string `json:"message"`
- // 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
- // second reading has seen is false until one has.
- Verified bool `json:"verified"`
- // ID is a stable short name for the finding: a hash of what it is about,
- // 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 {
- var b strings.Builder
- b.WriteString(f.Severity.String())
- b.WriteString(": ")
- if f.File != "" {
- b.WriteString(f.File)
- if f.Line > 0 {
- fmt.Fprintf(&b, ":%d", f.Line)
- }
- b.WriteString(": ")
- } else if f.Symbol != "" {
- b.WriteString(f.Symbol)
- b.WriteString(": ")
- }
- b.WriteString(f.Message)
- if f.Fix != "" {
- b.WriteString("\n → ")
- b.WriteString(f.Fix)
- }
- b.WriteString("\n [")
- b.WriteString(f.Job)
- b.WriteString("/")
- b.WriteString(f.Rule)
- b.WriteString("]")
- return b.String()
-}
-
-// Sort orders findings so the ones worth reading first are first.
-func Sort(findings []Finding) {
- slices.SortStableFunc(findings, func(a, b Finding) int {
- if order := cmp.Compare(a.Severity, b.Severity); order != 0 {
- return order
- }
- if order := cmp.Compare(a.File, b.File); order != 0 {
- return order
- }
- return cmp.Compare(a.Line, b.Line)
- })
-}
-
-// 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) {
- 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]
-}
-
-// ignore matches the comment that dismisses a finding. It lives in the source
-// beside what it justifies, rather than in a file of its own, so that the
-// reason is where a reader needs it and survives a clone.
-//
-// //review:ignore <rule> <why>
-var ignore = regexp.MustCompile(`//\s*review:ignore\s+(\S+)(?:\s+(.*))?`)
-
-// Suppressed reports whether the source dismisses a finding, and with what
-// reason. A dismissal on or just above the line it concerns covers that line;
-// one anywhere in a file covers the findings that name no line.
-func Suppressed(f Finding) (string, bool) {
- if f.File == "" {
- return "", false
- }
- file, err := os.Open(f.File)
- if err != nil {
- return "", false
- }
- defer file.Close()
-
- var (
- scanner = bufio.NewScanner(file)
- line int
- )
- for scanner.Scan() {
- line++
- match := ignore.FindStringSubmatch(scanner.Text())
- if match == nil || !rulesMatch(match[1], f.Rule) {
- continue
- }
- why := strings.TrimSpace(match[2])
- if why == "" {
- why = "no reason given"
- }
- // A dismissal with no line to answer to covers the file; otherwise it
- // has to sit within a few lines of what it dismisses, so that moving
- // code does not carry a dismissal somewhere it was never meant.
- if f.Line == 0 || (line >= f.Line-3 && line <= f.Line+1) {
- return why, true
- }
- }
- return "", false
-}
-
-// rulesMatch compares a dismissal against a rule, where "all" dismisses
-// anything the job found at that spot.
-func rulesMatch(dismissed, rule string) bool {
- return dismissed == "all" || strings.EqualFold(dismissed, rule)
-}
-
-// atoi reads a line number a job reported, which may arrive as a string.
-func atoi(s string) int {
- n, err := strconv.Atoi(strings.TrimSpace(s))
- if err != nil {
- return 0
- }
- return n
-}
diff --git a/odin/finding/finding.odin b/finding/finding.odin
diff --git a/odin/finding/finding_test.odin b/finding/finding_test.odin
diff --git a/finding_test.go b/finding_test.go
@@ -1,260 +0,0 @@
-package main
-
-import (
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-func TestParseSeverity(t *testing.T) {
- for _, test := range []struct {
- in string
- want Severity
- }{
- {"must-fix", MustFix},
- {"must fix", MustFix},
- {"MustFix", MustFix},
- {"error", MustFix},
- {" CONSIDER ", Consider},
- {"warning", Consider},
- {"note", Note},
- // A job that invents a word is read as the least serious rather than
- // stopping the run.
- {"catastrophic", Note},
- {"", Note},
- } {
- if got := ParseSeverity(test.in); got != test.want {
- t.Errorf("ParseSeverity(%q) = %v, want %v", test.in, got, test.want)
- }
- }
-}
-
-func TestSeverityString(t *testing.T) {
- for severity, want := range map[Severity]string{
- MustFix: "must-fix", Consider: "consider", Note: "note", Severity(9): "unknown",
- } {
- if got := severity.String(); got != want {
- t.Errorf("Severity(%d) = %q, want %q", severity, got, want)
- }
- }
-}
-
-func TestSort(t *testing.T) {
- findings := []Finding{
- {Severity: Note, File: "a.go", Line: 1},
- {Severity: MustFix, File: "b.go", Line: 9},
- {Severity: MustFix, File: "b.go", Line: 2},
- {Severity: MustFix, File: "a.go", Line: 50},
- {Severity: Consider, File: "a.go", Line: 1},
- }
- Sort(findings)
- var got []string
- for _, f := range findings {
- got = append(got, f.Severity.String()+" "+f.File+":"+string(rune('0'+f.Line%10)))
- }
- want := []string{"must-fix a.go:0", "must-fix b.go:2", "must-fix b.go:9", "consider a.go:1", "note a.go:1"}
- for i := range want {
- if got[i] != want[i] {
- t.Fatalf("got %v, want %v", got, want)
- }
- }
-}
-
-func TestFindingString(t *testing.T) {
- f := Finding{
- Job: "namer", Rule: "noun-for-type", Severity: Consider,
- File: "ico/ico.go", Line: 12, Message: "Stored is an adjective.",
- Fix: "Call it Encoded.",
- }
- got := f.String()
- for _, want := range []string{"consider: ", "ico/ico.go:12: ", "Stored is an adjective.", "→ Call it Encoded.", "[namer/noun-for-type]"} {
- if !strings.Contains(got, want) {
- t.Errorf("%q missing from:\n%s", want, got)
- }
- }
- // A finding about a name rather than a place is located by the name.
- bare := Finding{Job: "namer", Rule: "abbreviation", Symbol: "cfg", Message: "Invented."}
- if !strings.Contains(bare.String(), "cfg: Invented.") {
- t.Errorf("got %q", bare.String())
- }
-}
-
-// Suppressed reads the source the finding names, relative to the working
-// directory, which is the repository root by the time it is called.
-func TestSuppressed(t *testing.T) {
- dir := t.TempDir()
- source := `package x
-
-// nothing here
-const a = 1
-
-//review:ignore restates-a-fact the ico package owns the other one
-const b = 6
-
-const c = 7
-
-const d = 8
-
-const e = 9
-`
- if err := os.WriteFile(filepath.Join(dir, "x.go"), []byte(source), 0o644); err != nil {
- t.Fatal(err)
- }
- t.Chdir(dir)
-
- for _, test := range []struct {
- name string
- finding Finding
- want bool
- why string
- }{{
- name: "a dismissal just above the line covers it",
- finding: Finding{File: "x.go", Line: 7, Rule: "restates-a-fact"},
- want: true,
- why: "the ico package owns the other one",
- }, {
- name: "a dismissal on the line covers it",
- finding: Finding{File: "x.go", Line: 6, Rule: "restates-a-fact"},
- want: true,
- why: "the ico package owns the other one",
- }, {
- name: "a dismissal does not reach a distant line",
- finding: Finding{File: "x.go", Line: 12, Rule: "restates-a-fact"},
- want: false,
- }, {
- name: "a dismissal does not cover another rule",
- finding: Finding{File: "x.go", Line: 7, Rule: "already-named"},
- want: false,
- }, {
- name: "a finding with no line is covered anywhere in the file",
- finding: Finding{File: "x.go", Rule: "restates-a-fact"},
- want: true,
- why: "the ico package owns the other one",
- }, {
- name: "a finding with no file is never dismissed",
- finding: Finding{Rule: "restates-a-fact"},
- want: false,
- }, {
- name: "a file that is not there dismisses nothing",
- finding: Finding{File: "nowhere.go", Line: 1, Rule: "restates-a-fact"},
- want: false,
- }} {
- t.Run(test.name, func(t *testing.T) {
- why, ok := Suppressed(test.finding)
- if ok != test.want {
- t.Fatalf("got %v, want %v", ok, test.want)
- }
- if ok && why != test.why {
- t.Errorf("reason: got %q, want %q", why, test.why)
- }
- })
- }
-}
-
-func TestSuppressedAll(t *testing.T) {
- dir := t.TempDir()
- if err := os.WriteFile(filepath.Join(dir, "x.go"), []byte("//review:ignore all generated\nconst a = 1\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- t.Chdir(dir)
- why, ok := Suppressed(Finding{File: "x.go", Line: 2, Rule: "anything-at-all"})
- if !ok || why != "generated" {
- t.Errorf("got (%q, %v), want (generated, true)", why, ok)
- }
-}
-
-func TestSuppressedWithoutAReason(t *testing.T) {
- dir := t.TempDir()
- if err := os.WriteFile(filepath.Join(dir, "x.go"), []byte("// review:ignore cannot-fail\nfunc TestX(t *testing.T) {}\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- t.Chdir(dir)
- why, ok := Suppressed(Finding{File: "x.go", Line: 2, Rule: "cannot-fail"})
- if !ok {
- t.Fatal("a dismissal with a space after the slashes still dismisses")
- }
- if why != "no reason given" {
- t.Errorf("got %q", why)
- }
-}
-
-func TestRulesMatch(t *testing.T) {
- for _, test := range []struct {
- dismissed, rule string
- want bool
- }{
- {"all", "anything", true},
- {"cannot-fail", "cannot-fail", true},
- {"Cannot-Fail", "cannot-fail", true},
- {"cannot-fail", "name-overclaims", false},
- } {
- if got := rulesMatch(test.dismissed, test.rule); got != test.want {
- t.Errorf("rulesMatch(%q, %q) = %v, want %v", test.dismissed, test.rule, got, test.want)
- }
- }
-}
-
-func TestAtoi(t *testing.T) {
- for in, want := range map[string]int{"12": 12, " 7 ": 7, "": 0, "x": 0, "-3": -3} {
- if got := atoi(in); got != want {
- t.Errorf("atoi(%q) = %d, want %d", in, got, want)
- }
- }
-}
-
-// 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: 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)
- moved := f
- moved.Line = 9
- identify(&moved)
- 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.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
@@ -1,43 +0,0 @@
-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
@@ -1,40 +0,0 @@
-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
@@ -1,196 +0,0 @@
-package main
-
-import (
- "cmp"
- "fmt"
- "maps"
- "os"
- "os/exec"
- "path/filepath"
- "slices"
- "strings"
-)
-
-// Features names what a frontend can read out of a language, so a job can be
-// skipped with a reason where the reading is not possible rather than faked.
-type Features uint8
-
-const (
- // FeatSymbols covers the declarations a change adds, which the
- // duplication and namer jobs read.
- FeatSymbols Features = 1 << iota
- // FeatTests covers whole test functions, which the tests job reads.
- FeatTests
- // FeatComments covers the prose a change adds, which the claims job
- // reads.
- FeatComments
- // FeatIndex covers the declarations already in the repository, which the
- // duplication shortlist is built from.
- FeatIndex
-)
-
-// String renders the mask as the feature names, comma separated.
-func (f Features) String() string {
- names := []struct {
- bit Features
- name string
- }{
- {FeatSymbols, "symbols"},
- {FeatTests, "tests"},
- {FeatComments, "comments"},
- {FeatIndex, "index"},
- }
- var out []string
- for _, n := range names {
- if f&n.bit != 0 {
- out = append(out, n.name)
- }
- }
- if len(out) == 0 {
- return "nothing"
- }
- s := out[0]
- for _, rest := range out[1:] {
- s += ", " + rest
- }
- return s
-}
-
-// Frontend reads one language. Its Change method appends what the change
-// adds; its Whole method reads the declarations already in the repository,
-// which is what a new name is judged against. Language judgment — what
-// counts as exported, which functions are tests, where a doc comment sits —
-// is the frontend's, so the jobs never carry it.
-type Frontend interface {
- Name() string
- // Covers reports whether the path is this frontend's language.
- Covers(path string) bool
- Features() Features
- // Change appends the declarations, tests and prose the added lines of
- // this frontend's files introduce.
- Change(root, rev string, c *Change, added map[string][]int) error
- // Whole reads the declarations in the repository at the revision.
- Whole(root, rev string) ([]Declared, error)
-}
-
-// frontends returns the readers, in coverage order. A frontend whose
-// dependencies are not installed is left out rather than asked for and
-// failed, so its files fall to the heuristic reader and say why.
-func frontends() []Frontend {
- readers := []Frontend{GoFrontend{}}
- // 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{}, pythonFrontend(), rustFrontend())
- }
- // Same for the Odin sidecar, which is built from sidecar/odin in this
- // repository.
- if _, err := exec.LookPath("odin-review-extract"); err == nil {
- readers = append(readers, OdinFrontend{})
- }
- return append(readers, Heuristic{})
-}
-
-// reader picks the first frontend that covers the path.
-func reader(path string, frontends []Frontend) Frontend {
- for _, f := range frontends {
- if f.Covers(path) {
- return f
- }
- }
- return nil
-}
-
-// read parses the change's files through the frontend that covers each. What
-// a language cannot provide is skipped with a line on stderr, so a job's
-// "nothing to read" is explained rather than mysterious. Every file that
-// went without its part is named in the change's uncovered list, because a
-// job that read nothing reports nothing, and the program reading the report
-// has to know which silence is real.
-func (c *Change) read(root, rev string, frontends []Frontend) {
- added := addedLines(c.Diff)
-
- // owned records, per frontend, the paths it covers in this change, so a
- // missing feature is reported once per language rather than per file.
- owned := map[Frontend]map[string]bool{}
- exts := map[Frontend]map[string]bool{}
- for _, name := range c.Files {
- f := reader(name, frontends)
- if f == nil {
- // A file no reader could plausibly exist for — prose, data,
- // configuration — is not a promise the review broke; a code
- // file with no reader is, because a job that would have read
- // it read nothing.
- if isCodeFile(name) {
- c.Uncovered = append(c.Uncovered, Gap{File: name, Reason: "no reader"})
- }
- continue
- }
- if owned[f] == nil {
- owned[f] = map[string]bool{}
- exts[f] = map[string]bool{}
- }
- owned[f][name] = true
- exts[f][filepath.Ext(name)] = true
- }
- if len(owned) == 0 {
- return
- }
-
- missing := func(covers map[string]bool, f Frontend, want Features, jobs string) {
- if f.Features()&want != 0 {
- return
- }
- extensions := make([]string, 0, len(exts[f]))
- for ext := range exts[f] {
- extensions = append(extensions, ext)
- }
- slices.Sort(extensions)
- fmt.Fprintf(os.Stderr, " skipping %s for %s (%d file(s), %s): no parser\n", jobs, strings.Join(extensions, ", "), len(covers), f.Name())
- for _, name := range slices.Sorted(maps.Keys(covers)) {
- c.Uncovered = append(c.Uncovered, Gap{File: name, Reason: "no " + jobs + " parser"})
- }
- }
-
- for f, covers := range owned {
- if err := f.Change(root, rev, c, added); err != nil {
- fmt.Fprintf(os.Stderr, " %s: %v\n", f.Name(), err)
- for _, name := range slices.Sorted(maps.Keys(covers)) {
- c.Uncovered = append(c.Uncovered, Gap{File: name, Reason: "reader failed"})
- }
- continue
- }
- missing(covers, f, FeatSymbols, "duplication, namer")
- missing(covers, f, FeatTests, "tests")
- missing(covers, f, FeatComments, "claims")
- }
- c.order()
-}
-
-// order puts what the frontends read into the diff's own order — file as
-// git lists it, then line — so that a subject renders the same on every
-// run whichever frontend answered first, and the answer cache is hit.
-func (c *Change) order() {
- position := map[string]int{}
- for i, f := range c.Files {
- position[f] = i
- }
- slices.SortStableFunc(c.Symbols, func(a, b Symbol) int {
- if d := cmp.Compare(position[a.File], position[b.File]); d != 0 {
- return d
- }
- return cmp.Compare(a.Line, b.Line)
- })
- slices.SortStableFunc(c.Tests, func(a, b Function) int {
- if d := cmp.Compare(position[a.File], position[b.File]); d != 0 {
- return d
- }
- return cmp.Compare(a.Line, b.Line)
- })
- slices.SortStableFunc(c.Comments, func(a, b Located) int {
- if d := cmp.Compare(position[a.File], position[b.File]); d != 0 {
- return d
- }
- return cmp.Compare(a.Line, b.Line)
- })
-}
diff --git a/odin/frontend/frontend.odin b/frontend/frontend.odin
diff --git a/odin/frontend/frontend_test.odin b/frontend/frontend_test.odin
diff --git a/odin/frontend/grep.odin b/frontend/grep.odin
diff --git a/frontend_test.go b/frontend_test.go
@@ -1,129 +0,0 @@
-package main
-
-import (
- "os"
- "strings"
- "testing"
-)
-
-// captureStderr swaps stderr for a pipe while the work runs, so a test can
-// read what the gatherer told the user.
-func captureStderr(t *testing.T, work func()) string {
- t.Helper()
- real := os.Stderr
- r, w, err := os.Pipe()
- if err != nil {
- t.Fatal(err)
- }
- os.Stderr = w
- work()
- w.Close()
- os.Stderr = real
- var out strings.Builder
- buf := make([]byte, 4096)
- for {
- n, err := r.Read(buf)
- out.Write(buf[:n])
- if err != nil {
- break
- }
- }
- return out.String()
-}
-
-func TestHeuristicReadsOdinComments(t *testing.T) {
- r := newRepo(t)
- r.write("heimdall/main.odin", `package main
-
-import "core:fmt"
-
-main :: proc() {
- fmt.println("hello")
- // counts the icons a binary carries
-}
-`)
- r.commit("main: hello", "heimdall/main.odin")
- r.write("heimdall/main.odin", `package main
-
-import "core:fmt"
-
-// across the whole window
-main :: proc() {
- fmt.println("goodbye")
-}
-`)
- r.stage("heimdall/main.odin")
-
- change, err := Gather("", r.Root)
- if err != nil {
- t.Fatal(err)
- }
- if len(change.Comments) != 1 || change.Comments[0].Text != "across the whole window" {
- t.Fatalf("comments %v, want the one added comment", change.Comments)
- }
-}
-
-func TestHeuristicKeepsBlockCommentsWhole(t *testing.T) {
- r := newRepo(t)
- r.write("f/open.c", "int open(void) { return 0; }\n")
- r.commit("f: open", "f/open.c")
- r.write("f/open.c", `/* reads the icons
- a binary carries */
-int open(void) { return 1; }
-`)
- r.stage("f/open.c")
-
- change, err := Gather("", r.Root)
- if err != nil {
- t.Fatal(err)
- }
- if len(change.Comments) != 2 {
- t.Fatalf("comments %v, want both block lines", change.Comments)
- }
- if change.Comments[0].Text != "reads the icons" || change.Comments[1].Text != "a binary carries" {
- t.Fatalf("comments %q, %q", change.Comments[0].Text, change.Comments[1].Text)
- }
-}
-
-func TestHeuristicLeavesProseAlone(t *testing.T) {
- r := newRepo(t)
- r.write("f/open.c", "int open(void) { return 0; }\n")
- r.commit("f: open", "f/open.c")
- r.write("f/open.c", "#!/usr/bin/env runtime\nint open(void) { return 1; }\n")
- r.stage("f/open.c")
-
- change, err := Gather("", r.Root)
- if err != nil {
- t.Fatal(err)
- }
- if len(change.Comments) != 0 {
- t.Fatalf("comments %v, want a shebang rejected", change.Comments)
- }
-}
-
-func TestHeuristicLogsTheSkips(t *testing.T) {
- r := newRepo(t)
- // 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)
- if err != nil {
- t.Fatal(err)
- }
- logged = captureStderr(t, func() {
- change.read(r.Root, "", frontends())
- })
- 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 .lua") {
- t.Fatalf("logged %q, want the tests skip", logged)
- }
- if strings.Contains(logged, "skipping claims") {
- t.Fatalf("logged %q, want claims served", logged)
- }
-}
diff --git a/gaming.go b/gaming.go
@@ -1,295 +0,0 @@
-package main
-
-// These checks hold the review to itself. A change can pass every job by
-// dismissing what they find or by deleting the tests that would have
-// failed, and no narrow reader would say a word about it: each job reads
-// only its own criteria, and neither mechanism is one job's subject. Both
-// are the person reviewing's decision to make, not the change's progress,
-// so they are measured here, where nothing is asked of a model and nothing
-// can be talked around.
-
-import (
- "fmt"
- "maps"
- "path/filepath"
- "regexp"
- "slices"
- "strings"
-)
-
-// checkSuppressionAdded reports what the change dismisses before the
-// readers run — the criteria name the rule suppression-added. A finding
-// dismissed by the same change that produced it is the reviewer's decision
-// to accept, not the change's progress; left unmeasured, a reading can be
-// iterated into silence one ignore at a time. It is reported as one finding
-// without a file, so it cannot be dismissed in turn — a check that polices
-// the dismissing must not be dismissible.
-func checkSuppressionAdded(c *Change) []Finding {
- added, _ := diffSides(c.Diff)
- var spots []string
- for _, file := range slices.Sorted(maps.Keys(added)) {
- if !isCodeFile(file) {
- continue
- }
- for _, l := range added[file] {
- m := ignore.FindStringSubmatch(l.Text)
- if m == nil || !ruleID.MatchString(m[1]) {
- continue
- }
- spots = append(spots, fmt.Sprintf("%s:%d (%s)", file, l.Line, m[1]))
- }
- }
- if len(spots) == 0 {
- return nil
- }
- return []Finding{{
- Job: "static",
- Rule: "suppression-added",
- Severity: MustFix,
- Message: fmt.Sprintf(
- "the change adds its own dismissal%s: %s; a dismissal is a decision for the person reviewing the change, and a review that dismisses itself cannot be failed",
- plural(len(spots)), strings.Join(spots, "; ")),
- Fix: "correct what the dismissal covers; a dismissal belongs to the person reviewing, who accepts it deliberately",
- }}
-}
-
-// isCodeFile reports whether a path is a place code could live — which is
-// where a dismissal could work, and where a reader could plausibly exist.
-// The prose and data formats are where the mechanism gets documented and
-// where configuration lives, not where findings are made.
-func isCodeFile(path string) bool {
- base := filepath.Base(path)
- switch base {
- case ".gitignore", ".gitattributes", ".gitmodules", ".editorconfig",
- "Makefile", "Dockerfile", "LICENSE", "CODEOWNERS":
- return false
- }
- for _, ext := range []string{
- ".md", ".mdx", ".txt", ".rst", ".adoc", ".json", ".yaml", ".yml",
- ".toml", ".lock", ".sum", ".mod", ".html", ".htm", ".css", ".svg",
- ".xml",
- } {
- if strings.HasSuffix(base, ext) {
- return false
- }
- }
- return true
-}
-
-// ruleID is what a dismissal's rule id has to look like. The prose that
-// documents the mechanism writes placeholders (<rule>) and quoted examples,
-// which match the ignore pattern but dismiss nothing.
-var ruleID = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
-
-// goTest is a test function's declaration, as a removed line carries it.
-var goTest = regexp.MustCompile(`^func ((?:Test|Benchmark|Fuzz)[A-Za-z0-9_]+)\(`)
-
-// jsTestCall is a test's registration in the JavaScript and TypeScript
-// 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: 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."} {
- if strings.Contains(base, marker) {
- 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
-// goes to pass the review. A test the change also adds under a name made of
-// the same words is a rename, and is spared. The shapes read here are a Go
-// test function's and a JS test registration's; tests in other languages
-// are not named by anything this package reads.
-func checkDeletedTests(c *Change) []Finding {
- added, removed := diffSides(c.Diff)
- spoken := addedTestNames(added)
- var findings []Finding
- for _, file := range slices.Sorted(maps.Keys(removed)) {
- if !isTestFile(file) && !strings.HasSuffix(file, ".rs") {
- continue
- }
- var names []string
- seen := map[string]bool{}
- 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
- }
- seen[name] = true
- if !covered(name, spoken) {
- names = append(names, name)
- }
- }
- if len(names) == 0 {
- continue
- }
- shown := names
- if len(shown) > 8 {
- shown = append(shown[:8], fmt.Sprintf("and %d more", len(names)-8))
- }
- var listed []string
- for _, name := range shown {
- listed = append(listed, fmt.Sprintf("%q", name))
- }
- findings = append(findings, Finding{
- Job: "static",
- Rule: "test-deleted",
- Severity: MustFix,
- File: file,
- Symbol: names[0],
- Message: fmt.Sprintf(
- "the change deletes the test%s %s from %s; a deleted test cannot fail again, so deletion is where a failing test goes to pass the review",
- plural(len(names)), strings.Join(listed, ", "), file),
- Fix: fmt.Sprintf("restore the test, or dismiss it where a reader can read why: a review:ignore comment naming test-deleted, with the reason, in %s", file),
- })
- }
- return findings
-}
-
-// removedTestName reads the name of a deleted test out of its removed line,
-// in the shapes the tool's languages write tests in.
-func removedTestName(text string) string {
- trimmed := strings.TrimLeft(text, " \t")
- 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"))
- }
- return ""
-}
-
-// readQuoted reads the string literal a JS test registration is named by,
-// from the text that follows its opening parenthesis.
-func readQuoted(text string) string {
- if len(text) == 0 {
- return ""
- }
- q := text[0]
- if q != '"' && q != '\'' && q != '`' {
- return ""
- }
- if end := strings.IndexByte(text[1:], q); end >= 0 {
- return text[1 : 1+end]
- }
- return ""
-}
-
-// addedTestNames collects the names of the tests a change adds, read with
-// the same shapes the removed side is read with, so that a rename is not
-// read as a deletion.
-func addedTestNames(added map[string][]diffLine) []string {
- seen := map[string]bool{}
- for _, lines := range added {
- for _, l := range lines {
- 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
- }
- }
- }
- }
- return slices.Sorted(maps.Keys(seen))
-}
-
-// covered reports whether a deleted test's name is spoken for by a test the
-// change adds: every word of the old name is in a new one, which is what a
-// rename or a re-anchored test looks like.
-func covered(deleted string, spoken []string) bool {
- want := words(deleted)
- if len(want) == 0 {
- return false
- }
- for _, name := range spoken {
- have := words(name)
- saysAll := true
- for _, w := range want {
- if !slices.Contains(have, w) {
- saysAll = false
- break
- }
- }
- if saysAll {
- return true
- }
- }
- return false
-}
-
-// words breaks a test's name into its lowercased words: camel humps and
-// underscores for the Go shapes, spaces for the strings the JavaScript
-// shapes are named by, with the runner's own prefixes and short leftovers
-// left out.
-func words(name string) []string {
- var out []string
- for _, part := range split(name) {
- for _, piece := range strings.Fields(part) {
- piece = strings.ToLower(piece)
- if len(piece) < 3 {
- continue
- }
- switch piece {
- case "test", "benchmark", "fuzz", "skip", "only", "todo", "fails":
- continue
- }
- out = append(out, piece)
- }
- }
- return out
-}
-
-// plural is the s a count takes when it is not one.
-func plural(n int) string {
- if n == 1 {
- return ""
- }
- return "s"
-}
diff --git a/gaming_test.go b/gaming_test.go
@@ -1,262 +0,0 @@
-package main
-
-// The anti-gaming checks hold the review to itself: a change that dismisses
-// what the reading found, or deletes the tests that would have failed, is
-// measured rather than waved through. Nothing here asks a provider, so
-// nothing here can be talked around.
-
-import (
- "strings"
- "testing"
-)
-
-func TestDiffSidesReadsBothSides(t *testing.T) {
- diff := `--- a/x_test.go
-+++ b/x_test.go
-@@ -1,4 +1,4 @@
- package x
--func TestA() {}
-+func TestB() {}
- func f() {}
-`
- added, removed := diffSides(diff)
- if len(added["x_test.go"]) != 1 || added["x_test.go"][0].Line != 2 || added["x_test.go"][0].Text != "func TestB() {}" {
- t.Errorf("added %+v", added)
- }
- if len(removed["x_test.go"]) != 1 || removed["x_test.go"][0] != "func TestA() {}" {
- t.Errorf("removed %+v", removed)
- }
-}
-
-// A removed line of content that begins like a header is content, not a
-// header: the headers are read where they sit between hunks.
-func TestDiffSidesKeepsContentAndHeadersApart(t *testing.T) {
- diff := `--- a/notes.txt
-+++ b/notes.txt
-@@ -1,2 +1,3 @@
- text
--+++ a/other.txt
-+--- b/other.txt
- more
---- a/gone.txt
-+++ /dev/null
-@@ -1,2 +0,0 @@
----- b/other.txt
--more
-`
- added, removed := diffSides(diff)
- if len(added["notes.txt"]) != 1 || added["notes.txt"][0].Line != 2 || added["notes.txt"][0].Text != "--- b/other.txt" {
- t.Errorf("added %+v", added)
- }
- if len(removed["notes.txt"]) != 1 || removed["notes.txt"][0] != "+++ a/other.txt" {
- t.Errorf("removed %+v", removed)
- }
- if len(removed["gone.txt"]) != 2 || removed["gone.txt"][0] != "--- b/other.txt" {
- t.Errorf("removed %+v", removed)
- }
-}
-
-func TestSuppressionAdded(t *testing.T) {
- // The ignore comment is assembled at runtime so this file does not add
- // a dismissal of its own — the check reads added lines, and this test's
- // source is an added line of itself.
- diff := `--- a/ico.go
-+++ b/ico.go
-@@ -1,3 +1,5 @@
- package ico
-+//` + `review:ignore cannot-fail the test can fail
-+//review:ignore <rule> <why>
- func f() {}
-`
- findings := checkSuppressionAdded(&Change{Diff: diff})
- if len(findings) != 1 {
- t.Fatalf("got %+v", findings)
- }
- f := findings[0]
- if f.Rule != "suppression-added" || f.Severity != MustFix || f.Job != "static" {
- t.Errorf("got %+v", f)
- }
- if !strings.Contains(f.Message, "ico.go:2 (cannot-fail)") {
- t.Errorf("got %q", f.Message)
- }
- // A placeholder in the prose that documents the mechanism is not a
- // suppression being added.
- if strings.Contains(f.Message, "<rule>") {
- t.Errorf("counted a documented placeholder: %q", f.Message)
- }
- // The check polices the ignoring, so it cannot be ignored in turn.
- if f.File != "" {
- t.Errorf("got file %q", f.File)
- }
- if _, ok := Suppressed(f); ok {
- t.Error("a suppression-added finding dismissed itself")
- }
-}
-
-// A dismissal can only be written where a dismissal works; the prose that
-// documents the mechanism is not one.
-func TestSuppressionSkipsProse(t *testing.T) {
- diff := `--- a/readme.md
-+++ b/readme.md
-@@ -1,3 +1,4 @@
- # review
-+` + "`//review:ignore <rule> <why>`" + `
- done
-`
- if findings := checkSuppressionAdded(&Change{Diff: diff}); len(findings) != 0 {
- t.Fatalf("got %+v", findings)
- }
- // The zero means nothing unless the check still finds a real one: the
- // same comment in a file the readers read is a suppression.
- live := `--- a/ico.go
-+++ b/ico.go
-@@ -1,2 +1,3 @@
- package ico
-+//` + `review:ignore cannot-fail the test can fail
- func f() {}
-`
- if findings := checkSuppressionAdded(&Change{Diff: live}); len(findings) != 1 {
- t.Fatalf("got %+v", findings)
- }
-}
-
-func TestDeletedTests(t *testing.T) {
- diff := `--- a/ico_test.go
-+++ b/ico_test.go
-@@ -1,5 +1,4 @@
- package ico
--func TestAssemble(t *testing.T) {}
--func TestWrite(t *testing.T) {}
-+func TestAssembleIcons(t *testing.T) {}
- func f() {}
-`
- findings := checkDeletedTests(&Change{Diff: diff})
- if len(findings) != 1 {
- t.Fatalf("got %+v", findings)
- }
- f := findings[0]
- if f.Rule != "test-deleted" || f.Severity != MustFix || f.Job != "static" {
- t.Errorf("got %+v", f)
- }
- if f.File != "ico_test.go" {
- t.Errorf("got file %q", f.File)
- }
- if !strings.Contains(f.Message, "TestWrite") {
- t.Errorf("the deleted test is not named: %q", f.Message)
- }
- if strings.Contains(f.Message, "TestAssemble") {
- t.Errorf("the rename was counted as a deletion: %q", f.Message)
- }
- if !strings.Contains(f.Fix, "test-deleted") {
- t.Errorf("the fix does not say how to answer it: %q", f.Fix)
- }
- // A deleted test is the reviewer's decision, so the finding is
- // dismissible where a reader can read it.
- if _, ok := Suppressed(f); ok {
- t.Error("a test-deleted finding dismissed nothing")
- }
-}
-
-func TestDeletedJSTests(t *testing.T) {
- diff := `--- a/web/app.test.ts
-+++ b/web/app.test.ts
-@@ -1,4 +1 @@
- import { it } from "testing";
--it.skip("parses icons", () => {});
--describe("loads", () => {});
-+export {};
-`
- findings := checkDeletedTests(&Change{Diff: diff})
- if len(findings) != 1 {
- t.Fatalf("got %+v", findings)
- }
- for _, want := range []string{"parses icons", "loads"} {
- if !strings.Contains(findings[0].Message, want) {
- t.Errorf("%q missing from %q", want, findings[0].Message)
- }
- }
-}
-
-// A test in a file the runners do not read is not a test going missing;
-// it is a function that stopped existing, which is somebody else's subject.
-func TestDeletedFunctionsOutsideTestsAreNotTests(t *testing.T) {
- diff := `--- a/ico.go
-+++ b/ico.go
-@@ -1,3 +1,2 @@
- package ico
--func TestWrite(w io.Writer) {}
--func helper() {}
-+func helper() {}
-`
- if findings := checkDeletedTests(&Change{Diff: diff}); len(findings) != 0 {
- t.Fatalf("got %+v", findings)
- }
- // The zero means nothing unless the same shape inside a test file is
- // still heard: the same deletion there is a test going missing.
- inTests := `--- a/ico_test.go
-+++ b/ico_test.go
-@@ -1,3 +1,2 @@
- package ico
--func TestWrite(t *testing.T) {}
--func helper() {}
-+func helper() {}
-`
- if findings := checkDeletedTests(&Change{Diff: inTests}); len(findings) != 1 {
- t.Fatalf("got %+v", findings)
- }
-}
-
-func TestCoveredSparesARename(t *testing.T) {
- if !covered("TestParseIcons", []string{"TestParseIconsV2", "TestWrite"}) {
- t.Error("a rename was read as a deletion")
- }
- if covered("TestParseIcons", []string{"TestParse", "TestWrite"}) {
- t.Error("a different test was read as the same one")
- }
- // A name with no words of its own cannot be matched safely, so it is
- // never spared.
- if covered("TestV2", []string{"TestV2"}) {
- t.Error("covered a name it cannot read")
- }
-}
-
-func TestFilePredicates(t *testing.T) {
- for _, path := range []string{"ico.go", "web/app.ts", "main.rs", "makefile"} {
- if !isCodeFile(path) {
- t.Errorf("isCodeFile(%q) = false", path)
- }
- }
- for _, path := range []string{"readme.md", ".gitignore", "LICENSE"} {
- if isCodeFile(path) {
- t.Errorf("isCodeFile(%q) = true", path)
- }
- }
- for _, path := range []string{"ico_test.go", "web/app.test.ts", "web/app.spec.js"} {
- if !isTestFile(path) {
- t.Errorf("isTestFile(%q) = false", path)
- }
- }
- for _, path := range []string{"ico.go", "web/app.ts"} {
- if isTestFile(path) {
- t.Errorf("isTestFile(%q) = true", path)
- }
- }
-}
-
-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/odin/git/git.odin b/git/git.odin
diff --git a/go.mod b/go.mod
@@ -1,18 +1,3 @@
module github.com/jackmordaunt/review
go 1.27.0
-
-require (
- github.com/anthropics/anthropic-sdk-go v1.74.0 // indirect
- github.com/bahlo/generic-list-go v0.2.0 // indirect
- github.com/buger/jsonparser v1.1.2 // indirect
- github.com/invopop/jsonschema v0.14.0 // indirect
- github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
- github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect
- github.com/tidwall/gjson v1.18.0 // indirect
- github.com/tidwall/match v1.1.1 // indirect
- github.com/tidwall/pretty v1.2.1 // indirect
- github.com/tidwall/sjson v1.2.5 // indirect
- go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
- golang.org/x/sync v0.16.0 // indirect
-)
diff --git a/go.sum b/go.sum
@@ -1,26 +0,0 @@
-github.com/anthropics/anthropic-sdk-go v1.74.0 h1:u6pcrBJHLJZb1Q6uziqQ5VLvAjh82sJrreWmABdOOas=
-github.com/anthropics/anthropic-sdk-go v1.74.0/go.mod h1:x+lPk/cCl48uRegeP0hlYYBN1b7bEBTveInIMgLicnY=
-github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
-github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
-github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
-github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
-github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg=
-github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
-github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
-github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
-github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI=
-github.com/standard-webhooks/standard-webhooks/libraries v0.0.1/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo=
-github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
-github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
-github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
-github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
-github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
-github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
-github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
-github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
-github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
-github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
-go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
-go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
-golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
-golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
diff --git a/gofrontend.go b/gofrontend.go
@@ -1,227 +0,0 @@
-package main
-
-import (
- "go/ast"
- "go/parser"
- "go/token"
- "path/filepath"
- "slices"
- "strings"
-)
-
-// GoFrontend reads Go through the standard library's parser, which is the
-// grammar Go ships with itself.
-type GoFrontend struct{}
-
-func (GoFrontend) Name() string { return "go" }
-
-func (GoFrontend) Covers(path string) bool { return strings.HasSuffix(path, ".go") }
-
-func (GoFrontend) Features() Features {
- return FeatSymbols | FeatTests | FeatComments | FeatIndex
-}
-
-// Change appends the declarations, tests and prose the added lines of the
-// change's Go files introduce. Only what the diff added is reported, so a job
-// sees new work rather than the file it landed in.
-func (g GoFrontend) Change(root, rev string, c *Change, added map[string][]int) error {
- for _, name := range c.Files {
- if !g.Covers(name) {
- continue
- }
- source, err := at(root, rev, name)
- if err != nil {
- continue // Deleted by the change, so there is nothing to read.
- }
- path := filepath.Join(root, name)
- fset := token.NewFileSet()
- file, err := parser.ParseFile(fset, path, source, parser.ParseComments)
- if err != nil {
- continue
- }
- 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) {
- case *ast.FuncDecl:
- line := fset.Position(decl.Pos()).Line
- end := fset.Position(decl.End()).Line
- name := decl.Name.Name
- // A test is judged by what it asserts, so a change anywhere
- // inside one is a change to the test.
- if isTest(name) {
- if !touchedBetween(touched, line, end) {
- return true
- }
- c.Tests = append(c.Tests, Function{
- Name: name, File: relative(root, path), Line: line,
- Body: text(lines, line, end),
- })
- return true
- }
- // A name is new when its declaration is. Work inside a body
- // changes what a function does, not what it is called.
- if !slices.Contains(touched, line) {
- return true
- }
- c.Symbols = append(c.Symbols, Symbol{
- 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
- if !slices.Contains(touched, line) {
- return true
- }
- 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 {
- line := fset.Position(ident.Pos()).Line
- if !slices.Contains(touched, line) {
- continue
- }
- 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,
- })
- }
- }
- return true
- })
-
- for _, group := range file.Comments {
- for _, comment := range group.List {
- line := fset.Position(comment.Pos()).Line
- if !slices.Contains(touched, line) {
- continue
- }
- c.Comments = append(c.Comments, Located{
- Text: strings.TrimSpace(strings.TrimPrefix(comment.Text, "//")),
- File: relative(root, path), Line: line,
- })
- }
- }
- }
- return nil
-}
-
-// Whole reads every declaration in the repository. It is built by parsing
-// 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) {
- 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 tracked {
- if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
- continue
- }
- source, err := tree.Read(name)
- if err != nil {
- continue
- }
- fset := token.NewFileSet()
- file, err := parser.ParseFile(fset, name, source, 0)
- if err != nil {
- continue
- }
- lines := strings.Split(string(source), "\n")
- at := func(pos token.Pos) (int, string) {
- line := fset.Position(pos).Line
- if line-1 < 0 || line-1 >= len(lines) {
- return line, ""
- }
- return line, lines[line-1]
- }
- for _, decl := range file.Decls {
- switch d := decl.(type) {
- case *ast.FuncDecl:
- line, text := at(d.Pos())
- 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) {
- case *ast.TypeSpec:
- line, text := at(s.Pos())
- index = append(index, Declared{Name: s.Name.Name, Kind: "type", File: name, Line: line, Text: text})
- // A struct's fields are declarations too, and a field
- // is where a restated fact often sits.
- if structure, ok := s.Type.(*ast.StructType); ok {
- for _, field := range structure.Fields.List {
- for _, ident := range field.Names {
- line, text := at(ident.Pos())
- index = append(index, Declared{Name: ident.Name, Kind: "field", File: name, Line: line, Text: text})
- }
- }
- }
- case *ast.ValueSpec:
- for _, ident := range s.Names {
- line, text := at(ident.Pos())
- index = append(index, Declared{Name: ident.Name, Kind: kindOf(d.Tok), File: name, Line: line, Text: text})
- }
- }
- }
- }
- }
- }
- 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")
-}
-
-func kindOf(tok token.Token) string {
- switch tok {
- case token.CONST:
- return "const"
- case token.VAR:
- return "var"
- }
- return "value"
-}
-
-func doc(group *ast.CommentGroup) string {
- if group == nil {
- return ""
- }
- return strings.TrimSpace(group.Text())
-}
diff --git a/helper_test.go b/helper_test.go
@@ -1,73 +0,0 @@
-package main
-
-import (
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "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.
-type repo struct {
- t *testing.T
- Root string
-}
-
-func newRepo(t *testing.T) *repo {
- t.Helper()
- r := &repo{t: t, Root: t.TempDir()}
- r.run("init", "-q", "-b", "main")
- r.run("config", "user.email", "test@example.com")
- r.run("config", "user.name", "Test")
- r.run("config", "commit.gpgsign", "false")
- return r
-}
-
-func (r *repo) run(args ...string) string {
- r.t.Helper()
- cmd := exec.Command("git", args...)
- cmd.Dir = r.Root
- cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
- out, err := cmd.CombinedOutput()
- if err != nil {
- r.t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
- }
- return string(out)
-}
-
-// write puts a file in the working tree, creating the directories it needs.
-func (r *repo) write(path, content string) {
- r.t.Helper()
- full := filepath.Join(r.Root, path)
- if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
- r.t.Fatal(err)
- }
- if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
- r.t.Fatal(err)
- }
-}
-
-// commit stages the named paths and records them.
-func (r *repo) commit(message string, paths ...string) string {
- r.t.Helper()
- r.run(append([]string{"add", "--"}, paths...)...)
- r.run("commit", "-q", "-m", message)
- return strings.TrimSpace(r.run("rev-parse", "HEAD"))
-}
-
-// stage adds paths to the index without committing, which is what a bare
-// review reads.
-func (r *repo) stage(paths ...string) {
- r.t.Helper()
- r.run(append([]string{"add", "--"}, paths...)...)
-}
diff --git a/heuristic.go b/heuristic.go
@@ -1,117 +0,0 @@
-package main
-
-import (
- "strings"
-)
-
-// Heuristic reads the languages no parser frontend covers: the comment lines
-// a change adds, by the prefixes those languages share. It is deliberately
-// line-shaped, so it is right about prose and silent about declarations —
-// a pattern over lines missing an indented constant inside a block is the
-// mistake that built the real frontends.
-type Heuristic struct{}
-
-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. 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", ".jsx", ".mjs", ".cjs", ".lua", ".zig", ".swift", ".kt", ".java",
- ".php", ".scala", ".cs",
- } {
- if strings.HasSuffix(path, ext) {
- return true
- }
- }
- return false
-}
-
-// Features is comments only. The jobs that need declarations or test bodies
-// are skipped with a logged reason rather than served a guess.
-func (Heuristic) Features() Features { return FeatComments }
-
-// Change appends the comment lines the added lines introduce, keeping block
-// comments whole across their lines.
-func (g Heuristic) Change(root, rev string, c *Change, added map[string][]int) error {
- for _, name := range c.Files {
- if !g.Covers(name) {
- continue
- }
- source, err := at(root, rev, name)
- if err != nil {
- continue // Deleted by the change, so there is nothing to read.
- }
- c.Comments = append(c.Comments, commentProse(source, name, added[name])...)
- }
- return nil
-}
-
-// commentProse lifts the comment lines among the added ones out of a source
-// file by the prefixes the C-family shares: // and # for line comments, an
-// open /* kept whole until its close. The shebang is not a comment.
-func commentProse(source []byte, name string, touched []int) []Located {
- lines := strings.Split(string(source), "\n")
- if len(touched) == 0 {
- return nil
- }
- isTouched := map[int]bool{}
- for _, line := range touched {
- isTouched[line] = true
- }
- var out []Located
- inBlock := false
- for i, line := range lines {
- number := i + 1
- trimmed := strings.TrimSpace(line)
- switch {
- case inBlock:
- if isTouched[number] {
- t := strings.TrimSuffix(trimmed, "*/")
- t = strings.TrimSpace(strings.TrimPrefix(t, "*"))
- if t != "" {
- out = append(out, Located{Text: t, File: name, Line: number})
- }
- }
- if strings.Contains(trimmed, "*/") {
- inBlock = false
- }
- case strings.HasPrefix(trimmed, "/*"):
- t := trimmed[2:]
- if end := strings.Index(t, "*/"); end >= 0 {
- t = t[:end]
- if isTouched[number] && strings.TrimSpace(t) != "" {
- out = append(out, Located{Text: strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(t), "*")), File: name, Line: number})
- }
- } else {
- if isTouched[number] && strings.TrimSpace(t) != "" {
- out = append(out, Located{Text: strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(t), "*")), File: name, Line: number})
- }
- inBlock = true
- }
- case strings.HasPrefix(trimmed, "//"):
- if !isTouched[number] {
- continue
- }
- out = append(out, Located{
- Text: strings.TrimSpace(strings.TrimPrefix(trimmed, "//")),
- File: name, Line: number,
- })
- case strings.HasPrefix(trimmed, "#") && !strings.HasPrefix(trimmed, "#!"):
- if !isTouched[number] {
- continue
- }
- out = append(out, Located{
- Text: strings.TrimSpace(strings.TrimPrefix(trimmed, "#")),
- File: name, Line: number,
- })
- }
- }
- return out
-}
-
-// Whole is never asked: the heuristic declares no index.
-func (Heuristic) Whole(root, rev string) ([]Declared, error) { return nil, nil }
diff --git a/hook.go b/hook.go
@@ -1,160 +0,0 @@
-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/odin/hook/hook.odin b/hook/hook.odin
diff --git a/odin/hook/hook_test.odin b/hook/hook_test.odin
diff --git a/hook_test.go b/hook_test.go
@@ -1,94 +0,0 @@
-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,142 +0,0 @@
-package main
-
-import (
- "cmp"
- "fmt"
- "slices"
- "strings"
-)
-
-// Declared is one declaration somewhere in the repository, which a new name
-// might turn out to duplicate.
-type Declared struct {
- Name string
- Kind string
- File string
- Line int
- // 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. 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 {
- 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 i, declared := range index {
- if declared.File == symbol.File && declared.Line == symbol.Line {
- continue
- }
- 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 lines
-}
-
-// same reports whether two declarations hold the same literal, which is the
-// cheapest signal that a fact has been written twice. It is a hint for the
-// shortlist, not a finding.
-func same(a, b Declared) bool {
- _, left, okA := cutLiteral(a.Text)
- _, right, okB := cutLiteral(b.Text)
- return okA && okB && strings.TrimSpace(left) == strings.TrimSpace(right)
-}
-
-// cutLiteral takes the value half of a declaration line. Go and TypeScript
-// write `name = value`; Odin writes `name :: value`.
-func cutLiteral(text string) (string, string, bool) {
- if _, right, ok := strings.Cut(text, "="); ok {
- return text, right, true
- }
- if _, right, ok := strings.Cut(text, "::"); ok {
- return text, right, true
- }
- return text, "", false
-}
-
-// split breaks a name into the words it is made of, so that Assemble finds
-// assemble and icoEntrySize finds both Entry and Size.
-func split(name string) []string {
- var (
- words []string
- word strings.Builder
- )
- for i, r := range name {
- // A capital starts a word; an underscore ends one, as snake_case
- // languages put the next word after it.
- if i > 0 && (r >= 'A' && r <= 'Z' || r == '_') {
- if word.Len() > 0 {
- words = append(words, word.String())
- word.Reset()
- }
- if r != '_' {
- word.WriteRune(r)
- }
- continue
- }
- word.WriteRune(r)
- }
- if word.Len() > 0 {
- words = append(words, word.String())
- }
- return words
-}
diff --git a/index_test.go b/index_test.go
@@ -1,178 +0,0 @@
-package main
-
-import (
- "reflect"
- "strings"
- "testing"
-)
-
-func TestSplit(t *testing.T) {
- for _, test := range []struct {
- name string
- want []string
- }{
- {"Assemble", []string{"Assemble"}},
- {"icoEntrySize", []string{"ico", "Entry", "Size"}},
- {"groupHeaderSize", []string{"group", "Header", "Size"}},
- {"Stored", []string{"Stored"}},
- {"", nil},
- {"x", []string{"x"}},
- // An acronym splits per letter. Words shorter than four characters
- // are dropped by the caller, so this costs nothing.
- {"ID", []string{"I", "D"}},
- } {
- if got := split(test.name); !reflect.DeepEqual(got, test.want) {
- t.Errorf("split(%q) = %v, want %v", test.name, got, test.want)
- }
- }
-}
-
-func TestSame(t *testing.T) {
- for _, test := range []struct {
- left, right string
- want bool
- }{
- {"\tgroupHeaderSize = 6", "\tdirectorySize = 6", true},
- {"const icoEntrySize = 16", "\tentrySize = 16", true},
- {"const a = 6", "const b = 7", false},
- {"const a = 6", "type T struct{}", false},
- {"type T struct{}", "type U struct{}", false},
- {"x := 6", "const y = 6", true},
- {"\tmagic = \"PE\\x00\\x00\"", "\theader = \"PE\\x00\\x00\"", true},
- } {
- got := same(Declared{Text: test.left}, Declared{Text: test.right})
- if got != test.want {
- t.Errorf("same(%q, %q) = %v, want %v", test.left, test.right, got, test.want)
- }
- }
-}
-
-func TestIndex(t *testing.T) {
- r := newRepo(t)
- r.write("ico/writer.go", `package ico
-
-const (
- directorySize = 6
- entrySize = 16
-)
-
-type Entry struct {
- Width int
- Offset int
-}
-
-func Write() {}
-
-var ErrNoIcons = errorString("none")
-`)
- // Tests are not part of the index: a duplicate in a test is not a fact
- // with two owners.
- r.write("ico/writer_test.go", "package ico\n\nconst directorySize = 99\n")
- rev := r.commit("first", "ico/writer.go", "ico/writer_test.go")
-
- index, err := GoFrontend{}.Whole(r.Root, rev+".."+rev)
- if err != nil {
- t.Fatal(err)
- }
- kinds := map[string]string{}
- for _, d := range index {
- if kinds[d.Name] != "" {
- t.Errorf("%s indexed twice", d.Name)
- }
- kinds[d.Name] = d.Kind
- }
- want := map[string]string{
- "directorySize": "const",
- "entrySize": "const",
- "Entry": "type",
- "Width": "field",
- "Offset": "field",
- "Write": "func",
- "ErrNoIcons": "var",
- }
- if !reflect.DeepEqual(kinds, want) {
- t.Errorf("got %v, want %v", kinds, want)
- }
-
- // An indented constant inside a block is exactly where a duplicated fact
- // lives, so its declaring line has to survive into the index.
- for _, d := range index {
- if d.Name == "entrySize" {
- if !strings.Contains(d.Text, "16") {
- t.Errorf("entrySize carries no value: %q", d.Text)
- }
- if d.File != "ico/writer.go" || d.Line != 5 {
- t.Errorf("entrySize located at %s:%d, want ico/writer.go:5", d.File, d.Line)
- }
- }
- }
-}
-
-func TestIndexSkipsUnparseableFiles(t *testing.T) {
- r := newRepo(t)
- r.write("good.go", "package x\n\nconst a = 1\n")
- r.write("bad.go", "package x\n\nfunc (\n")
- rev := r.commit("first", "good.go", "bad.go")
-
- index, err := GoFrontend{}.Whole(r.Root, rev+".."+rev)
- if err != nil {
- t.Fatal(err)
- }
- if len(index) != 1 || index[0].Name != "a" {
- t.Errorf("got %v, want only a", index)
- }
-}
-
-func TestResembling(t *testing.T) {
- index := []Declared{
- {Name: "entrySize", File: "ico/writer.go", Line: 5, Text: "\tentrySize = 16"},
- {Name: "directorySize", File: "ico/writer.go", Line: 4, Text: "\tdirectorySize = 6"},
- {Name: "Entry", File: "ico/ico.go", Line: 10, Text: "type Entry struct {"},
- {Name: "colour", File: "png/png.go", Line: 3, Text: "var colour int"},
- }
- symbol := Symbol{Name: "icoEntrySize", File: "exe/exe.go", Line: 12}
-
- got := Resembling(index, symbol, 16)
- joined := strings.Join(got, "\n")
- for _, want := range []string{"entrySize", "Entry", "directorySize"} {
- if !strings.Contains(joined, want) {
- t.Errorf("%s missing from the shortlist:\n%s", want, joined)
- }
- }
- if strings.Contains(joined, "colour") {
- t.Errorf("an unrelated name reached the shortlist:\n%s", joined)
- }
- // "ico" is three letters, so it contributes nothing and cannot drag in
- // every declaration in the ico package.
- if len(got) != 3 {
- t.Errorf("got %d candidates, want 3:\n%s", len(got), joined)
- }
-}
-
-func TestResemblingLeavesOutItself(t *testing.T) {
- index := []Declared{
- {Name: "entrySize", File: "exe/exe.go", Line: 12, Text: "\tentrySize = 16"},
- {Name: "entrySize", File: "ico/ico.go", Line: 4, Text: "\tentrySize = 16"},
- }
- got := Resembling(index, Symbol{Name: "entrySize", File: "exe/exe.go", Line: 12}, 16)
- if len(got) != 1 || !strings.Contains(got[0], "ico/ico.go") {
- t.Errorf("got %v, want only the declaration elsewhere", got)
- }
-}
-
-func TestResemblingObeysItsLimit(t *testing.T) {
- var index []Declared
- for i := range 40 {
- index = append(index, Declared{Name: "entrySize", File: "x.go", Line: i + 1, Text: "entrySize = 1"})
- }
- if got := Resembling(index, Symbol{Name: "entrySize", File: "y.go"}, 16); len(got) != 16 {
- t.Errorf("got %d candidates, want the limit of 16", len(got))
- }
-}
-
-func TestDeclaredString(t *testing.T) {
- d := Declared{File: "ico/writer.go", Line: 4, Text: "\tdirectorySize = 6\t"}
- if got := d.String(); got != "ico/writer.go:4: directorySize = 6" {
- t.Errorf("got %q", got)
- }
-}
diff --git a/job.go b/job.go
@@ -1,417 +0,0 @@
-package main
-
-import (
- "embed"
- "encoding/json"
- "fmt"
- "regexp"
- "slices"
- "strings"
-)
-
-//go:embed criteria/*.md
-var criteria embed.FS
-
-// Job is one narrow reading of a change. Each is given the part of the change
-// it needs and nothing else: a job that reads less is cheaper, and harder to
-// distract into reporting something another job owns.
-type Job struct {
- // Name is how it is asked for and how its findings are labelled.
- Name string
- // Criteria is the rules it judges against, and the thing to tune when it
- // reports the wrong things.
- Criteria string
- // Subject renders the part of the change this job reads. An empty string
- // 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, 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},
- }
-}
-
-func read(name string) string {
- data, err := criteria.ReadFile("criteria/" + name + ".md")
- if err != nil {
- panic(err) // The criteria are embedded; a missing one is a build fault.
- }
- return string(data)
-}
-
-func namerSubject(c *Change) string {
- if len(c.Symbols) == 0 {
- return ""
- }
- var b strings.Builder
- b.WriteString("Names this change adds or renames:\n\n")
- for _, s := range c.Symbols {
- fmt.Fprintf(&b, "%s:%d %s %s", s.File, s.Line, s.Kind, s.Name)
- if s.Exported {
- b.WriteString(" (exported)")
- }
- b.WriteString("\n")
- if s.Signature != "" {
- fmt.Fprintf(&b, " %s\n", s.Signature)
- }
- if s.Doc != "" {
- fmt.Fprintf(&b, " doc: %s\n", firstLine(s.Doc))
- }
- if near := c.Candidates[s.Name]; len(near) > 0 {
- fmt.Fprintf(&b, " names already in this repository: %s\n", strings.Join(short(near, 6), "; "))
- }
- b.WriteString("\n")
- }
- return b.String()
-}
-
-func duplicationSubject(c *Change) string {
- if len(c.Symbols) == 0 {
- return ""
- }
- var b strings.Builder
- // The pairs holding the same literal go first and alone. Buried among
- // the resemblances a reading finds one of them and stops.
- if twinned := twins(c); twinned != "" {
- b.WriteString("Declarations this change adds that hold a value already declared elsewhere.\n")
- b.WriteString("Judge every pair on this list.\n\n")
- b.WriteString(twinned)
- b.WriteString("\n")
- }
- b.WriteString("Each name the change adds, with existing declarations found by searching for its words.\n\n")
- for _, s := range c.Symbols {
- fmt.Fprintf(&b, "NEW %s:%d %s %s\n", s.File, s.Line, s.Kind, s.Name)
- if s.Signature != "" {
- fmt.Fprintf(&b, " %s\n", s.Signature)
- }
- if s.Doc != "" {
- fmt.Fprintf(&b, " doc: %s\n", firstLine(s.Doc))
- }
- candidates := c.Candidates[s.Name]
- if len(candidates) == 0 {
- b.WriteString(" candidates: none found\n\n")
- continue
- }
- b.WriteString(" candidates:\n")
- for _, line := range candidates {
- fmt.Fprintf(&b, " %s\n", line)
- }
- b.WriteString("\n")
- }
- return b.String()
-}
-
-// twins renders the declarations whose value already exists, in the order the
-// change declares them.
-func twins(c *Change) string {
- var b strings.Builder
- for _, s := range c.Symbols {
- lines := c.Twins[s.Name]
- if len(lines) == 0 {
- continue
- }
- fmt.Fprintf(&b, " %s:%d %s\n", s.File, s.Line, s.Signature)
- for _, line := range lines {
- fmt.Fprintf(&b, " %s\n", strings.TrimSuffix(line, " <- same value"))
- }
- }
- return b.String()
-}
-
-func testsSubject(c *Change) string {
- if len(c.Tests) == 0 {
- return ""
- }
- 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", 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 {
- blocks := commentBlocks(c.Comments)
- if len(blocks) == 0 {
- return ""
- }
- var b strings.Builder
- 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 ""
- }
- var b strings.Builder
- b.WriteString("Commit message:\n\n")
- b.WriteString(c.Message)
- b.WriteString("\n\nFiles changed:\n")
- b.WriteString(c.Stat)
- if len(c.Convention) > 0 {
- b.WriteString("\nRecent subjects in this repository, as the local convention:\n")
- for _, subject := range c.Convention {
- fmt.Fprintf(&b, " %s\n", subject)
- }
- }
- return b.String()
-}
-
-func firstLine(s string) string {
- if i := strings.IndexByte(s, '\n'); i >= 0 {
- s = s[:i]
- }
- if len(s) > 140 {
- s = s[:140] + "…"
- }
- return s
-}
-
-func short(lines []string, n int) []string {
- if len(lines) > n {
- lines = lines[:n]
- }
- return lines
-}
-
-// reported is the shape a job answers in. It is enforced by the schema rather
-// than asked for in prose, so a malformed answer is impossible rather than
-// merely unlikely.
-type reported struct {
- Findings []struct {
- Rule string `json:"rule"`
- Severity string `json:"severity"`
- File string `json:"file"`
- Line int `json:"line"`
- Symbol string `json:"symbol"`
- Message string `json:"message"`
- Fix string `json:"fix"`
- } `json:"findings"`
-}
-
-func (r reported) findings(job string, rules map[string]bool) []Finding {
- var out []Finding
- for _, f := range r.Findings {
- // A finding that cites no rule from the criteria is dropped: the
- // criteria are what gets tuned, so a job may not invent one.
- if !rules[f.Rule] {
- continue
- }
- out = append(out, Finding{
- Job: job, Rule: f.Rule,
- Severity: ParseSeverity(f.Severity), SeverityName: f.Severity,
- File: f.File, Line: f.Line, Symbol: f.Symbol,
- Message: f.Message, Fix: f.Fix,
- })
- }
- return out
-}
-
-func decode(raw string) (reported, error) {
- var r reported
- if err := json.Unmarshal([]byte(raw), &r); err != nil {
- return reported{}, fmt.Errorf("reading the answer: %w", err)
- }
- 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/job.odin b/job/job.odin
@@ -0,0 +1,530 @@
+/*
+Package job is the narrow readings a model is asked for. Each is given the
+part of the change it needs and nothing else: a job that reads less is
+cheaper, and harder to distract into reporting something another job
+owns. The criteria a job judges against are the thing to tune when it
+reports the wrong things, and they travel with the binary.
+*/
+package job
+
+import "base:runtime"
+import "core:encoding/json"
+import "core:fmt"
+import "core:slice"
+import "core:strings"
+import "core:text/regex"
+
+import "../change"
+import "../check"
+import "../finding"
+import "../txt"
+
+// Job is one reading. subject renders the part of the change it reads;
+// an empty subject means there is nothing here for it and the job is
+// skipped. splittable is whether the subject can be read file by file: a
+// subject over the packet cap is then asked in parts.
+Job :: struct {
+ name: string,
+ criteria: string,
+ subject: proc(c: ^change.Change, allocator: runtime.Allocator) -> string,
+ splittable: bool,
+}
+
+// all is the readings, in the order their findings are worth having.
+all :: proc(allocator := context.temp_allocator) -> []Job {
+ jobs := make([]Job, 5, allocator)
+ jobs[0] = Job {
+ "duplication",
+ #load("../criteria/duplication.md", string),
+ duplication_subject,
+ true,
+ }
+ jobs[1] = Job{"tests", #load("../criteria/tests.md", string), tests_subject, true}
+ jobs[2] = Job{"namer", #load("../criteria/namer.md", string), namer_subject, true}
+ jobs[3] = Job{"claims", #load("../criteria/claims.md", string), claims_subject, true}
+ jobs[4] = Job{"hygiene", #load("../criteria/hygiene.md", string), hygiene_subject, false}
+ return jobs
+}
+
+// chosen is the jobs named, comma separated, or all of them.
+chosen :: proc(only: string, allocator := context.temp_allocator) -> (jobs: []Job, err: string) {
+ if strings.trim_space(only) == "" {
+ return all(allocator), ""
+ }
+ picked := make([dynamic]Job, allocator)
+ for name in strings.split(only, ",", context.temp_allocator) {
+ want := strings.trim_space(name)
+ found := false
+ for j in all(allocator) {
+ if j.name == want {
+ append(&picked, j)
+ found = true
+ }
+ }
+ if !found {
+ return nil, fmt.aprintf(
+ "no job called %q; the jobs are claims, duplication, hygiene, namer, tests",
+ want,
+ allocator = allocator,
+ )
+ }
+ }
+ return picked[:], ""
+}
+
+// rules reads the ids a job may cite out of its own criteria: the bullets
+// opening with a backticked id.
+rules :: proc(criteria: string, allocator := context.temp_allocator) -> map[string]bool {
+ out := make(map[string]bool, allocator)
+ rest := criteria
+ for line in strings.split_lines_iterator(&rest) {
+ if !strings.has_prefix(line, "- `") {
+ continue
+ }
+ end := strings.index_byte(line[3:], '`')
+ if end < 0 {
+ continue
+ }
+ id := line[3:3 + end]
+ valid := len(id) > 0
+ for i in 0 ..< len(id) {
+ c := id[i]
+ if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
+ valid = false
+ }
+ }
+ if valid {
+ out[id] = true
+ }
+ }
+ return out
+}
+
+namer_subject :: proc(c: ^change.Change, allocator: runtime.Allocator) -> string {
+ if len(c.symbols) == 0 {
+ return ""
+ }
+ b := strings.builder_make(allocator)
+ strings.write_string(&b, "Names this change adds or renames:\n\n")
+ for s in c.symbols {
+ fmt.sbprintf(&b, "%s:%d %s %s", s.file, s.line, s.kind, s.name)
+ if s.exported {
+ strings.write_string(&b, " (exported)")
+ }
+ strings.write_string(&b, "\n")
+ if s.signature != "" {
+ fmt.sbprintf(&b, " %s\n", s.signature)
+ }
+ if s.doc != "" {
+ fmt.sbprintf(&b, " doc: %s\n", check.first_line(s.doc, context.temp_allocator))
+ }
+ if near := c.candidates[s.name]; len(near) > 0 {
+ fmt.sbprintf(
+ &b,
+ " names already in this repository: %s\n",
+ strings.join(near[:min(len(near), 6)], "; ", context.temp_allocator),
+ )
+ }
+ strings.write_string(&b, "\n")
+ }
+ return strings.to_string(b)
+}
+
+duplication_subject :: proc(c: ^change.Change, allocator: runtime.Allocator) -> string {
+ if len(c.symbols) == 0 {
+ return ""
+ }
+ b := strings.builder_make(allocator)
+ // The pairs holding the same literal go first and alone. Buried among
+ // the resemblances a reading finds one of them and stops.
+ if twinned := twins(c, context.temp_allocator); twinned != "" {
+ strings.write_string(
+ &b,
+ "Declarations this change adds that hold a value already declared elsewhere.\n",
+ )
+ strings.write_string(&b, "Judge every pair on this list.\n\n")
+ strings.write_string(&b, twinned)
+ strings.write_string(&b, "\n")
+ }
+ strings.write_string(
+ &b,
+ "Each name the change adds, with existing declarations found by searching for its words.\n\n",
+ )
+ for s in c.symbols {
+ fmt.sbprintf(&b, "NEW %s:%d %s %s\n", s.file, s.line, s.kind, s.name)
+ if s.signature != "" {
+ fmt.sbprintf(&b, " %s\n", s.signature)
+ }
+ if s.doc != "" {
+ fmt.sbprintf(&b, " doc: %s\n", check.first_line(s.doc, context.temp_allocator))
+ }
+ candidates := c.candidates[s.name]
+ if len(candidates) == 0 {
+ strings.write_string(&b, " candidates: none found\n\n")
+ continue
+ }
+ strings.write_string(&b, " candidates:\n")
+ for line in candidates {
+ fmt.sbprintf(&b, " %s\n", line)
+ }
+ strings.write_string(&b, "\n")
+ }
+ return strings.to_string(b)
+}
+
+// twins renders the declarations whose value already exists, in the
+// order the change declares them.
+twins :: proc(c: ^change.Change, allocator := context.allocator) -> string {
+ b := strings.builder_make(allocator)
+ for s in c.symbols {
+ lines := c.twins[s.name]
+ if len(lines) == 0 {
+ continue
+ }
+ fmt.sbprintf(&b, " %s:%d %s\n", s.file, s.line, s.signature)
+ for line in lines {
+ fmt.sbprintf(&b, " %s\n", strings.trim_suffix(line, " <- same value"))
+ }
+ }
+ return strings.to_string(b)
+}
+
+tests_subject :: proc(c: ^change.Change, allocator: runtime.Allocator) -> string {
+ if len(c.tests) == 0 {
+ return ""
+ }
+ b := strings.builder_make(allocator)
+ strings.write_string(&b, "Test functions this change adds or alters:\n\n")
+ for t in c.tests {
+ fmt.sbprintf(&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.sbprintf(&b, " (skips itself at line %d)", t.skips)
+ }
+ fmt.sbprintf(&b, "\n%s\n\n", t.body)
+ }
+ if called := functions_under_test(c, context.temp_allocator); called != "" {
+ strings.write_string(
+ &b,
+ "Functions the tests call, as they stand at the end of the change. A test that\n",
+ )
+ strings.write_string(
+ &b,
+ "would pass with one of these returning its input or a zero value is the finding.\n\n",
+ )
+ strings.write_string(&b, called)
+ }
+ return strings.to_string(b)
+}
+
+// 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.
+called_functions :: 8
+called_lines :: 60
+
+// functions_under_test 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.
+// A helper a test file declares is not the code under test.
+functions_under_test :: proc(c: ^change.Change, allocator := context.allocator) -> string {
+ if len(c.index) == 0 {
+ return ""
+ }
+ declared := make(map[string]change.Declared, context.temp_allocator)
+ for d in c.index {
+ if d.kind == "func" && d.body != "" && !check.is_test_file(d.file) {
+ if d.name not_in declared {
+ declared[d.name] = d
+ }
+ }
+ }
+ seen := make(map[string]bool, context.temp_allocator)
+ shown := make([dynamic]change.Declared, context.temp_allocator)
+ outer: for t in c.tests {
+ for name in calls(t.body, context.temp_allocator) {
+ d, ok := declared[name]
+ if !ok || seen[name] || name == t.name {
+ continue
+ }
+ seen[name] = true
+ append(&shown, d)
+ if len(shown) == called_functions {
+ break outer
+ }
+ }
+ }
+ if len(shown) == 0 {
+ return ""
+ }
+ b := strings.builder_make(allocator)
+ for d in shown {
+ body := d.body
+ lines := strings.split_lines(body, context.temp_allocator)
+ if len(lines) > called_lines {
+ body = fmt.tprintf(
+ "%s\n\t… cut at %d lines",
+ strings.join(lines[:called_lines], "\n", context.temp_allocator),
+ called_lines,
+ )
+ }
+ fmt.sbprintf(&b, "--- %s:%d %s\n%s\n\n", d.file, d.line, d.name, body)
+ }
+ return strings.to_string(b)
+}
+
+// calls are the names a body calls, in order, which is how a test names
+// what it tests.
+calls :: proc(body: string, allocator := context.allocator) -> []string {
+ out := make([dynamic]string, allocator)
+ it, err := regex.create_iterator(
+ body,
+ `\b([A-Za-z_][A-Za-z0-9_]*)\(`,
+ {},
+ context.temp_allocator,
+ )
+ if err != nil {
+ return out[:]
+ }
+ defer regex.destroy_iterator(it, context.temp_allocator)
+ for {
+ cap, _, ok := regex.match_iterator(&it)
+ if !ok {
+ break
+ }
+ append(&out, strings.trim_suffix(cap.groups[0], "("))
+ }
+ return out[:]
+}
+
+claims_subject :: proc(c: ^change.Change, allocator: runtime.Allocator) -> string {
+ blocks := comment_blocks(c.comments[:], context.temp_allocator)
+ if len(blocks) == 0 {
+ return ""
+ }
+ b := strings.builder_make(allocator)
+ strings.write_string(
+ &b,
+ "Comment and documentation lines this change adds, each with the code beneath it:\n\n",
+ )
+ for block in blocks {
+ for comment in block {
+ fmt.sbprintf(&b, "%s:%d %s\n", comment.file, comment.line, comment.text)
+ }
+ if below := block[len(block) - 1].below; below != "" {
+ rest := below
+ for line in strings.split_lines_iterator(&rest) {
+ fmt.sbprintf(&b, " code: %s\n", line)
+ }
+ }
+ strings.write_string(&b, "\n")
+ }
+ return strings.to_string(b)
+}
+
+// comment_blocks 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.
+comment_blocks :: proc(
+ comments: []change.Located,
+ allocator := context.allocator,
+) -> [][]change.Located {
+ blocks := make([dynamic][]change.Located, allocator)
+ current := make([dynamic]change.Located, allocator)
+ for comment in comments {
+ if check.restates(comment) {
+ continue
+ }
+ if len(current) > 0 {
+ last := current[len(current) - 1]
+ if !(last.file == comment.file && last.line + 1 == comment.line) {
+ append(&blocks, current[:])
+ current = make([dynamic]change.Located, allocator)
+ }
+ }
+ append(¤t, comment)
+ }
+ if len(current) > 0 {
+ append(&blocks, current[:])
+ }
+ return blocks[:]
+}
+
+hygiene_subject :: proc(c: ^change.Change, allocator: runtime.Allocator) -> string {
+ if strings.trim_space(c.message) == "" {
+ return ""
+ }
+ b := strings.builder_make(allocator)
+ strings.write_string(&b, "Commit message:\n\n")
+ strings.write_string(&b, c.message)
+ strings.write_string(&b, "\n\nFiles changed:\n")
+ strings.write_string(&b, c.stat)
+ if len(c.convention) > 0 {
+ strings.write_string(
+ &b,
+ "\nRecent subjects in this repository, as the local convention:\n",
+ )
+ for subject in c.convention {
+ fmt.sbprintf(&b, " %s\n", subject)
+ }
+ }
+ return strings.to_string(b)
+}
+
+// Reported is the shape a job answers in.
+Reported :: struct {
+ findings: []struct {
+ rule: string `json:"rule"`,
+ severity: string `json:"severity"`,
+ file: string `json:"file"`,
+ line: int `json:"line"`,
+ symbol: string `json:"symbol"`,
+ message: string `json:"message"`,
+ fix: string `json:"fix"`,
+ } `json:"findings"`,
+}
+
+// decode reads a job's answer. A finding that cites no rule from the
+// criteria is dropped: the criteria are what gets tuned, so a job may not
+// invent one.
+decode :: proc(
+ raw: string,
+ name: string,
+ allowed: map[string]bool,
+ allocator := context.allocator,
+) -> (
+ out: []finding.Finding,
+ ok: bool,
+) {
+ r: Reported
+ if json.unmarshal_string(raw, &r, allocator = context.temp_allocator) != nil {
+ return nil, false
+ }
+ kept := make([dynamic]finding.Finding, allocator)
+ for f in r.findings {
+ if !allowed[f.rule] {
+ continue
+ }
+ append(
+ &kept,
+ finding.Finding {
+ job = strings.clone(name, allocator),
+ rule = strings.clone(f.rule, allocator),
+ severity = finding.parse_severity(f.severity),
+ severity_name = strings.clone(f.severity, allocator),
+ file = strings.clone(f.file, allocator),
+ line = f.line,
+ symbol = strings.clone(f.symbol, allocator),
+ message = strings.clone(f.message, allocator),
+ fix = strings.clone(f.fix, allocator),
+ },
+ )
+ }
+ return kept[:], true
+}
+
+// readable is whether an answer holds a findings object at all.
+readable :: proc(text: string) -> bool {
+ raw, found := txt.object(text)
+ if !found {
+ return false
+ }
+ r: Reported
+ return json.unmarshal_string(raw, &r, allocator = context.temp_allocator) == nil
+}
+
+// packet_cap is the size of subject past which a splittable job is asked
+// in parts. Under the cap each part is an ask a slow gateway finishes,
+// and a part whose files did not change replays from the cache.
+packet_cap :: 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.
+parts :: proc(j: Job, c: ^change.Change, allocator := context.allocator) -> []^change.Change {
+ out := make([dynamic]^change.Change, allocator)
+ if !j.splittable || len(j.subject(c, context.temp_allocator)) <= packet_cap {
+ append(&out, c)
+ return out[:]
+ }
+ group := make([dynamic]string, context.temp_allocator)
+ for file in c.files {
+ if !contributes(c, file) {
+ continue
+ }
+ if len(group) > 0 {
+ trial := slice.clone(group[:], context.temp_allocator)
+ with := make([dynamic]string, context.temp_allocator)
+ append(&with, ..trial)
+ append(&with, file)
+ piece := part(c, with[:], context.temp_allocator)
+ if len(j.subject(piece, context.temp_allocator)) > packet_cap {
+ append(&out, part(c, group[:], allocator))
+ clear(&group)
+ }
+ }
+ append(&group, file)
+ }
+ if len(group) > 0 {
+ append(&out, part(c, group[:], allocator))
+ }
+ return out[:]
+}
+
+// contributes is whether a file has anything a splittable job reads.
+contributes :: proc(c: ^change.Change, file: string) -> bool {
+ for s in c.symbols {
+ if s.file == file {
+ return true
+ }
+ }
+ for t in c.tests {
+ if t.file == file {
+ return true
+ }
+ }
+ for comment in 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.
+part :: proc(
+ c: ^change.Change,
+ files: []string,
+ allocator := context.allocator,
+) -> ^change.Change {
+ keep := make(map[string]bool, context.temp_allocator)
+ for f in files {
+ keep[f] = true
+ }
+ p := new(change.Change, allocator)
+ p^ = c^
+ p.files = slice.clone(files, allocator)
+ p.symbols = make([dynamic]change.Symbol, allocator)
+ p.tests = make([dynamic]change.Function, allocator)
+ p.comments = make([dynamic]change.Located, allocator)
+ for s in c.symbols {
+ if keep[s.file] {
+ append(&p.symbols, s)
+ }
+ }
+ for t in c.tests {
+ if keep[t.file] {
+ append(&p.tests, t)
+ }
+ }
+ for comment in c.comments {
+ if keep[comment.file] {
+ append(&p.comments, comment)
+ }
+ }
+ return p
+}
diff --git a/odin/job/job_test.odin b/job/job_test.odin
diff --git a/job_test.go b/job_test.go
@@ -1,439 +0,0 @@
-package main
-
-import (
- "context"
- "fmt"
- "strings"
- "testing"
-)
-
-func TestRules(t *testing.T) {
- criteria := "# Naming\n\n" +
- "- `noun-for-type` — a type is a noun.\n" +
- "- `verb-for-func` — a function is a verb.\n" +
- " - `not-a-rule` — indented, so not a rule id.\n" +
- "- not a rule at all\n" +
- "- `Shouty` — capitals are not rule ids.\n"
- got := rules(criteria)
- want := map[string]bool{"noun-for-type": true, "verb-for-func": true}
- if len(got) != len(want) {
- t.Fatalf("got %v, want %v", got, want)
- }
- for id := range want {
- if !got[id] {
- t.Errorf("%s missing from %v", id, got)
- }
- }
-}
-
-// Every job's criteria have to define at least one citable id, or the job can
-// report nothing that survives filtering.
-func TestEveryJobDefinesRules(t *testing.T) {
- for _, job := range Jobs() {
- ids := rules(job.Criteria)
- if len(ids) == 0 {
- t.Errorf("job %q defines no rule ids", job.Name)
- }
- for id := range ids {
- if strings.TrimSpace(id) == "" {
- t.Errorf("job %q defines an empty rule id", job.Name)
- }
- }
- }
-}
-
-func TestReportedFindingsDropsAnUnknownRule(t *testing.T) {
- r, err := decode(`{"findings":[
- {"rule":"cannot-fail","severity":"must-fix","file":"x_test.go","line":4,"message":"kept","fix":"f"},
- {"rule":"invented-by-the-model","severity":"must-fix","message":"dropped"},
- {"rule":"","severity":"note","message":"also dropped"}
- ]}`)
- if err != nil {
- t.Fatal(err)
- }
- got := r.findings("tests", map[string]bool{"cannot-fail": true})
- if len(got) != 1 {
- t.Fatalf("got %d findings, want only the one citing a known rule: %v", len(got), got)
- }
- if got[0].Job != "tests" || got[0].Rule != "cannot-fail" || got[0].Severity != MustFix {
- t.Errorf("got %+v", got[0])
- }
- if got[0].SeverityName != "must-fix" {
- t.Errorf("the severity as reported is kept for JSON, got %q", got[0].SeverityName)
- }
-}
-
-func TestDecode(t *testing.T) {
- r, err := decode(`{"findings":[{"rule":"cannot-fail","severity":"note","file":"x.go","line":3,"message":"m","fix":"f"}]}`)
- if err != nil {
- t.Fatal(err)
- }
- if len(r.Findings) != 1 || r.Findings[0].Line != 3 {
- t.Fatalf("got %+v", r.Findings)
- }
- if _, err := decode(`not json`); err == nil {
- t.Error("decoding rubbish should fail")
- }
- // An empty list is how a job reports nothing, and must not be an error.
- if _, err := decode(`{"findings":[]}`); err != nil {
- t.Errorf("an empty report is valid: %v", err)
- }
-}
-
-func TestChosen(t *testing.T) {
- all, err := chosen("")
- if err != nil {
- t.Fatal(err)
- }
- if len(all) != len(Jobs()) {
- t.Errorf("got %d jobs, want all of them", len(all))
- }
- some, err := chosen("namer, tests")
- if err != nil {
- t.Fatal(err)
- }
- if len(some) != 2 || some[0].Name != "namer" || some[1].Name != "tests" {
- t.Errorf("got %v", some)
- }
- if _, err := chosen("nonsense"); err == nil {
- t.Error("an unknown job should be named as such")
- }
-}
-
-// A job with nothing to read renders nothing, which is what keeps a
-// documentation-only commit from paying for a test review.
-func TestSubjectsAreEmptyWithNothingToRead(t *testing.T) {
- empty := &Change{Candidates: map[string][]string{}}
- for _, job := range Jobs() {
- if got := job.Subject(empty); strings.TrimSpace(got) != "" {
- t.Errorf("job %q renders %q for an empty change", job.Name, got)
- }
- }
-}
-
-func TestSubjectsRenderWhatTheirJobReads(t *testing.T) {
- change := &Change{
- Symbols: []Symbol{{
- Name: "Stored", Kind: "type", File: "ico/ico.go", Line: 12,
- Exported: true, Signature: "type Stored struct {", Doc: "Stored is an icon already encoded.",
- }},
- Tests: []Function{{Name: "TestWrite", File: "ico/w_test.go", Line: 3, Body: "func TestWrite(t *testing.T) {\n\tt.Skip()\n}"}},
- Comments: []Located{{Text: "Windows rejects a 256 pixel icon.", File: "exe/exe.go", Line: 40}},
- Message: "ico: assemble a file",
- Stat: " ico/ico.go | 3 +++",
- Convention: []string{"ico: read a directory", "exe: carry icons"},
- Candidates: map[string][]string{"Stored": {"ico/ico.go:4: type Encoded struct {"}},
- }
- for _, test := range []struct {
- job string
- want []string
- gone []string
- }{
- {"namer", []string{"Stored", "(exported)", "type Stored struct {", "doc:", "type Encoded struct {"}, []string{"TestWrite", "Windows rejects"}},
- {"duplication", []string{"NEW", "Stored", "candidates:", "type Encoded struct {"}, []string{"TestWrite"}},
- {"tests", []string{"TestWrite", "t.Skip()"}, []string{"Stored"}},
- {"claims", []string{"Windows rejects a 256 pixel icon.", "exe/exe.go:40"}, []string{"TestWrite"}},
- {"hygiene", []string{"ico: assemble a file", "ico/ico.go | 3 +++", "ico: read a directory"}, []string{"TestWrite"}},
- } {
- t.Run(test.job, func(t *testing.T) {
- jobs, err := chosen(test.job)
- if err != nil {
- t.Fatal(err)
- }
- got := jobs[0].Subject(change)
- for _, want := range test.want {
- if !strings.Contains(got, want) {
- t.Errorf("%q missing from:\n%s", want, got)
- }
- }
- for _, gone := range test.gone {
- if strings.Contains(got, gone) {
- t.Errorf("%q leaked into another job's subject:\n%s", gone, got)
- }
- }
- })
- }
-}
-
-func TestDuplicationSubjectSaysWhenThereAreNoCandidates(t *testing.T) {
- change := &Change{
- Symbols: []Symbol{{Name: "Alone", Kind: "type", File: "x.go", Line: 1}},
- Candidates: map[string][]string{},
- }
- if got := duplicationSubject(change); !strings.Contains(got, "candidates: none found") {
- t.Errorf("got:\n%s", got)
- }
-}
-
-func TestFirstLine(t *testing.T) {
- if got := firstLine("one\ntwo"); got != "one" {
- t.Errorf("got %q", got)
- }
- long := strings.Repeat("x", 200)
- got := firstLine(long)
- if len([]rune(got)) != 141 || !strings.HasSuffix(got, "…") {
- t.Errorf("a long line is cut, got %d runes", len([]rune(got)))
- }
-}
-
-func TestShort(t *testing.T) {
- lines := []string{"a", "b", "c"}
- if got := short(lines, 2); len(got) != 2 {
- t.Errorf("got %v", got)
- }
- if got := short(lines, 9); len(got) != 3 {
- t.Errorf("got %v", got)
- }
-}
-
-// still is a provider that answers with whatever it was handed, so the whole
-// path from an answer to a finding can be walked without a model.
-type still struct {
- answer string
- system string
- user string
-}
-
-func (s *still) Name() string { return "still" }
-
-func (s *still) Ask(ctx context.Context, system, user string) (Answer, error) {
- s.system, s.user = system, user
- return Answer{Text: s.answer}, nil
-}
-
-func TestReviewerReadsAnAnswer(t *testing.T) {
- provider := &still{answer: "Sure! ```json\n{\"findings\":[" +
- `{"rule":"cannot-fail","severity":"must-fix","file":"x_test.go","line":4,"message":"m","fix":"f"},` +
- `{"rule":"made-up","severity":"must-fix","message":"dropped"}` +
- "]}\n```"}
- change := &Change{Tests: []Function{{Name: "TestX", File: "x_test.go", Line: 3, Body: "func TestX(t *testing.T) {}"}}}
- jobs, err := chosen("tests")
- if err != nil {
- t.Fatal(err)
- }
- result := Reviewer{Provider: provider}.Run(context.Background(), change, jobs)
- if len(result.Failures) != 0 {
- t.Fatalf("failures: %v", result.Failures)
- }
- if len(result.Findings) != 1 || result.Findings[0].Rule != "cannot-fail" {
- t.Fatalf("got %v", result.Findings)
- }
- // The criteria are the system prompt, where a provider that caches
- // anything will cache them.
- if !strings.Contains(provider.system, instruction) || !strings.Contains(provider.system, "cannot-fail") {
- t.Errorf("the system prompt is missing the instruction or the criteria:\n%s", provider.system)
- }
- if !strings.Contains(provider.user, "TestX") {
- t.Errorf("the subject never reached the provider:\n%s", provider.user)
- }
-}
-
-func TestReviewerReportsABadAnswer(t *testing.T) {
- change := &Change{Tests: []Function{{Name: "TestX", File: "x_test.go", Line: 3, Body: "func TestX(t *testing.T) {}"}}}
- jobs, err := chosen("tests")
- if err != nil {
- t.Fatal(err)
- }
- result := Reviewer{Provider: &still{answer: "I would rather not."}}.Run(context.Background(), change, jobs)
- if len(result.Failures) != 1 {
- t.Fatalf("got %v, want one failure", result.Failures)
- }
- if !strings.Contains(result.Failures[0].Error(), "tests:") {
- t.Errorf("a failure names its job, got %q", result.Failures[0])
- }
-}
-
-// One job failing does not stop the others: four readings out of five is
-// worth more than none.
-func TestReviewerKeepsGoingAfterAFailure(t *testing.T) {
- change := &Change{
- Tests: []Function{{Name: "TestX", File: "x_test.go", Line: 3, Body: "func TestX(t *testing.T) {}"}},
- Comments: []Located{{Text: "Windows rejects this.", File: "x.go", Line: 1}},
- }
- jobs, err := chosen("tests,claims")
- if err != nil {
- t.Fatal(err)
- }
- provider := &refusing{good: "claims"}
- result := Reviewer{Provider: provider}.Run(context.Background(), change, jobs)
- if len(result.Failures) != 1 {
- t.Fatalf("failures: %v", result.Failures)
- }
- if len(result.Findings) != 1 || result.Findings[0].Job != "claims" {
- t.Fatalf("got %v, want the reading that worked", result.Findings)
- }
-}
-
-// refusing answers only the job whose criteria it recognises.
-type refusing struct{ good string }
-
-func (r *refusing) Name() string { return "refusing" }
-
-func (r *refusing) Ask(ctx context.Context, system, user string) (Answer, error) {
- if r.good == "claims" && strings.Contains(system, "unsupported-claim") {
- return Answer{Text: `{"findings":[{"rule":"unsupported-claim","severity":"note","file":"x.go","line":1,"message":"m","fix":"f"}]}`}, nil
- }
- return Answer{}, context.Canceled
-}
-
-// A reading finds one same-value pair and stops when the pairs are buried
-// among the resemblances, so they are rendered first and alone.
-func TestDuplicationSubjectLeadsWithTheTwins(t *testing.T) {
- change := &Change{
- Symbols: []Symbol{
- {Name: "typeIconGroup", Kind: "value", File: "exe/exe.go", Line: 32, Signature: "typeIconGroup = 14"},
- {Name: "groupHeaderSize", Kind: "value", File: "exe/exe.go", Line: 44, Signature: "groupHeaderSize = 6"},
- },
- Twins: map[string][]string{
- "typeIconGroup": {"exe/exe.go:47: groupEntrySize = 14 <- same value"},
- "groupHeaderSize": {"ico/writer.go:15: directorySize = 6 <- same value"},
- },
- Candidates: map[string][]string{
- "groupHeaderSize": {"ico/writer.go:15: directorySize = 6 <- same value", "exe/exe.go:53: type Group struct {"},
- },
- }
- got := duplicationSubject(change)
- list, detail, found := strings.Cut(got, "Each name the change adds")
- if !found {
- t.Fatalf("no detail section in:\n%s", got)
- }
- for _, want := range []string{"groupEntrySize = 14", "directorySize = 6", "exe/exe.go:44"} {
- if !strings.Contains(list, want) {
- t.Errorf("%q missing from the list of pairs:\n%s", want, list)
- }
- }
- // The pairs are declared in the order the change declares them, so two
- // readings of the same change see the same list.
- if strings.Index(list, "typeIconGroup") > strings.Index(list, "groupHeaderSize") {
- t.Errorf("the pairs are out of order:\n%s", list)
- }
- if !strings.Contains(detail, "type Group struct {") {
- t.Errorf("the resemblances are gone:\n%s", detail)
- }
-}
-
-func TestDuplicationSubjectWithoutTwins(t *testing.T) {
- change := &Change{
- Symbols: []Symbol{{Name: "Alone", Kind: "type", File: "x.go", Line: 1}},
- Twins: map[string][]string{},
- Candidates: map[string][]string{},
- }
- if got := duplicationSubject(change); strings.Contains(got, "already declared elsewhere") {
- t.Errorf("a change with no pairs is not asked about them:\n%s", got)
- }
-}
-
-// A provider that cannot be held to a schema sometimes describes its
-// findings instead of reporting them. Losing the whole reading to that is
-// worse than asking again.
-func TestReviewerAsksAgainForAnAnswerItCanRead(t *testing.T) {
- provider := &relenting{
- answers: []string{
- "Reported two findings: `Stored` (must-fix) and `largest` (consider).",
- `{"findings":[{"rule":"cannot-fail","severity":"must-fix","file":"x_test.go","line":4,"message":"m","fix":"f"}]}`,
- },
- }
- change := &Change{Tests: []Function{{Name: "TestX", File: "x_test.go", Line: 3, Body: "func TestX(t *testing.T) {}"}}}
- jobs, err := chosen("tests")
- if err != nil {
- t.Fatal(err)
- }
- result := Reviewer{Provider: provider}.Run(context.Background(), change, jobs)
- if len(result.Failures) != 0 {
- t.Fatalf("failures: %v", result.Failures)
- }
- if len(result.Findings) != 1 || result.Findings[0].Rule != "cannot-fail" {
- t.Fatalf("got %v", result.Findings)
- }
- if len(provider.asked) != 2 {
- t.Fatalf("asked %d times, want two", len(provider.asked))
- }
- if !strings.Contains(provider.asked[1], again) {
- t.Errorf("the second question does not say what was wrong:\n%s", provider.asked[1])
- }
- // The subject is asked again, not only the correction.
- if !strings.Contains(provider.asked[1], "TestX") {
- t.Errorf("the subject was dropped from the second question:\n%s", provider.asked[1])
- }
-}
-
-// It is asked twice and no more: a provider that will not answer in JSON
-// will not do so on the third try either.
-func TestReviewerGivesUpAfterAskingTwice(t *testing.T) {
- provider := &relenting{answers: []string{"no", "still no"}}
- change := &Change{Tests: []Function{{Name: "TestX", File: "x_test.go", Line: 3, Body: "func TestX(t *testing.T) {}"}}}
- jobs, err := chosen("tests")
- if err != nil {
- t.Fatal(err)
- }
- result := Reviewer{Provider: provider}.Run(context.Background(), change, jobs)
- if len(result.Failures) != 1 {
- t.Fatalf("got %v, want one failure", result.Failures)
- }
- // The first answer is the one worth seeing: the second was made under
- // duress.
- if !strings.Contains(result.Failures[0].Error(), "no") || !strings.Contains(result.Failures[0].Error(), "twice") {
- t.Errorf("got %q", result.Failures[0])
- }
- if len(provider.asked) != 2 {
- t.Errorf("asked %d times, want two", len(provider.asked))
- }
-}
-
-// relenting answers each question with the next answer it was given.
-type relenting struct {
- answers []string
- asked []string
-}
-
-func (r *relenting) Name() string { return "relenting" }
-
-func (r *relenting) Ask(ctx context.Context, system, user string) (Answer, error) {
- r.asked = append(r.asked, user)
- if len(r.asked) > len(r.answers) {
- return Answer{}, fmt.Errorf("asked once too often")
- }
- 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/justfile b/justfile
@@ -1,13 +1,12 @@
# Standard recipes: build (debug), release, clean, test, install.
-# install also builds review-vet, the go vet sidecar that `go-vet/<analyzer>`
-# findings use when it sits beside review on PATH, and review-go, the Go
-# parser sidecar the Odin reading under odin/ drives.
-# odin-test and odin-build cover the Odin reading; they need the jfm
-# collection at ~/Source/Personal/odin and review-go on PATH.
+# review is the Odin binary; review-go and review-vet are the Go sidecars
+# (the Go parser, and go vet's multichecker) and odin-review-extract the
+# Odin one. install ships all four beside each other on PATH. The Odin
+# packages need the jfm collection at ~/Source/Personal/odin.
-bin := "review"
odin := env("ODIN", "odin")
-odin_flags := "-vet -strict-style -collection:jfm=" + home_directory() / "Source" / "Personal" / "odin"
+flags := "-vet -strict-style -collection:jfm=" + home_directory() / "Source" / "Personal" / "odin"
+packages := "txt frontend git tree change finding report check analyser job provider cache reviewer hook bench"
# `just` alone lists the recipes.
default:
@@ -15,49 +14,43 @@ default:
# Debug build: symbols kept, for delve and stack traces.
build:
- go build -o {{bin}} .
- go build -C sidecar/govet -o ../../{{bin}}-vet .
- go build -o {{bin}}-go ./sidecar/gofront
+ mkdir -p build
+ {{odin}} build review {{flags}} -debug -out:build/review
+ {{odin}} build sidecar/odin {{flags}} -debug -out:build/odin-review-extract
+ go build -o build/review-go ./sidecar/gofront
+ go build -C sidecar/govet -o ../../build/review-vet .
# Optimised build: stripped, reproducible paths.
release:
- go build -trimpath -ldflags='-s -w' -o {{bin}} .
- go build -C sidecar/govet -trimpath -ldflags='-s -w' -o ../../{{bin}}-vet .
- go build -trimpath -ldflags='-s -w' -o {{bin}}-go ./sidecar/gofront
+ mkdir -p build
+ {{odin}} build review {{flags}} -o:speed -out:build/review
+ {{odin}} build sidecar/odin {{flags}} -o:speed -out:build/odin-review-extract
+ go build -trimpath -ldflags='-s -w' -o build/review-go ./sidecar/gofront
+ go build -C sidecar/govet -trimpath -ldflags='-s -w' -o ../../build/review-vet .
-# Vet and run the tests.
+# Type-check, then run every package's tests and the Go sidecar's.
test:
- go vet ./...
- go test ./...
+ #!/usr/bin/env bash
+ set -euo pipefail
+ mkdir -p build/test
+ for p in {{packages}}; do
+ {{odin}} test $p {{flags}} -out:build/test/$p
+ done
+ go vet ./... && go test ./...
# Remove build output and the Go build cache for this module.
clean:
- rm -f {{bin}} {{bin}}-vet {{bin}}-go
rm -rf build
go clean
-# Test the Odin reading, package by package.
-odin-test:
- #!/usr/bin/env bash
- set -euo pipefail
- mkdir -p build
- for p in txt frontend git tree change finding report check analyser job provider cache reviewer hook bench; do
- {{odin}} test odin/$p {{odin_flags}} -out:build/${p}_test
- done
-
-# Debug build of the Odin reading -> build/review-odin
-odin-build:
- mkdir -p build
- {{odin}} build odin/review {{odin_flags}} -debug -out:build/review-odin
-
-# Copy the release binary to a directory on PATH. An installed copy is replaced in
-# place; otherwise ~/.local/bin, ~/bin, GOBIN, GOPATH/bin, /usr/local/bin, else the
-# first writable PATH entry.
+# Copy the release binaries to a directory on PATH. An installed review is
+# replaced in place; otherwise ~/.local/bin, ~/bin, GOBIN, GOPATH/bin,
+# /usr/local/bin, else the first writable PATH entry.
install: release
#!/usr/bin/env bash
set -euo pipefail
dest=""
- if existing=$(command -v {{bin}} 2>/dev/null) && [[ -w ${existing%/*} ]]; then
+ if existing=$(command -v review 2>/dev/null) && [[ -w ${existing%/*} ]]; then
dest=${existing%/*}
fi
gopath=$(go env GOPATH 2>/dev/null || true); gobin=$(go env GOBIN 2>/dev/null || true)
@@ -69,7 +62,7 @@ install: release
for d in "${dirs[@]}"; do [[ -d $d && -w $d ]] && { dest=$d; break; }; done
fi
[[ -n $dest ]] || { echo "install: no writable directory on PATH" >&2; exit 1; }
- install -m 755 {{bin}} "$dest/{{bin}}"
- install -m 755 {{bin}}-vet "$dest/{{bin}}-vet"
- install -m 755 {{bin}}-go "$dest/{{bin}}-go"
- echo "installed $dest/{{bin}}, $dest/{{bin}}-vet and $dest/{{bin}}-go"
+ for bin in review review-go review-vet odin-review-extract; do
+ install -m 755 build/$bin "$dest/$bin"
+ done
+ echo "installed review, review-go, review-vet and odin-review-extract into $dest"
diff --git a/kinds.go b/kinds.go
@@ -1,243 +0,0 @@
-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"
- "slices"
- "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)
- }
- // In file and line order, so that what is read is the same on every
- // run.
- slices.SortFunc(out, func(a, b kindMatch) int {
- if c := strings.Compare(a.File, b.File); c != 0 {
- return c
- }
- return a.Line - b.Line
- })
- 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
@@ -1,179 +0,0 @@
-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
@@ -1,207 +0,0 @@
-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
@@ -1,106 +0,0 @@
-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
@@ -1,176 +0,0 @@
-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
@@ -1,617 +0,0 @@
-// Command review 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 the things those checks cannot — whether a name says what the thing
-// is, whether a fact is already stated elsewhere, whether a test can fail,
-// whether a comment claims something nobody verified. A set of deterministic
-// checks — the commit message, the repository's history, and staticcheck where
-// it is installed — run before anything is asked. Each job's findings are put
-// back to the provider once, against the same evidence, and the ones a second
-// reading does not let stand are retracted rather than reported; what each ask
-// answered is cached, so that a re-run of an unchanged part of the change
-// replays it rather than asking again.
-//
-// review the staged change
-// 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:
-//
-// //review:ignore <rule> <why>
-package main
-
-import (
- "context"
- "encoding/json"
- "errors"
- "flag"
- "fmt"
- "os"
- "os/exec"
- "slices"
- "strings"
-)
-
-func main() {
- 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
- 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.")
- flag.BoolVar(&fresh, "fresh", false, "Ask the provider even where the answer cache holds this exact question.")
- flag.StringVar(&only, "jobs", "", "Run only these jobs, comma separated.")
- flag.StringVar(&which, "provider", orElse(os.Getenv("REVIEW_PROVIDER"), "chain"), "Who answers: chain (probe the default order), claude, pi, api, or command.")
- 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 {
- return err
- }
- change, err := Gather(flag.Arg(0), root)
- 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"})
- }
- fmt.Println("nothing to review")
- return nil
- }
-
- jobs, err := chosen(only)
- 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)
- fmt.Printf("=== %s ===\n", job.Name)
- if strings.TrimSpace(subject) == "" {
- fmt.Print("(nothing to read)\n\n")
- continue
- }
- 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 {
- fmt.Printf("reviewing %d files, %d new names, %d tests, %d comments\n",
- len(change.Files), len(change.Symbols), len(change.Tests), len(change.Comments))
- }
-
- var provider Provider
- var providerName string
- if which == "chain" {
- picked, name, err := defaultChain().pick(context.Background(), func(s string) { fmt.Fprintln(os.Stderr, s) })
- if err != nil {
- return err
- }
- provider = picked
- providerName = provider.Name()
- if verbose {
- fmt.Printf("asking %s\n", name)
- }
- } else {
- build, known := Providers()[which]
- if !known {
- return fmt.Errorf("no provider called %q", which)
- }
- provider = build(model)
- providerName = provider.Name()
- if verbose {
- fmt.Printf("asking %s\n", providerName)
- }
- }
-
- // The cache records what each ask answered, so that a re-run of a part
- // of the change that did not change replays it instead of asking. A
- // cache that cannot exist is no fault of the review.
- cache := openCache(fresh)
- if cache != nil {
- defer cache.save()
- }
- reviewer := Reviewer{Provider: provider, Verbose: verbose, Verify: !noVerify, Cache: cache}
- result := reviewer.Run(context.Background(), change, jobs)
-
- 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,
- Status: statusOf(change, result),
- Provider: providerName,
- Findings: kept,
- Retracted: result.Retracted,
- Failed: faults(result.Failures),
- Skipped: result.Skipped,
- 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 {
- 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
- }
- 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
-
-// JobFault is one job that could not finish, and what failed about it.
-type JobFault struct {
- Job string `json:"job"`
- Error string `json:"error"`
-}
-
-// metered is what the asks cost, in tokens and dollars. Answers replayed
-// from the cache count as replayed rather than as usage: they cost nothing
-// this run.
-type metered struct {
- In int `json:"in"`
- Out int `json:"out"`
- Cached int `json:"cached"`
- Replayed int `json:"replayed"`
- Cost float64 `json:"usd"`
-}
-
-// contract is the whole report a program reads: what was found, what the
-// second reading did not let stand, what was dismissed in the source, and
-// how complete the measurement was. The status is what makes an empty
-// findings list readable: "complete" is the only status an empty list can
-// be read as a pass against, because the others say which part of the
-// measurement is missing.
-type contract struct {
- Version int `json:"version"`
- Status string `json:"status"`
- Provider string `json:"provider,omitempty"`
- Findings []Finding `json:"findings"`
- Retracted []Retracted `json:"retracted"`
- Failed []JobFault `json:"failed"`
- Skipped []string `json:"skipped"`
- Uncovered []Gap `json:"uncovered"`
- Dismissed []Dismissed `json:"dismissed"`
- Truncated bool `json:"truncated,omitempty"`
- // 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
-// the deterministic side could not read and the jobs that failed are what
-// keep an empty findings list from being read as a pass.
-func statusOf(change *Change, result RunResult) string {
- if len(result.Failures) > 0 || len(change.Uncovered) > 0 || change.Truncated {
- return "incomplete"
- }
- return "complete"
-}
-
-// faults pairs each failure's job with what failed about it, for the
-// report.
-func faults(failures []error) []JobFault {
- var out []JobFault
- for _, err := range failures {
- job, cause, found := strings.Cut(err.Error(), ": ")
- if !found {
- out = append(out, JobFault{Job: err.Error(), Error: err.Error()})
- continue
- }
- out = append(out, JobFault{Job: job, Error: cause})
- }
- return out
-}
-
-// Dismissed is a finding the source itself answered, and the answer.
-type Dismissed struct {
- Finding Finding `json:"finding"`
- Why string `json:"why"`
-}
-
-// filter drops the findings the source itself dismisses, and keeps them so
-// that a silent dismissal can still be read back.
-func filter(root string, findings []Finding) (kept []Finding, dismissed []Dismissed) {
- if err := os.Chdir(root); err != nil {
- return findings, nil
- }
- for _, f := range findings {
- if why, ok := Suppressed(f); ok {
- dismissed = append(dismissed, Dismissed{Finding: f, Why: why})
- continue
- }
- kept = append(kept, f)
- }
- return kept, dismissed
-}
-
-func render(env contract, failures []error, verbose bool) {
- for _, err := range failures {
- fmt.Fprintln(os.Stderr, " job failed:", err)
- }
- // A dismissal reaches a few lines either side of itself, so which
- // findings one answered is worth being able to read; a retraction is
- // worth reading in full, because it is the second reading's word
- // against the first's.
- if verbose {
- for _, d := range env.Dismissed {
- fmt.Printf(" dismissed: %s:%d %s (%s)\n", d.Finding.File, d.Finding.Line, d.Finding.Rule, d.Why)
- }
- for _, r := range env.Retracted {
- fmt.Printf(" retracted: [%s] %s:%d — %s\n", r.Finding.Rule, r.Finding.File, r.Finding.Line, first(r.Reason, 120))
- }
- }
- findings := env.Findings
- if len(findings) == 0 {
- fmt.Print("no findings")
- if len(env.Dismissed) > 0 {
- fmt.Printf(" (%d dismissed in the source)", len(env.Dismissed))
- }
- fmt.Println()
- retractedNote(env)
- baselineNote(env)
- return
- }
- var severity Severity = -1
- for _, f := range findings {
- if f.Severity != severity {
- severity = f.Severity
- fmt.Printf("\n%s\n", strings.ToUpper(severity.String()))
- }
- fmt.Printf(" %s\n", indent(f.String()))
- }
- fmt.Printf("\n%d findings", len(findings))
- if len(env.Dismissed) > 0 {
- fmt.Printf(", %d dismissed in the source", len(env.Dismissed))
- }
- 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.
-func retractedNote(env contract) {
- if len(env.Retracted) == 0 {
- return
- }
- fmt.Printf("%d of the findings reported did not survive verification\n", len(env.Retracted))
-}
-
-func indent(s string) string {
- return strings.ReplaceAll(s, "\n ", "\n ")
-}
-
-// report prints the contract: the whole measurement, not only the
-// findings, so that a program reading it can tell a pass from a hole. Each
-// finding is given its stable id and its severity as text, which is what a
-// loop needs to answer a finding and check it stayed answered.
-func report(env contract) error {
- // Every list is said even when empty: a key an agent cannot find is a
- // hole it guesses about, and the contract is what makes the reading
- // readable without guessing.
- if env.Findings == nil {
- env.Findings = []Finding{}
- }
- if env.Retracted == nil {
- env.Retracted = []Retracted{}
- }
- if env.Failed == nil {
- env.Failed = []JobFault{}
- }
- if env.Skipped == nil {
- env.Skipped = []string{}
- }
- if env.Uncovered == nil {
- env.Uncovered = []Gap{}
- }
- if env.Dismissed == nil {
- env.Dismissed = []Dismissed{}
- }
- nameFindings(env.Findings)
- retracted := make([]Finding, len(env.Retracted))
- for i := range env.Retracted {
- 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 {
- return err
- }
- fmt.Println(string(out))
- return nil
-}
-
-// nameFindings fills in what a finding carries only in the report: its
-// 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)
- }
- }
-}
-
-func chosen(only string) ([]Job, error) {
- all := Jobs()
- if only == "" {
- return all, nil
- }
- var out []Job
- for _, name := range strings.Split(only, ",") {
- name = strings.TrimSpace(name)
- found := false
- for _, job := range all {
- if job.Name == name {
- out = append(out, job)
- found = true
- }
- }
- if !found {
- return nil, fmt.Errorf("no job called %q", name)
- }
- }
- return out, nil
-}
-
-func repository() (string, error) {
- out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
- if err != nil {
- return "", fmt.Errorf("not inside a git repository")
- }
- return strings.TrimSpace(string(out)), nil
-}
-
-func usage() {
- fmt.Fprint(flag.CommandLine.Output(), `review reads a change the way several narrow readers would.
-
-Usage:
- review [flags] [revision]
- review rules [job | rule]
-
-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:
-`)
- flag.PrintDefaults()
- fmt.Fprint(flag.CommandLine.Output(), `
-Jobs:
- duplication whether the change states something the repository already states
- tests whether the tests it adds can fail
- namer whether the names it adds say what the things are
- claims whether the comments it adds assert what nobody checked
- hygiene whether the commit message matches the commit
-
-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
@@ -1,375 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "fmt"
- "io"
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-// capture collects what is printed to standard output, which is how the
-// command reports.
-func capture(t *testing.T, fn func()) string {
- t.Helper()
- read, write, err := os.Pipe()
- if err != nil {
- t.Fatal(err)
- }
- stdout := os.Stdout
- os.Stdout = write
- done := make(chan string)
- go func() {
- out, _ := io.ReadAll(read)
- done <- string(out)
- }()
- fn()
- write.Close()
- os.Stdout = stdout
- return <-done
-}
-
-func TestFilterDropsWhatTheSourceDismisses(t *testing.T) {
- root := t.TempDir()
- source := "package x\n\n//review:ignore restates-a-fact the ico package owns it\nconst b = 6\n"
- if err := os.WriteFile(filepath.Join(root, "x.go"), []byte(source), 0o644); err != nil {
- t.Fatal(err)
- }
- t.Chdir(t.TempDir()) // Somewhere else, so that filter has to change directory itself.
-
- kept, dismissed := filter(root, []Finding{
- {File: "x.go", Line: 4, Rule: "restates-a-fact", Message: "dismissed"},
- {File: "x.go", Line: 4, Rule: "already-named", Message: "kept"},
- })
- if len(dismissed) != 1 {
- t.Fatalf("dismissed %v, want one", dismissed)
- }
- if dismissed[0].Why != "the ico package owns it" {
- t.Errorf("the reason is kept, got %q", dismissed[0].Why)
- }
- if len(kept) != 1 || kept[0].Message != "kept" {
- t.Errorf("kept %v", kept)
- }
-}
-
-// A dismissal reaches a few lines either side of itself, so which findings
-// it answered has to be readable rather than only countable.
-func TestRenderNamesWhatWasDismissed(t *testing.T) {
- dismissed := []Dismissed{{
- Finding: Finding{File: "x.go", Line: 7, Rule: "restates-a-fact"},
- Why: "the ico package owns it",
- }}
- out := capture(t, func() { render(contract{Dismissed: dismissed}, nil, true) })
- for _, want := range []string{"dismissed: x.go:7", "restates-a-fact", "the ico package owns it"} {
- if !strings.Contains(out, want) {
- t.Errorf("%q missing from:\n%s", want, out)
- }
- }
- // Without being asked, only the count.
- quiet := capture(t, func() { render(contract{Dismissed: dismissed}, nil, false) })
- if strings.Contains(quiet, "the ico package owns it") {
- t.Errorf("got %q", quiet)
- }
-}
-
-func TestFilterKeepsEverythingWhenTheRootIsGone(t *testing.T) {
- findings := []Finding{{File: "x.go", Line: 1, Rule: "a"}}
- kept, dismissed := filter(filepath.Join(t.TempDir(), "nowhere"), findings)
- if len(kept) != 1 || len(dismissed) != 0 {
- t.Errorf("got %v, %v", kept, dismissed)
- }
-}
-
-func TestReport(t *testing.T) {
- out := capture(t, func() {
- if err := report(contract{Findings: []Finding{{
- Job: "tests", Rule: "cannot-fail", Severity: MustFix,
- File: "x_test.go", Line: 4, Message: "m", Fix: "f",
- }}}); err != nil {
- t.Error(err)
- }
- })
- var envelope struct {
- Findings []struct {
- Rule string `json:"rule"`
- Severity string `json:"severity"`
- Line int `json:"line"`
- } `json:"findings"`
- }
- if err := json.Unmarshal([]byte(out), &envelope); err != nil {
- t.Fatalf("%v in:\n%s", err, out)
- }
- if len(envelope.Findings) != 1 {
- t.Fatalf("got %v", envelope.Findings)
- }
- // The severity travels as its name, since the number means nothing to a
- // program reading this.
- if envelope.Findings[0].Severity != "must-fix" || envelope.Findings[0].Line != 4 {
- t.Errorf("got %+v", envelope.Findings[0])
- }
-}
-
-// An agent reading the JSON needs an empty list rather than null.
-func TestReportNothing(t *testing.T) {
- out := capture(t, func() {
- if err := report(contract{}); err != nil {
- t.Error(err)
- }
- })
- if !strings.Contains(out, `"findings": []`) {
- t.Errorf("got %s", out)
- }
-}
-
-// The contract is what makes an empty findings list readable: the status,
-// the parts of the measurement that went missing, and the cost, all in one
-// object, with every finding named and marked.
-func TestReportCarriesTheContract(t *testing.T) {
- out := capture(t, func() {
- if err := report(contract{
- Version: contractVersion,
- Status: "incomplete",
- Provider: "api/probe",
- Retracted: []Retracted{{
- Finding: Finding{Job: "tests", Rule: "cannot-fail", Severity: MustFix, File: "x_test.go", Message: "m"},
- Reason: "the test can fail",
- }},
- Failed: []JobFault{{Job: "duplication", Error: "context canceled"}},
- Skipped: []string{"hygiene"},
- Uncovered: []Gap{{File: "a.odin", Reason: "no tests parser"}},
- Dismissed: []Dismissed{{Finding: Finding{Rule: "cannot-fail"}, Why: "wrong"}},
- Truncated: true,
- Usage: metered{In: 10, Out: 2, Cached: 1, Replayed: 3, Cost: 0.5},
- }); err != nil {
- t.Error(err)
- }
- })
- var env struct {
- Version int `json:"version"`
- Status string `json:"status"`
- Provider string `json:"provider"`
- Findings []any `json:"findings"`
- Retracted []struct {
- Finding struct {
- ID string `json:"id"`
- Verified bool `json:"verified"`
- Severity string `json:"severity"`
- } `json:"finding"`
- Reason string `json:"reason"`
- } `json:"retracted"`
- Failed []struct {
- Job string `json:"job"`
- Error string `json:"error"`
- } `json:"failed"`
- Skipped []string `json:"skipped"`
- Uncovered []struct {
- File string `json:"file"`
- Reason string `json:"reason"`
- } `json:"uncovered"`
- Dismissed []struct {
- Why string `json:"why"`
- } `json:"dismissed"`
- Truncated bool `json:"truncated"`
- Usage struct {
- In int `json:"in"`
- Out int `json:"out"`
- Cached int `json:"cached"`
- Replayed int `json:"replayed"`
- Cost float64 `json:"usd"`
- } `json:"usage"`
- }
- if err := json.Unmarshal([]byte(out), &env); err != nil {
- t.Fatalf("%v in:\n%s", err, out)
- }
- if env.Version != contractVersion || env.Status != "incomplete" || env.Provider != "api/probe" {
- t.Errorf("got %d %q %q", env.Version, env.Status, env.Provider)
- }
- if len(env.Retracted) != 1 || env.Retracted[0].Reason != "the test can fail" {
- t.Errorf("got %+v", env.Retracted)
- }
- if env.Retracted[0].Finding.ID == "" || env.Retracted[0].Finding.Verified {
- t.Errorf("a retracted finding is named and unverified, got %+v", env.Retracted[0].Finding)
- }
- if env.Retracted[0].Finding.Severity != "must-fix" {
- t.Errorf("got %q", env.Retracted[0].Finding.Severity)
- }
- if len(env.Failed) != 1 || env.Failed[0].Job != "duplication" || env.Failed[0].Error != "context canceled" {
- t.Errorf("got %+v", env.Failed)
- }
- if len(env.Skipped) != 1 || env.Skipped[0] != "hygiene" {
- t.Errorf("got %+v", env.Skipped)
- }
- if len(env.Uncovered) != 1 || env.Uncovered[0].File != "a.odin" {
- t.Errorf("got %+v", env.Uncovered)
- }
- if len(env.Dismissed) != 1 || env.Dismissed[0].Why != "wrong" {
- t.Errorf("got %+v", env.Dismissed)
- }
- if !env.Truncated || env.Usage.In != 10 || env.Usage.Replayed != 3 || env.Usage.Cost != 0.5 {
- t.Errorf("got %v %+v", env.Truncated, env.Usage)
- }
- if len(env.Findings) != 0 {
- t.Errorf("got %+v", env.Findings)
- }
-}
-
-// The status is what keeps an empty findings list from being read as a
-// pass when part of the measurement is missing.
-func TestStatusOfTheMeasurement(t *testing.T) {
- cases := []struct {
- change *Change
- run RunResult
- want string
- }{
- {&Change{}, RunResult{}, "complete"},
- {&Change{Truncated: true}, RunResult{}, "incomplete"},
- {&Change{Uncovered: []Gap{{File: "a.odin", Reason: "no tests parser"}}}, RunResult{}, "incomplete"},
- {&Change{}, RunResult{Failures: []error{fmt.Errorf("tests: context canceled")}}, "incomplete"},
- }
- for _, test := range cases {
- if got := statusOf(test.change, test.run); got != test.want {
- t.Errorf("got %q, want %q", got, test.want)
- }
- }
-}
-
-// A retraction is worth reading in full under verbose, because it is the
-// second reading's word against the first's.
-func TestRenderNamesWhatWasRetracted(t *testing.T) {
- env := contract{Retracted: []Retracted{{
- Finding: Finding{Rule: "cannot-fail", File: "x_test.go", Line: 4},
- Reason: "the test can fail",
- }}}
- out := capture(t, func() { render(env, nil, true) })
- for _, want := range []string{"retracted: [cannot-fail] x_test.go:4", "the test can fail"} {
- if !strings.Contains(out, want) {
- t.Errorf("%q missing from:\n%s", want, out)
- }
- }
- // The count is said even without being asked.
- quiet := capture(t, func() { render(env, nil, false) })
- if !strings.Contains(quiet, "1 of the findings reported did not survive verification") {
- t.Errorf("got %q", quiet)
- }
-}
-
-func TestRenderGroupsBySeverity(t *testing.T) {
- out := capture(t, func() {
- render(contract{
- Findings: []Finding{
- {Job: "tests", Rule: "cannot-fail", Severity: MustFix, File: "a_test.go", Line: 4, Message: "one"},
- {Job: "namer", Rule: "abbreviation", Severity: Note, Symbol: "cfg", Message: "two"},
- },
- Dismissed: []Dismissed{{}, {}},
- }, nil, false)
- })
- for _, want := range []string{"MUST-FIX", "NOTE", "one", "two", "2 findings", "2 dismissed in the source"} {
- if !strings.Contains(out, want) {
- t.Errorf("%q missing from:\n%s", want, out)
- }
- }
- if strings.Count(out, "MUST-FIX") != 1 {
- t.Errorf("a severity is announced once:\n%s", out)
- }
-}
-
-func TestRenderNothing(t *testing.T) {
- out := capture(t, func() { render(contract{}, nil, false) })
- if strings.TrimSpace(out) != "no findings" {
- t.Errorf("got %q", out)
- }
- // A silent dismissal is still visible.
- out = capture(t, func() { render(contract{Dismissed: []Dismissed{{}, {}, {}}}, nil, false) })
- if !strings.Contains(out, "3 dismissed in the source") {
- t.Errorf("got %q", out)
- }
-}
-
-func TestRepository(t *testing.T) {
- r := newRepo(t)
- r.write("x.go", "package x\n")
- r.commit("first", "x.go")
- t.Chdir(r.Root)
-
- root, err := repository()
- if err != nil {
- t.Fatal(err)
- }
- // A temporary directory may be reached through a symlink, so compare
- // what the filesystem resolves to.
- want, err := filepath.EvalSymlinks(r.Root)
- if err != nil {
- t.Fatal(err)
- }
- if got, _ := filepath.EvalSymlinks(root); got != want {
- t.Errorf("got %q, want %q", got, want)
- }
-}
-
-func TestRepositoryOutsideOne(t *testing.T) {
- t.Chdir(t.TempDir())
- t.Setenv("GIT_CEILING_DIRECTORIES", os.TempDir())
- if _, err := repository(); err == nil {
- t.Skip("the temporary directory is inside a repository on this machine")
- }
-}
-
-func TestIndent(t *testing.T) {
- if got := indent("a\n b"); got != "a\n b" {
- 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
@@ -1,166 +0,0 @@
-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
@@ -1,78 +0,0 @@
-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/odin/job/job.odin b/odin/job/job.odin
@@ -1,530 +0,0 @@
-/*
-Package job is the narrow readings a model is asked for. Each is given the
-part of the change it needs and nothing else: a job that reads less is
-cheaper, and harder to distract into reporting something another job
-owns. The criteria a job judges against are the thing to tune when it
-reports the wrong things, and they travel with the binary.
-*/
-package job
-
-import "base:runtime"
-import "core:encoding/json"
-import "core:fmt"
-import "core:slice"
-import "core:strings"
-import "core:text/regex"
-
-import "../change"
-import "../check"
-import "../finding"
-import "../txt"
-
-// Job is one reading. subject renders the part of the change it reads;
-// an empty subject means there is nothing here for it and the job is
-// skipped. splittable is whether the subject can be read file by file: a
-// subject over the packet cap is then asked in parts.
-Job :: struct {
- name: string,
- criteria: string,
- subject: proc(c: ^change.Change, allocator: runtime.Allocator) -> string,
- splittable: bool,
-}
-
-// all is the readings, in the order their findings are worth having.
-all :: proc(allocator := context.temp_allocator) -> []Job {
- jobs := make([]Job, 5, allocator)
- jobs[0] = Job {
- "duplication",
- #load("../../criteria/duplication.md", string),
- duplication_subject,
- true,
- }
- jobs[1] = Job{"tests", #load("../../criteria/tests.md", string), tests_subject, true}
- jobs[2] = Job{"namer", #load("../../criteria/namer.md", string), namer_subject, true}
- jobs[3] = Job{"claims", #load("../../criteria/claims.md", string), claims_subject, true}
- jobs[4] = Job{"hygiene", #load("../../criteria/hygiene.md", string), hygiene_subject, false}
- return jobs
-}
-
-// chosen is the jobs named, comma separated, or all of them.
-chosen :: proc(only: string, allocator := context.temp_allocator) -> (jobs: []Job, err: string) {
- if strings.trim_space(only) == "" {
- return all(allocator), ""
- }
- picked := make([dynamic]Job, allocator)
- for name in strings.split(only, ",", context.temp_allocator) {
- want := strings.trim_space(name)
- found := false
- for j in all(allocator) {
- if j.name == want {
- append(&picked, j)
- found = true
- }
- }
- if !found {
- return nil, fmt.aprintf(
- "no job called %q; the jobs are claims, duplication, hygiene, namer, tests",
- want,
- allocator = allocator,
- )
- }
- }
- return picked[:], ""
-}
-
-// rules reads the ids a job may cite out of its own criteria: the bullets
-// opening with a backticked id.
-rules :: proc(criteria: string, allocator := context.temp_allocator) -> map[string]bool {
- out := make(map[string]bool, allocator)
- rest := criteria
- for line in strings.split_lines_iterator(&rest) {
- if !strings.has_prefix(line, "- `") {
- continue
- }
- end := strings.index_byte(line[3:], '`')
- if end < 0 {
- continue
- }
- id := line[3:3 + end]
- valid := len(id) > 0
- for i in 0 ..< len(id) {
- c := id[i]
- if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
- valid = false
- }
- }
- if valid {
- out[id] = true
- }
- }
- return out
-}
-
-namer_subject :: proc(c: ^change.Change, allocator: runtime.Allocator) -> string {
- if len(c.symbols) == 0 {
- return ""
- }
- b := strings.builder_make(allocator)
- strings.write_string(&b, "Names this change adds or renames:\n\n")
- for s in c.symbols {
- fmt.sbprintf(&b, "%s:%d %s %s", s.file, s.line, s.kind, s.name)
- if s.exported {
- strings.write_string(&b, " (exported)")
- }
- strings.write_string(&b, "\n")
- if s.signature != "" {
- fmt.sbprintf(&b, " %s\n", s.signature)
- }
- if s.doc != "" {
- fmt.sbprintf(&b, " doc: %s\n", check.first_line(s.doc, context.temp_allocator))
- }
- if near := c.candidates[s.name]; len(near) > 0 {
- fmt.sbprintf(
- &b,
- " names already in this repository: %s\n",
- strings.join(near[:min(len(near), 6)], "; ", context.temp_allocator),
- )
- }
- strings.write_string(&b, "\n")
- }
- return strings.to_string(b)
-}
-
-duplication_subject :: proc(c: ^change.Change, allocator: runtime.Allocator) -> string {
- if len(c.symbols) == 0 {
- return ""
- }
- b := strings.builder_make(allocator)
- // The pairs holding the same literal go first and alone. Buried among
- // the resemblances a reading finds one of them and stops.
- if twinned := twins(c, context.temp_allocator); twinned != "" {
- strings.write_string(
- &b,
- "Declarations this change adds that hold a value already declared elsewhere.\n",
- )
- strings.write_string(&b, "Judge every pair on this list.\n\n")
- strings.write_string(&b, twinned)
- strings.write_string(&b, "\n")
- }
- strings.write_string(
- &b,
- "Each name the change adds, with existing declarations found by searching for its words.\n\n",
- )
- for s in c.symbols {
- fmt.sbprintf(&b, "NEW %s:%d %s %s\n", s.file, s.line, s.kind, s.name)
- if s.signature != "" {
- fmt.sbprintf(&b, " %s\n", s.signature)
- }
- if s.doc != "" {
- fmt.sbprintf(&b, " doc: %s\n", check.first_line(s.doc, context.temp_allocator))
- }
- candidates := c.candidates[s.name]
- if len(candidates) == 0 {
- strings.write_string(&b, " candidates: none found\n\n")
- continue
- }
- strings.write_string(&b, " candidates:\n")
- for line in candidates {
- fmt.sbprintf(&b, " %s\n", line)
- }
- strings.write_string(&b, "\n")
- }
- return strings.to_string(b)
-}
-
-// twins renders the declarations whose value already exists, in the
-// order the change declares them.
-twins :: proc(c: ^change.Change, allocator := context.allocator) -> string {
- b := strings.builder_make(allocator)
- for s in c.symbols {
- lines := c.twins[s.name]
- if len(lines) == 0 {
- continue
- }
- fmt.sbprintf(&b, " %s:%d %s\n", s.file, s.line, s.signature)
- for line in lines {
- fmt.sbprintf(&b, " %s\n", strings.trim_suffix(line, " <- same value"))
- }
- }
- return strings.to_string(b)
-}
-
-tests_subject :: proc(c: ^change.Change, allocator: runtime.Allocator) -> string {
- if len(c.tests) == 0 {
- return ""
- }
- b := strings.builder_make(allocator)
- strings.write_string(&b, "Test functions this change adds or alters:\n\n")
- for t in c.tests {
- fmt.sbprintf(&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.sbprintf(&b, " (skips itself at line %d)", t.skips)
- }
- fmt.sbprintf(&b, "\n%s\n\n", t.body)
- }
- if called := functions_under_test(c, context.temp_allocator); called != "" {
- strings.write_string(
- &b,
- "Functions the tests call, as they stand at the end of the change. A test that\n",
- )
- strings.write_string(
- &b,
- "would pass with one of these returning its input or a zero value is the finding.\n\n",
- )
- strings.write_string(&b, called)
- }
- return strings.to_string(b)
-}
-
-// 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.
-called_functions :: 8
-called_lines :: 60
-
-// functions_under_test 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.
-// A helper a test file declares is not the code under test.
-functions_under_test :: proc(c: ^change.Change, allocator := context.allocator) -> string {
- if len(c.index) == 0 {
- return ""
- }
- declared := make(map[string]change.Declared, context.temp_allocator)
- for d in c.index {
- if d.kind == "func" && d.body != "" && !check.is_test_file(d.file) {
- if d.name not_in declared {
- declared[d.name] = d
- }
- }
- }
- seen := make(map[string]bool, context.temp_allocator)
- shown := make([dynamic]change.Declared, context.temp_allocator)
- outer: for t in c.tests {
- for name in calls(t.body, context.temp_allocator) {
- d, ok := declared[name]
- if !ok || seen[name] || name == t.name {
- continue
- }
- seen[name] = true
- append(&shown, d)
- if len(shown) == called_functions {
- break outer
- }
- }
- }
- if len(shown) == 0 {
- return ""
- }
- b := strings.builder_make(allocator)
- for d in shown {
- body := d.body
- lines := strings.split_lines(body, context.temp_allocator)
- if len(lines) > called_lines {
- body = fmt.tprintf(
- "%s\n\t… cut at %d lines",
- strings.join(lines[:called_lines], "\n", context.temp_allocator),
- called_lines,
- )
- }
- fmt.sbprintf(&b, "--- %s:%d %s\n%s\n\n", d.file, d.line, d.name, body)
- }
- return strings.to_string(b)
-}
-
-// calls are the names a body calls, in order, which is how a test names
-// what it tests.
-calls :: proc(body: string, allocator := context.allocator) -> []string {
- out := make([dynamic]string, allocator)
- it, err := regex.create_iterator(
- body,
- `\b([A-Za-z_][A-Za-z0-9_]*)\(`,
- {},
- context.temp_allocator,
- )
- if err != nil {
- return out[:]
- }
- defer regex.destroy_iterator(it, context.temp_allocator)
- for {
- cap, _, ok := regex.match_iterator(&it)
- if !ok {
- break
- }
- append(&out, strings.trim_suffix(cap.groups[0], "("))
- }
- return out[:]
-}
-
-claims_subject :: proc(c: ^change.Change, allocator: runtime.Allocator) -> string {
- blocks := comment_blocks(c.comments[:], context.temp_allocator)
- if len(blocks) == 0 {
- return ""
- }
- b := strings.builder_make(allocator)
- strings.write_string(
- &b,
- "Comment and documentation lines this change adds, each with the code beneath it:\n\n",
- )
- for block in blocks {
- for comment in block {
- fmt.sbprintf(&b, "%s:%d %s\n", comment.file, comment.line, comment.text)
- }
- if below := block[len(block) - 1].below; below != "" {
- rest := below
- for line in strings.split_lines_iterator(&rest) {
- fmt.sbprintf(&b, " code: %s\n", line)
- }
- }
- strings.write_string(&b, "\n")
- }
- return strings.to_string(b)
-}
-
-// comment_blocks 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.
-comment_blocks :: proc(
- comments: []change.Located,
- allocator := context.allocator,
-) -> [][]change.Located {
- blocks := make([dynamic][]change.Located, allocator)
- current := make([dynamic]change.Located, allocator)
- for comment in comments {
- if check.restates(comment) {
- continue
- }
- if len(current) > 0 {
- last := current[len(current) - 1]
- if !(last.file == comment.file && last.line + 1 == comment.line) {
- append(&blocks, current[:])
- current = make([dynamic]change.Located, allocator)
- }
- }
- append(¤t, comment)
- }
- if len(current) > 0 {
- append(&blocks, current[:])
- }
- return blocks[:]
-}
-
-hygiene_subject :: proc(c: ^change.Change, allocator: runtime.Allocator) -> string {
- if strings.trim_space(c.message) == "" {
- return ""
- }
- b := strings.builder_make(allocator)
- strings.write_string(&b, "Commit message:\n\n")
- strings.write_string(&b, c.message)
- strings.write_string(&b, "\n\nFiles changed:\n")
- strings.write_string(&b, c.stat)
- if len(c.convention) > 0 {
- strings.write_string(
- &b,
- "\nRecent subjects in this repository, as the local convention:\n",
- )
- for subject in c.convention {
- fmt.sbprintf(&b, " %s\n", subject)
- }
- }
- return strings.to_string(b)
-}
-
-// Reported is the shape a job answers in.
-Reported :: struct {
- findings: []struct {
- rule: string `json:"rule"`,
- severity: string `json:"severity"`,
- file: string `json:"file"`,
- line: int `json:"line"`,
- symbol: string `json:"symbol"`,
- message: string `json:"message"`,
- fix: string `json:"fix"`,
- } `json:"findings"`,
-}
-
-// decode reads a job's answer. A finding that cites no rule from the
-// criteria is dropped: the criteria are what gets tuned, so a job may not
-// invent one.
-decode :: proc(
- raw: string,
- name: string,
- allowed: map[string]bool,
- allocator := context.allocator,
-) -> (
- out: []finding.Finding,
- ok: bool,
-) {
- r: Reported
- if json.unmarshal_string(raw, &r, allocator = context.temp_allocator) != nil {
- return nil, false
- }
- kept := make([dynamic]finding.Finding, allocator)
- for f in r.findings {
- if !allowed[f.rule] {
- continue
- }
- append(
- &kept,
- finding.Finding {
- job = strings.clone(name, allocator),
- rule = strings.clone(f.rule, allocator),
- severity = finding.parse_severity(f.severity),
- severity_name = strings.clone(f.severity, allocator),
- file = strings.clone(f.file, allocator),
- line = f.line,
- symbol = strings.clone(f.symbol, allocator),
- message = strings.clone(f.message, allocator),
- fix = strings.clone(f.fix, allocator),
- },
- )
- }
- return kept[:], true
-}
-
-// readable is whether an answer holds a findings object at all.
-readable :: proc(text: string) -> bool {
- raw, found := txt.object(text)
- if !found {
- return false
- }
- r: Reported
- return json.unmarshal_string(raw, &r, allocator = context.temp_allocator) == nil
-}
-
-// packet_cap is the size of subject past which a splittable job is asked
-// in parts. Under the cap each part is an ask a slow gateway finishes,
-// and a part whose files did not change replays from the cache.
-packet_cap :: 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.
-parts :: proc(j: Job, c: ^change.Change, allocator := context.allocator) -> []^change.Change {
- out := make([dynamic]^change.Change, allocator)
- if !j.splittable || len(j.subject(c, context.temp_allocator)) <= packet_cap {
- append(&out, c)
- return out[:]
- }
- group := make([dynamic]string, context.temp_allocator)
- for file in c.files {
- if !contributes(c, file) {
- continue
- }
- if len(group) > 0 {
- trial := slice.clone(group[:], context.temp_allocator)
- with := make([dynamic]string, context.temp_allocator)
- append(&with, ..trial)
- append(&with, file)
- piece := part(c, with[:], context.temp_allocator)
- if len(j.subject(piece, context.temp_allocator)) > packet_cap {
- append(&out, part(c, group[:], allocator))
- clear(&group)
- }
- }
- append(&group, file)
- }
- if len(group) > 0 {
- append(&out, part(c, group[:], allocator))
- }
- return out[:]
-}
-
-// contributes is whether a file has anything a splittable job reads.
-contributes :: proc(c: ^change.Change, file: string) -> bool {
- for s in c.symbols {
- if s.file == file {
- return true
- }
- }
- for t in c.tests {
- if t.file == file {
- return true
- }
- }
- for comment in 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.
-part :: proc(
- c: ^change.Change,
- files: []string,
- allocator := context.allocator,
-) -> ^change.Change {
- keep := make(map[string]bool, context.temp_allocator)
- for f in files {
- keep[f] = true
- }
- p := new(change.Change, allocator)
- p^ = c^
- p.files = slice.clone(files, allocator)
- p.symbols = make([dynamic]change.Symbol, allocator)
- p.tests = make([dynamic]change.Function, allocator)
- p.comments = make([dynamic]change.Located, allocator)
- for s in c.symbols {
- if keep[s.file] {
- append(&p.symbols, s)
- }
- }
- for t in c.tests {
- if keep[t.file] {
- append(&p.tests, t)
- }
- }
- for comment in c.comments {
- if keep[comment.file] {
- append(&p.comments, comment)
- }
- }
- return p
-}
diff --git a/odin/odinfmt.json b/odin/odinfmt.json
@@ -1,8 +0,0 @@
-{
- "$schema": "https://raw.githubusercontent.com/DanielGavin/ols/master/misc/odinfmt.schema.json",
- "character_width": 100,
- "tabs": true,
- "tabs_width": 4,
- "newline_style": "LF",
- "align_constant_definitions": true
-}
diff --git a/odinfrontend.go b/odinfrontend.go
@@ -1,200 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
-)
-
-// OdinFrontend reads Odin through the odin-review-extract sidecar, a small
-// program built on Odin's own parser (core:odin). Declaring a review in terms
-// of the language's grammar is the point; the sidecar ships in this
-// repository and is found on the path.
-type OdinFrontend struct{}
-
-func (OdinFrontend) Name() string { return "odin" }
-
-func (OdinFrontend) Covers(path string) bool { return strings.HasSuffix(path, ".odin") }
-
-func (OdinFrontend) Features() Features {
- return FeatSymbols | FeatTests | FeatComments | FeatIndex
-}
-
-// odinDecl is one declaration as the sidecar reports it.
-type odinDecl struct {
- Name string `json:"name"`
- Kind string `json:"kind"`
- Line int `json:"line"`
- EndLine int `json:"end_line"`
- Exported bool `json:"exported"`
- Test bool `json:"test"`
- Text string `json:"text"`
- Doc string `json:"doc"`
-}
-
-type odinFile struct {
- Name string `json:"name"`
- Decls []odinDecl `json:"decls"`
-}
-
-type odinOutput struct {
- Files []odinFile `json:"files"`
-}
-
-// odinScan materialises the files at the revision into a scratch directory,
-// asks the sidecar for their declarations, and maps the answers back to the
-// names given. A missing sidecar is the caller's problem: frontends() only
-// offers this one when it is on the path.
-func odinScan(files []string, root, rev string) ([]odinDecl, map[string][]odinDecl, error) {
- if len(files) == 0 {
- return nil, nil, nil
- }
- dir, err := os.MkdirTemp("", "review-odin")
- if err != nil {
- return nil, nil, err
- }
- defer os.RemoveAll(dir)
-
- var args []string
- for i, name := range files {
- source, err := at(root, rev, name)
- if err != nil {
- continue // Deleted by the change, so there is nothing to read.
- }
- scratch := filepath.Join(dir, fmt.Sprintf("%04d%s", i, filepath.Ext(name)))
- if err := os.WriteFile(scratch, source, 0o644); err != nil {
- return nil, nil, err
- }
- args = append(args, scratch)
- }
- cmd := exec.Command("odin-review-extract", args...)
- stdout, err := cmd.Output()
- if err != nil && len(stdout) == 0 {
- return nil, nil, fmt.Errorf("odin-review-extract: %w", err)
- }
- var out odinOutput
- if err := json.Unmarshal(stdout, &out); err != nil {
- return nil, nil, fmt.Errorf("odin-review-extract: %w", err)
- }
- byFile := map[string][]odinDecl{}
- var all []odinDecl
- for _, f := range out.Files {
- base := strings.TrimSuffix(filepath.Base(f.Name), filepath.Ext(f.Name))
- index, err := fmt.Sscanf(base, "%04d", new(int))
- if err != nil || index == 0 {
- continue
- }
- var n int
- if _, err := fmt.Sscanf(base, "%d", &n); err != nil || n >= len(files) {
- continue
- }
- byFile[files[n]] = f.Decls
- all = append(all, f.Decls...)
- }
- return all, byFile, nil
-}
-
-// Change appends the declarations, tests and prose the added lines of the
-// change's Odin files introduce.
-func (g OdinFrontend) Change(root, rev string, c *Change, added map[string][]int) error {
- var covered []string
- for _, name := range c.Files {
- if g.Covers(name) {
- covered = append(covered, name)
- }
- }
- _, byFile, err := odinScan(covered, root, rev)
- if err != nil {
- return err
- }
- for _, name := range covered {
- touched := map[int]bool{}
- 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 {
- c.Tests = append(c.Tests, Function{
- Name: decl.Name, File: name, Line: decl.Line,
- Body: text(lines, decl.Line, end),
- })
- continue
- }
- symbol := Symbol{
- Name: decl.Name, Kind: decl.Kind, Doc: decl.Doc,
- File: name, Line: decl.Line,
- 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) {
- 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 g.Covers(name) {
- files = append(files, name)
- }
- }
- _, byFile, err := odinScan(files, root, rev)
- if err != nil {
- return nil, err
- }
- 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.
- }
- 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/odinfrontend_test.go b/odinfrontend_test.go
@@ -1,143 +0,0 @@
-package main
-
-import (
- "os/exec"
- "testing"
-)
-
-// The Odin tests need the sidecar; without it the frontend is not offered.
-func needSidecar(t *testing.T) {
- t.Helper()
- if _, err := exec.LookPath("odin-review-extract"); err != nil {
- t.Skip("odin-review-extract is not installed")
- }
-}
-
-func odinFixture(t *testing.T) *repo {
- t.Helper()
- r := newRepo(t)
- r.write("src/.keep", "")
- r.commit("odin: begin", "src/.keep")
- return r
-}
-
-func TestOdinReadsDeclarations(t *testing.T) {
- needSidecar(t)
- r := odinFixture(t)
- r.write("src/icons.odin", `package icons
-
-// Counts the icons a binary carries.
-count_icons :: proc(groups: []Group) -> int { return 0 }
-
-@(test)
-test_counts_icons :: proc(t: ^testing.T) {}
-
-Group :: struct {
- id: int,
-}
-
-@(private="file")
-helper :: proc() {}
-
-MAX_ICONS :: 12
-`)
- rev := r.commit("odin: count", "src/icons.odin")
-
- 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
- }{
- "count_icons": {"func", true},
- "Group": {"type", true},
- "helper": {"func", false},
- "MAX_ICONS": {"value", 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 s := got["count_icons"]; s.Doc != "Counts the icons a binary carries." {
- t.Errorf("doc %q", s.Doc)
- }
- if len(change.Tests) != 1 || change.Tests[0].Name != "test_counts_icons" {
- t.Fatalf("tests %v", change.Tests)
- }
- if len(change.Tests[0].Body) == 0 {
- t.Error("test body is empty")
- }
-}
-
-func TestOdinIndexesTheRepository(t *testing.T) {
- needSidecar(t)
- r := odinFixture(t)
- r.write("src/icons.odin", `package icons
-
-count_icons :: proc(groups: []Group) -> int { return 0 }
-
-@(test)
-test_counts_icons :: proc(t: ^testing.T) {}
-
-Group :: struct {
- id: int,
-}
-`)
- rev := r.commit("odin: index", "src/icons.odin")
-
- declared, err := OdinFrontend{}.Whole(r.Root, rev)
- if err != nil {
- t.Fatal(err)
- }
- index := map[string]Declared{}
- for _, d := range declared {
- if _, dup := index[d.Name]; dup {
- t.Errorf("%s indexed twice", d.Name)
- }
- index[d.Name] = d
- }
- if d, ok := index["count_icons"]; !ok || d.Kind != "func" || d.Text == "" {
- t.Errorf("count_icons %v", d)
- }
- if d, ok := index["Group"]; !ok || d.Kind != "type" {
- t.Errorf("Group %v", d)
- }
- if _, ok := index["test_counts_icons"]; ok {
- t.Error("test procedure indexed")
- }
-}
-
-func TestOdinReadsComments(t *testing.T) {
- needSidecar(t)
- r := odinFixture(t)
- r.write("src/icons.odin", `package icons
-
-count_icons :: proc() {}
-`)
- r.commit("odin: empty", "src/icons.odin")
- r.write("src/icons.odin", `package icons
-
-// counts the icons a binary carries
-count_icons :: proc() {}
-`)
- r.stage("src/icons.odin")
-
- change, err := Gather("", r.Root)
- if err != nil {
- t.Fatal(err)
- }
- if len(change.Comments) != 1 || change.Comments[0].Text != "counts the icons a binary carries" {
- t.Fatalf("comments %v", change.Comments)
- }
-}
diff --git a/packet.go b/packet.go
@@ -1,594 +0,0 @@
-package main
-
-import (
- "cmp"
- "fmt"
- "os/exec"
- "path/filepath"
- "regexp"
- "slices"
- "strings"
-)
-
-// Change is everything the jobs are allowed to see, gathered once. Each job
-// takes the part it needs and no more: a job that reads less is both cheaper
-// and harder to distract.
-type Change struct {
- // Diff is the change itself, capped.
- Diff string
- // Files are the paths it touches.
- Files []string
- // 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.
- Stat string
- // Truncated is whether the diff was capped, which makes every count
- // under it a count of what was read rather than of what changed.
- Truncated bool
- // Uncovered is the part of the change the deterministic side could not
- // read, with a reason for each file. Jobs read what they can; what
- // they could not is said rather than missed.
- Uncovered []Gap
- // Symbols are the declarations the change adds or renames.
- Symbols []Symbol
- // Tests are the test functions it adds or changes, whole.
- Tests []Function
- // Comments are the comment and documentation lines it adds.
- Comments []Located
- // Candidates are existing names that resemble each new one, gathered by
- // search rather than by the model, which is what keeps the duplication
- // job cheap.
- Candidates map[string][]string
- // Twins are the existing declarations holding the same literal as a new
- // one. They are the shortest list worth reading, so they are kept apart
- // from the resemblances they would otherwise be buried in.
- Twins map[string][]string
- // Convention is the recent commit subjects, so a job can read the local
- // habit rather than impose one.
- Convention []string
- // History is the subjects of the repository's last thousand commits, the
- // word frequencies the commit message is measured against.
- History []string
- // Temporal is what the repository's history counts about the files the
- // 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.
-type Temporal struct {
- // Commits counts, over the counted commits, how many touch each file.
- Commits map[string]int
- // Partners lists, per changed file, the other files those commits also
- // touched, nearest first.
- Partners map[string][]Partner
-}
-
-// Partner is another file that history shows changing with a changed one.
-type Partner struct {
- Name string
- Shared int
-}
-
-// Symbol is a declaration the change introduces.
-type Symbol struct {
- Name string `json:"name"`
- Kind string `json:"kind"`
- Doc string `json:"doc,omitempty"`
- File string `json:"file"`
- Line int `json:"line"`
- Exported bool `json:"exported"`
- // 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
-// only be judged by what it asserts.
-type Function struct {
- Name string `json:"name"`
- 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.
-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"`
-}
-
-// 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
-// is made of.
-type Gap struct {
- File string `json:"file"`
- Reason string `json:"reason"`
-}
-
-// maxDiff caps what is sent. A change larger than this is reviewed by its
-// parts rather than badly as a whole.
-const maxDiff = 60000
-
-// 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) {
- // The prefixes are named rather than inherited: diff.mnemonicPrefix
- // rewrites them per side, and the diff is parsed by them below.
- var diffArgs, nameArgs, statArgs []string
- switch rev {
- case "":
- diffArgs = []string{"diff", "--cached", "-U3", "--src-prefix=a/", "--dst-prefix=b/"}
- nameArgs = []string{"diff", "--cached", "--name-only"}
- statArgs = []string{"diff", "--cached", "--stat"}
- default:
- diffArgs = []string{"diff", rev, "-U3", "--src-prefix=a/", "--dst-prefix=b/"}
- nameArgs = []string{"diff", rev, "--name-only"}
- statArgs = []string{"diff", rev, "--stat"}
- }
-
- 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
- }
- names, err := git(root, nameArgs...)
- if err != nil {
- return nil, err
- }
- for _, name := range strings.Split(strings.TrimSpace(names), "\n") {
- if name != "" {
- change.Files = append(change.Files, name)
- }
- }
- if change.Stat, err = git(root, statArgs...); err != nil {
- return nil, err
- }
- // 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 {
- for _, s := range strings.Split(strings.TrimSpace(subjects), "\n") {
- if s != "" {
- change.Convention = append(change.Convention, s)
- }
- }
- }
- if subjects, err := git(root, "log", "-1000", "--format=%s"); err == nil {
- for _, s := range strings.Split(strings.TrimSpace(subjects), "\n") {
- if s != "" {
- change.History = append(change.History, s)
- }
- }
- }
-
- 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
-// once says nothing about any pair, and leaving it out errs toward
-// silence.
-const (
- temporalWindow = 1000
- temporalWidth = 100
-
- // partnerList is the fewest-nearest partners kept per changed file, so
- // a file whose history touches everything does not carry the history.
- partnerList = 20
-)
-
-// readTemporal counts, over the last thousand commits before the change,
-// how often each changed file is touched by a commit that also touches
-// another file. The history starts at the commit the change begins at, so
-// the change under review is never counted against itself.
-func (c *Change) readTemporal(root, rev string) {
- if len(c.Files) == 0 {
- return
- }
- var args []string
- start := ""
- switch {
- case rev == "":
- case strings.Contains(rev, ".."):
- start = strings.SplitN(rev, "..", 2)[0]
- case strings.HasSuffix(rev, "^"):
- start = rev
- default:
- start = rev + "^"
- }
- args = []string{"log", fmt.Sprintf("-%d", temporalWindow), "--format=%x00", "--name-only"}
- if start != "" {
- args = append(args, start)
- }
- logs, err := git(root, args...)
- if err != nil {
- return // No history, or a bare first commit: nothing to count.
- }
- changed := map[string]bool{}
- for _, f := range c.Files {
- changed[f] = true
- }
- commits := map[string]int{}
- pairs := map[string]map[string]int{}
- for _, chunk := range strings.Split(logs, "\x00") {
- var files []string
- for _, line := range strings.Split(chunk, "\n") {
- if line = strings.TrimSpace(line); line != "" {
- files = append(files, line)
- }
- }
- if len(files) > temporalWidth {
- continue
- }
- for _, f := range files {
- commits[f]++
- }
- for _, f := range files {
- if !changed[f] {
- continue
- }
- if pairs[f] == nil {
- pairs[f] = map[string]int{}
- }
- for _, g := range files {
- if g != f {
- pairs[f][g]++
- }
- }
- }
- }
- remaining := c.remaining(root, rev, pairs)
- t := &Temporal{Commits: commits, Partners: map[string][]Partner{}}
- for f, ps := range pairs {
- type named struct {
- name string
- j float64
- }
- list := make([]named, 0, len(ps))
- for name, shared := range ps {
- union := commits[f] + commits[name] - shared
- if union <= 0 {
- continue
- }
- list = append(list, named{name, float64(shared) / float64(union)})
- }
- slices.SortFunc(list, func(a, b named) int {
- if c := cmp.Compare(b.j, a.j); c != 0 {
- return c
- }
- return cmp.Compare(a.name, b.name)
- })
- var partners []Partner
- for _, e := range list {
- if len(partners) == partnerList {
- break
- }
- if remaining[e.name] {
- partners = append(partners, Partner{Name: e.name, Shared: ps[e.name]})
- }
- }
- if len(partners) > 0 {
- t.Partners[f] = partners
- }
- }
- if len(t.Partners) > 0 {
- c.Temporal = t
- }
-}
-
-// 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.
-func (c *Change) remaining(root, rev string, pairs map[string]map[string]int) map[string]bool {
- out := map[string]bool{}
- tree, err := treeAt(root, rev)
- if err != nil {
- return out
- }
- 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.
-func (c *Change) findCandidates(root, rev string, frontends []Frontend) {
- var index []Declared
- for _, f := range frontends {
- if f.Features()&FeatIndex == 0 {
- continue
- }
- declared, err := f.Whole(root, rev)
- if err != nil {
- continue
- }
- 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 {
- var twins []string
- for _, declared := range index {
- if declared.File == symbol.File && declared.Line == symbol.Line {
- continue
- }
- if same(declared, Declared{Text: symbol.Signature}) {
- twins = append(twins, declared.String()+" <- same value")
- }
- }
- if len(twins) > 0 {
- c.Twins[symbol.Name] = twins
- }
- c.Candidates[symbol.Name] = slices.Concat(twins, Resembling(index, symbol, shortlist))
- }
-}
-
-var hunk = regexp.MustCompile(`^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@`)
-
-// diffLine is one added line and the number it lands on in the file the
-// change arrives at.
-type diffLine struct {
- Line int
- Text string
-}
-
-// diffSides splits a diff into the added and removed lines of each file,
-// with the added lines' text. The header lines are read wherever they sit,
-// as they sit between hunks; the hunk's own content is read only inside a
-// hunk, so that a removed line of content beginning "+++ " is not mistaken
-// for the header it resembles.
-func diffSides(diff string) (added map[string][]diffLine, removed map[string][]string) {
- added = map[string][]diffLine{}
- removed = map[string][]string{}
- var (
- addedFile, removedFile string
- line int
- inHunk bool
- )
- for _, text := range strings.Split(diff, "\n") {
- switch {
- case strings.HasPrefix(text, "+++ "):
- // A deletion names /dev/null, which owns no added lines.
- addedFile = side(text[4:], "b/")
- inHunk = false
- case strings.HasPrefix(text, "--- "):
- // The left side is not being read, but a removed line is
- // only a removed line inside a hunk, so the header is
- // where the removed file is named.
- removedFile = side(text[4:], "a/")
- inHunk = false
- case strings.HasPrefix(text, "\\ "):
- // The no-newline marker annotates the line above it rather than
- // standing for a line of its own.
- case hunk.MatchString(text):
- inHunk = true
- line = atoi(hunk.FindStringSubmatch(text)[1])
- case strings.HasPrefix(text, "+") && inHunk:
- if addedFile != "" {
- added[addedFile] = append(added[addedFile], diffLine{line, strings.TrimPrefix(text, "+")})
- }
- line++
- case strings.HasPrefix(text, "-") && inHunk:
- if removedFile != "" {
- removed[removedFile] = append(removed[removedFile], strings.TrimPrefix(text, "-"))
- }
- default:
- if inHunk {
- line++
- }
- }
- }
- return added, removed
-}
-
-// side strips a diff's prefix from a header path, and a deletion's
-// /dev/null with it.
-func side(path, prefix string) string {
- path = strings.TrimPrefix(path, prefix)
- if path == "/dev/null" {
- return ""
- }
- return path
-}
-
-// addedLines reports which lines of which files the diff adds, so that a
-// job is shown new work rather than whatever file it landed in.
-func addedLines(diff string) map[string][]int {
- added, _ := diffSides(diff)
- out := map[string][]int{}
- for file, lines := range added {
- for _, l := range lines {
- out[file] = append(out[file], l.Line)
- }
- }
- return out
-}
-
-func touchedBetween(lines []int, from, to int) bool {
- for _, line := range lines {
- if line >= from && line <= to {
- return true
- }
- }
- return false
-}
-
-func text(lines []string, from, to int) string {
- if from < 1 || to > len(lines) {
- return ""
- }
- return strings.Join(lines[from-1:to], "\n")
-}
-
-func relative(root, path string) string {
- if rel, err := filepath.Rel(root, path); err == nil {
- return rel
- }
- return path
-}
-
-func git(root string, args ...string) (string, error) {
- cmd := exec.Command("git", args...)
- cmd.Dir = root
- out, err := cmd.Output()
- if err != nil {
- return "", fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
- }
- 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
@@ -1,440 +0,0 @@
-package main
-
-import (
- "reflect"
- "strings"
- "testing"
-)
-
-func TestAddedLines(t *testing.T) {
- for _, test := range []struct {
- name string
- diff string
- want map[string][]int
- }{{
- name: "a new file is added whole",
- diff: `diff --git a/x.go b/x.go
-new file mode 100644
---- /dev/null
-+++ b/x.go
-@@ -0,0 +1,3 @@
-+package x
-+
-+const a = 1
-`,
- want: map[string][]int{"x.go": {1, 2, 3}},
- }, {
- name: "context lines advance the count and removals do not",
- diff: `--- a/x.go
-+++ b/x.go
-@@ -1,4 +1,4 @@
- package x
--const a = 1
-+const a = 2
-
- func f() {}
-`,
- want: map[string][]int{"x.go": {2}},
- }, {
- name: "a second hunk restarts at its own line",
- diff: `--- a/x.go
-+++ b/x.go
-@@ -1,2 +1,3 @@
- package x
-+const a = 1
-
-@@ -20,2 +21,3 @@
- func f() {}
-+func g() {}
-
-`,
- want: map[string][]int{"x.go": {2, 22}},
- }, {
- name: "each file keeps its own lines",
- diff: `--- a/x.go
-+++ b/x.go
-@@ -0,0 +1 @@
-+package x
---- a/y.go
-+++ b/y.go
-@@ -0,0 +1 @@
-+package y
-`,
- want: map[string][]int{"x.go": {1}, "y.go": {1}},
- }, {
- name: "a hunk header without counts still gives the line",
- diff: `--- a/x.go
-+++ b/x.go
-@@ -7 +7 @@
--const a = 1
-+const a = 2
-`,
- want: map[string][]int{"x.go": {7}},
- }, {
- name: "a diff touching nothing adds nothing",
- diff: ``,
- want: map[string][]int{},
- }} {
- t.Run(test.name, func(t *testing.T) {
- got := addedLines(test.diff)
- if !reflect.DeepEqual(got, test.want) {
- t.Errorf("got %v, want %v", got, test.want)
- }
- })
- }
-}
-
-// The no-newline marker sits between the two sides of a hunk, so counting it
-// as a line puts every added line after it one too far down.
-func TestAddedLinesIgnoresTheNoNewlineMarker(t *testing.T) {
- diff := `--- a/f.txt
-+++ b/f.txt
-@@ -1,2 +1,3 @@
- a
--b
-\ No newline at end of file
-+B
-+c
-`
- want := map[string][]int{"f.txt": {2, 3}}
- if got := addedLines(diff); !reflect.DeepEqual(got, want) {
- t.Errorf("got %v, want %v", got, want)
- }
-}
-
-// A deletion writes +++ /dev/null, which must not be read as a line added to
-// whichever file came before it.
-func TestAddedLinesIgnoresADeletedFile(t *testing.T) {
- diff := `--- a/x.go
-+++ b/x.go
-@@ -1,1 +1,2 @@
- package x
-+const a = 1
-diff --git a/y.go b/y.go
-deleted file mode 100644
---- a/y.go
-+++ /dev/null
-@@ -1,2 +0,0 @@
--package y
--const b = 2
-`
- want := map[string][]int{"x.go": {2}}
- if got := addedLines(diff); !reflect.DeepEqual(got, want) {
- t.Errorf("got %v, want %v", got, want)
- }
-}
-
-func TestTouchedBetween(t *testing.T) {
- lines := []int{4, 9}
- for _, test := range []struct {
- from, to int
- want bool
- }{
- {1, 3, false},
- {1, 4, true},
- {4, 4, true},
- {5, 8, false},
- {5, 20, true},
- {10, 20, false},
- } {
- if got := touchedBetween(lines, test.from, test.to); got != test.want {
- t.Errorf("touchedBetween(%v, %d, %d) = %v, want %v", lines, test.from, test.to, got, test.want)
- }
- }
- if touchedBetween(nil, 1, 100) {
- t.Error("nothing touched should report false")
- }
-}
-
-func TestText(t *testing.T) {
- lines := []string{"one", "two", "three"}
- if got := text(lines, 1, 2); got != "one\ntwo" {
- t.Errorf("got %q", got)
- }
- if got := text(lines, 2, 2); got != "two" {
- t.Errorf("got %q", got)
- }
- // Out of range asks for text that is not there, which is empty rather
- // than a panic.
- if got := text(lines, 0, 2); got != "" {
- t.Errorf("got %q, want empty", got)
- }
- if got := text(lines, 1, 4); got != "" {
- t.Errorf("got %q, want empty", got)
- }
-}
-
-func TestAtReadsTheRevisionUnderReview(t *testing.T) {
- r := newRepo(t)
- r.write("x.go", "package x\n\nconst a = 1\n")
- first := r.commit("first", "x.go")
- r.write("x.go", "package x\n\nconst a = 2\n")
- second := r.commit("second", "x.go")
- r.write("x.go", "package x\n\nconst a = 3\n")
-
- for _, test := range []struct {
- rev string
- want string
- }{
- {"", "const a = 3"}, // The working tree.
- {first + ".." + second, "const a = 2"}, // A range is read at its end.
- {first + "..." + second, "const a = 2"}, // So is a symmetric range.
- {first, "const a = 3"}, // A bare revision diffs to now.
- {second, "const a = 3"},
- } {
- got, err := at(r.Root, test.rev, "x.go")
- if err != nil {
- t.Fatalf("at(%q): %v", test.rev, err)
- }
- if !strings.Contains(string(got), test.want) {
- t.Errorf("at(%q) = %q, want it to contain %q", test.rev, got, test.want)
- }
- }
-}
-
-func TestAtReportsAMissingFile(t *testing.T) {
- r := newRepo(t)
- r.write("x.go", "package x\n")
- first := r.commit("first", "x.go")
- if _, err := at(r.Root, first+".."+first, "nowhere.go"); err == nil {
- t.Error("reading a file that is not in the revision should fail")
- }
-}
-
-// Gather with no revision reads the staged change, which is the default the
-// command documents and the one a commit hook would use.
-func TestGatherStaged(t *testing.T) {
- r := newRepo(t)
- r.write("x.go", "package x\n")
- r.commit("first", "x.go")
-
- r.write("x.go", `package x
-
-// Size is how wide a row is.
-const Size = 6
-
-// Assemble builds a thing.
-func Assemble() {}
-`)
- r.write("x_test.go", `package x
-
-import "testing"
-
-func TestAssemble(t *testing.T) {
- Assemble()
-}
-`)
- r.stage("x.go", "x_test.go")
-
- change, err := Gather("", r.Root)
- if err != nil {
- t.Fatal(err)
- }
- if len(change.Files) != 2 {
- t.Fatalf("files: got %v, want two", change.Files)
- }
- names := map[string]string{}
- for _, s := range change.Symbols {
- names[s.Name] = s.Kind
- }
- if names["Size"] != "value" {
- t.Errorf("Size: got kind %q, want value; symbols were %v", names["Size"], names)
- }
- if names["Assemble"] != "func" {
- t.Errorf("Assemble: got kind %q, want func; symbols were %v", names["Assemble"], names)
- }
- if len(change.Tests) != 1 || change.Tests[0].Name != "TestAssemble" {
- t.Errorf("tests: got %v, want TestAssemble", change.Tests)
- }
- if !strings.Contains(change.Tests[0].Body, "Assemble()") {
- t.Errorf("a test is carried whole, got %q", change.Tests[0].Body)
- }
- var comments []string
- for _, c := range change.Comments {
- comments = append(comments, c.Text)
- }
- if len(comments) != 2 {
- t.Errorf("comments: got %v, want both doc lines", comments)
- }
- if strings.TrimSpace(change.Stat) == "" {
- t.Error("the statistics are empty")
- }
-}
-
-// Reviewing a range reads the code that range left behind, not whatever the
-// working tree holds now.
-func TestGatherRangeIgnoresTheWorkingTree(t *testing.T) {
- r := newRepo(t)
- r.write("x.go", "package x\n")
- first := r.commit("first", "x.go")
- r.write("x.go", "package x\n\nconst Size = 6\n")
- second := r.commit("second", "x.go")
- // Work that landed after the revision under review.
- r.write("x.go", "package x\n\nconst Size = 6\n\nconst Later = 9\n")
- r.commit("third", "x.go")
-
- change, err := Gather(first+".."+second, r.Root)
- if err != nil {
- t.Fatal(err)
- }
- for _, s := range change.Symbols {
- if s.Name == "Later" {
- t.Fatalf("read a symbol added after the revision under review: %v", change.Symbols)
- }
- }
- if len(change.Symbols) != 1 || change.Symbols[0].Name != "Size" {
- t.Fatalf("symbols: got %v, want only Size", change.Symbols)
- }
-}
-
-// The shortlist is what makes the duplication job cheap, so a constant
-// holding the same literal as an existing one has to reach it.
-func TestGatherFindsTheTwinConstant(t *testing.T) {
- r := newRepo(t)
- r.write("ico/writer.go", "package ico\n\nconst (\n\tdirectorySize = 6\n)\n")
- first := r.commit("first", "ico/writer.go")
- r.write("exe/exe.go", "package exe\n\nconst (\n\tgroupHeaderSize = 6\n)\n")
- second := r.commit("second", "exe/exe.go")
-
- change, err := Gather(first+".."+second, r.Root)
- if err != nil {
- t.Fatal(err)
- }
- candidates := strings.Join(change.Candidates["groupHeaderSize"], "\n")
- if !strings.Contains(candidates, "directorySize = 6") {
- t.Errorf("the twin constant is missing from the shortlist:\n%s", candidates)
- }
- if !strings.Contains(candidates, "same value") {
- t.Errorf("the twin is not marked as holding the same value:\n%s", candidates)
- }
-}
-
-func TestGatherEmptyChange(t *testing.T) {
- r := newRepo(t)
- r.write("x.go", "package x\n")
- r.commit("first", "x.go")
-
- change, err := Gather("", r.Root)
- if err != nil {
- t.Fatal(err)
- }
- if strings.TrimSpace(change.Diff) != "" {
- t.Errorf("nothing is staged, so the diff should be empty, got %q", change.Diff)
- }
-}
-
-// A loop rewritten inside a function body is work on what the function does.
-// Reading it as a new name is what made a purely mechanical commit report a
-// dozen names it never introduced.
-func TestGatherIgnoresABodyChange(t *testing.T) {
- r := newRepo(t)
- r.write("x.go", `package x
-
-func existing() int {
- total := 0
- for i := 0; i < 3; i++ {
- total += i
- }
- return total
-}
-`)
- first := r.commit("first", "x.go")
- r.write("x.go", `package x
-
-func existing() int {
- total := 0
- for i := range 3 {
- total += i
- }
- return total
-}
-
-func added() int { return 1 }
-`)
- second := r.commit("second", "x.go")
-
- change, err := Gather(first+".."+second, r.Root)
- if err != nil {
- t.Fatal(err)
- }
- var names []string
- for _, s := range change.Symbols {
- names = append(names, s.Name)
- }
- if len(names) != 1 || names[0] != "added" {
- t.Errorf("got %v, want only the declaration the change adds", names)
- }
-}
-
-// A test is judged by what it asserts, so a change anywhere inside one is a
-// change to the test.
-func TestGatherReadsATestWhoseBodyChanged(t *testing.T) {
- r := newRepo(t)
- r.write("x_test.go", "package x\n\nimport \"testing\"\n\nfunc TestX(t *testing.T) {\n\tt.Log(\"one\")\n}\n")
- first := r.commit("first", "x_test.go")
- r.write("x_test.go", "package x\n\nimport \"testing\"\n\nfunc TestX(t *testing.T) {\n\tt.Skip(\"two\")\n}\n")
- second := r.commit("second", "x_test.go")
-
- change, err := Gather(first+".."+second, r.Root)
- if err != nil {
- t.Fatal(err)
- }
- if len(change.Tests) != 1 || change.Tests[0].Name != "TestX" {
- t.Fatalf("got %v, want TestX", change.Tests)
- }
- if !strings.Contains(change.Tests[0].Body, "t.Skip") {
- t.Errorf("the test is carried whole, got %q", change.Tests[0].Body)
- }
-}
-
-func TestIsTest(t *testing.T) {
- for name, want := range map[string]bool{
- "TestX": true, "FuzzX": true, "BenchmarkX": true,
- "Assemble": false, "testHelper": false, "Tested": true,
- } {
- if got := isTest(name); got != want {
- t.Errorf("isTest(%q) = %v, want %v", name, got, want)
- }
- }
-}
-
-// 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
@@ -1,72 +0,0 @@
-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
@@ -1,49 +0,0 @@
-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/provider.go b/provider.go
@@ -1,284 +0,0 @@
-package main
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "os"
- "os/exec"
- "strings"
-)
-
-// Provider answers a question. What answers it is not this tool's business:
-// a console API, a coding assistant on the path, or anything else that takes
-// a prompt and returns text.
-type Provider interface {
- // Name is how it is asked for.
- Name() string
- // Ask puts one question and returns what came back, as text. Findings are
- // read out of that text by the caller, so every provider answers the same
- // way whether or not it can enforce a schema.
- Ask(ctx context.Context, system, user string) (Answer, error)
-}
-
-// Answer is what a provider returned, with whatever it could say about the
-// cost. A provider that reports no usage leaves the counts at zero rather
-// than inventing them. Replayed marks an answer the cache remembered rather
-// than asked for: it cost nothing this run, so its counts stay at zero.
-type Answer struct {
- Text string
- In int
- Out int
- Cached int
- Cost float64
- Replayed bool
-}
-
-// Providers returns the ones built in, by name.
-//
-// The default model is the middle one rather than the smallest. Measured
-// against the eval set, the smallest reads the shortlist and reports the
-// first duplicate it finds rather than all of them, which is the one thing
-// this tool is for.
-func Providers() map[string]func(model string) Provider {
- return map[string]func(string) Provider{
- "claude": func(model string) Provider { return ClaudeCode{Model: orElse(model, "sonnet")} },
- "pi": func(model string) Provider { return Pi{Model: model, Upstream: os.Getenv("REVIEW_PI_PROVIDER")} },
- "api": func(model string) Provider { return API{Model: orElse(model, "claude-sonnet-5")} },
- "command": func(model string) Provider {
- return Command{Argv: strings.Fields(os.Getenv("REVIEW_COMMAND")), Field: os.Getenv("REVIEW_COMMAND_FIELD")}
- },
- }
-}
-
-func orElse(value, fallback string) string {
- if value == "" {
- return fallback
- }
- return value
-}
-
-// ClaudeCode asks the coding assistant on the path, in its non-interactive
-// mode. It uses whatever credentials that assistant already holds, which is
-// why it works where no API key is set.
-type ClaudeCode struct{ Model string }
-
-// Name carries the model, so that two asks through differently pointed
-// providers are never confused — the answer cache keys on it.
-func (c ClaudeCode) Name() string { return "claude/" + c.Model }
-
-func (c ClaudeCode) Ask(ctx context.Context, system, user string) (Answer, error) {
- // The tools are refused rather than left to judgement: these jobs are
- // given everything they may read, and a reader that goes looking for more
- // is answering a different question from the one asked.
- out, err := shell(ctx, "claude",
- []string{
- "-p", "--model", c.Model, "--output-format", "json",
- "--disallowedTools", "Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch,Task,NotebookEdit",
- "--append-system-prompt", system,
- }, user)
- if err != nil {
- return Answer{}, err
- }
- var envelope struct {
- Result string `json:"result"`
- IsError bool `json:"is_error"`
- Cost float64 `json:"total_cost_usd"`
- Usage struct {
- In int `json:"input_tokens"`
- Out int `json:"output_tokens"`
- Cached int `json:"cache_read_input_tokens"`
- } `json:"usage"`
- }
- if err := json.Unmarshal(out, &envelope); err != nil {
- return Answer{}, fmt.Errorf("reading the answer: %w", err)
- }
- if envelope.IsError {
- return Answer{}, fmt.Errorf("%s", strings.TrimSpace(envelope.Result))
- }
- return Answer{
- Text: envelope.Result, In: envelope.Usage.In, Out: envelope.Usage.Out,
- Cached: envelope.Usage.Cached, Cost: envelope.Cost,
- }, nil
-}
-
-// Pi asks the assistant of the same name, which speaks to several providers
-// of its own. The upstream provider is named through the environment rather
-// than here, since which one is reachable is a property of the machine. Its
-// answer arrives as a stream of events, not as one envelope.
-type Pi struct{ Model, Upstream string }
-
-// Name carries the upstream and the model, for the same reason.
-func (p Pi) Name() string {
- name := "pi"
- if p.Upstream != "" {
- name += "/" + p.Upstream
- }
- if p.Model != "" {
- name += "/" + p.Model
- }
- return name
-}
-
-func (p Pi) Ask(ctx context.Context, system, user string) (Answer, error) {
- // The tools are refused for the same reason as the other assistant: a
- // reader that goes looking for more is answering a different question.
- args := []string{"-p", "--mode", "json", "--no-session", "--no-tools", "--system-prompt", system}
- if p.Upstream != "" {
- args = append(args, "--provider", p.Upstream)
- }
- if p.Model != "" {
- args = append(args, "--model", p.Model)
- }
- out, err := shell(ctx, "pi", args, user)
- if err != nil {
- return Answer{}, err
- }
- return spoken(out)
-}
-
-// spoken reads pi's answer out of its event stream, which is one JSON object
-// per line rather than one envelope. The last assistant message is the
-// answer; everything before it is the working.
-func spoken(out []byte) (Answer, error) {
- var (
- answer Answer
- said bool
- prose []string
- )
- for _, line := range strings.Split(string(out), "\n") {
- line = strings.TrimSpace(line)
- if line == "" {
- continue
- }
- var event struct {
- Type string `json:"type"`
- Message struct {
- Role string `json:"role"`
- Content []struct {
- Type string `json:"type"`
- Text string `json:"text"`
- } `json:"content"`
- Usage struct {
- Input int `json:"input"`
- Output int `json:"output"`
- CacheRead int `json:"cacheRead"`
- Cost struct {
- Total float64 `json:"total"`
- } `json:"cost"`
- } `json:"usage"`
- StopReason string `json:"stopReason"`
- ErrorMessage string `json:"errorMessage"`
- } `json:"message"`
- }
- if err := json.Unmarshal([]byte(line), &event); err != nil {
- // Whatever pi says outside the stream is the only clue to a
- // refusal, so it is kept for the error.
- prose = append(prose, line)
- continue
- }
- if event.Type != "message_end" || event.Message.Role != "assistant" {
- continue
- }
- if event.Message.StopReason == "error" {
- return Answer{}, fmt.Errorf("%s", orElse(event.Message.ErrorMessage, "the model stopped with an error"))
- }
- var text strings.Builder
- for _, block := range event.Message.Content {
- if block.Type == "text" {
- text.WriteString(block.Text)
- }
- }
- usage := event.Message.Usage
- answer = Answer{
- Text: text.String(), In: usage.Input, Out: usage.Output,
- Cached: usage.CacheRead, Cost: usage.Cost.Total,
- }
- said = true
- }
- if !said {
- return Answer{}, fmt.Errorf("%s", orElse(first(strings.Join(prose, " "), 200), "no assistant message in the answer"))
- }
- return answer, nil
-}
-
-// Command asks whatever the environment names, for a provider this tool has
-// never heard of. REVIEW_COMMAND is the command line; REVIEW_COMMAND_FIELD is
-// the JSON field its answer arrives in, where it answers in JSON at all.
-type Command struct {
- Argv []string
- Field string
-}
-
-// Name carries the command line, because two commands under the same name
-// are different providers as far as the cache is concerned.
-func (c Command) Name() string {
- if len(c.Argv) == 0 {
- return "command"
- }
- return "command: " + strings.Join(c.Argv, " ")
-}
-
-func (c Command) Ask(ctx context.Context, system, user string) (Answer, error) {
- if len(c.Argv) == 0 {
- return Answer{}, fmt.Errorf("REVIEW_COMMAND names no command")
- }
- out, err := shell(ctx, c.Argv[0], c.Argv[1:], system+"\n\n"+user)
- if err != nil {
- return Answer{}, err
- }
- if c.Field == "" {
- return Answer{Text: string(out)}, nil
- }
- return Answer{Text: field(out, c.Field)}, nil
-}
-
-// shell works a command with the prompt on its input, which every assistant
-// here accepts and which keeps a long prompt out of the argument list.
-func shell(ctx context.Context, name string, args []string, prompt string) ([]byte, error) {
- cmd := exec.CommandContext(ctx, name, args...)
- cmd.Stdin = strings.NewReader(prompt)
- var stderr bytes.Buffer
- cmd.Stderr = &stderr
- out, err := cmd.Output()
- if err != nil {
- detail := strings.TrimSpace(stderr.String())
- if detail == "" {
- detail = strings.TrimSpace(string(out))
- }
- if len(detail) > 400 {
- detail = detail[:400]
- }
- return nil, fmt.Errorf("%s: %w: %s", name, err, detail)
- }
- return out, nil
-}
-
-// field pulls the answer out of a JSON envelope, taking the first of the
-// names that holds a string. An answer that is not JSON at all is returned
-// whole, since plenty of commands simply print what they were asked for.
-func field(out []byte, names ...string) string {
- var envelope map[string]any
- if err := json.Unmarshal(out, &envelope); err != nil {
- return string(out)
- }
- for _, name := range names {
- if text, ok := envelope[name].(string); ok && text != "" {
- return text
- }
- }
- return string(out)
-}
-
-// object finds the JSON object in an answer. Only a provider that can enforce
-// a schema returns bare JSON; the rest wrap it in whatever they were minded
-// to say, so the object is taken from within the text.
-func object(text string) (string, error) {
- start := strings.Index(text, "{")
- end := strings.LastIndex(text, "}")
- if start < 0 || end < start {
- return "", fmt.Errorf("no findings object in the answer")
- }
- return text[start : end+1], nil
-}
diff --git a/odin/provider/api.odin b/provider/api.odin
diff --git a/odin/provider/chain.odin b/provider/chain.odin
diff --git a/odin/provider/provider.odin b/provider/provider.odin
diff --git a/odin/provider/provider_test.odin b/provider/provider_test.odin
diff --git a/provider_test.go b/provider_test.go
@@ -1,363 +0,0 @@
-package main
-
-import (
- "context"
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-func TestObject(t *testing.T) {
- for _, test := range []struct {
- name string
- answer string
- want string
- }{{
- name: "bare json is returned whole",
- answer: `{"findings":[]}`,
- want: `{"findings":[]}`,
- }, {
- name: "a fenced answer gives up its object",
- answer: "Here is what I found:\n```json\n{\"findings\":[{\"rule\":\"cannot-fail\"}]}\n```\nHope that helps.",
- want: `{"findings":[{"rule":"cannot-fail"}]}`,
- }, {
- name: "prose on both sides is stripped",
- answer: "I reviewed it. {\"findings\":[]} Let me know.",
- want: `{"findings":[]}`,
- }, {
- name: "nested braces survive",
- answer: `prefix {"findings":[{"rule":"a"},{"rule":"b"}]} suffix`,
- want: `{"findings":[{"rule":"a"},{"rule":"b"}]}`,
- }} {
- t.Run(test.name, func(t *testing.T) {
- got, err := object(test.answer)
- if err != nil {
- t.Fatal(err)
- }
- if got != test.want {
- t.Errorf("got %q, want %q", got, test.want)
- }
- })
- }
-}
-
-func TestObjectWithNothingToFind(t *testing.T) {
- for _, answer := range []string{"", "I could not answer that.", "}{"} {
- if _, err := object(answer); err == nil {
- t.Errorf("object(%q) should fail", answer)
- }
- }
-}
-
-func TestField(t *testing.T) {
- // The first name holding a string wins.
- got := field([]byte(`{"text":"","result":"answer","content":"other"}`), "result", "text", "content")
- if got != "answer" {
- t.Errorf("got %q", got)
- }
- // An empty value is passed over, which is what lets one set of names
- // serve several envelopes.
- got = field([]byte(`{"result":"","text":"answer"}`), "result", "text")
- if got != "answer" {
- t.Errorf("got %q", got)
- }
- // Plenty of commands simply print what they were asked for.
- if got := field([]byte("just text"), "result"); got != "just text" {
- t.Errorf("got %q", got)
- }
- // JSON that holds none of the names is returned whole rather than lost.
- raw := `{"other":"answer"}`
- if got := field([]byte(raw), "result"); got != raw {
- t.Errorf("got %q", got)
- }
-}
-
-func TestProvidersAreNamed(t *testing.T) {
- providers := Providers()
- if len(providers) == 0 {
- t.Fatal("Providers() returned no providers")
- }
- for name, build := range providers {
- // A name is the start of what the provider calls itself: the model
- // is carried as well, because the answer cache keys on the pair.
- if got := build("").Name(); !strings.HasPrefix(got, name) {
- t.Errorf("provider %q calls itself %q", name, got)
- }
- }
-}
-
-func TestNamesCarryTheModel(t *testing.T) {
- providers := Providers()
- if len(providers) == 0 {
- t.Fatal("Providers() returned no providers")
- }
- for name, build := range providers {
- got := build("probe-model").Name()
- // The command provider is named by its command line rather than its
- // model, which it does not take; its argv is the identity that two
- // commands under one name would collide on.
- if name == "command" {
- continue
- }
- // The model is carried after the provider's own name: a name that
- // echoed the model alone would not say which provider answered,
- // and two providers sharing a model would collide in the cache.
- if !strings.HasPrefix(got, name+"/") {
- t.Errorf("provider %q names itself %q", name, got)
- }
- if !strings.Contains(got, "probe-model") {
- t.Errorf("provider %q names itself %q without the model", name, got)
- }
- }
- argv := Command{Argv: []string{"my", "reviewer"}}.Name()
- if argv != "command: my reviewer" {
- t.Errorf("got %q", argv)
- }
-}
-
-func TestOrElse(t *testing.T) {
- if got := orElse("", "fallback"); got != "fallback" {
- t.Errorf("got %q", got)
- }
- if got := orElse("value", "fallback"); got != "value" {
- t.Errorf("got %q", got)
- }
-}
-
-// Command is the seam for a provider this tool has never heard of, so it is
-// exercised with a command that is certainly on the path.
-func TestCommandProvider(t *testing.T) {
- c := Command{Argv: []string{"sh", "-c", `cat > /dev/null; printf '{"findings":[]}'`}}
- answer, err := c.Ask(context.Background(), "system", "user")
- if err != nil {
- t.Fatal(err)
- }
- if answer.Text != `{"findings":[]}` {
- t.Errorf("got %q", answer.Text)
- }
-}
-
-// The prompt reaches the command on its input, which is what keeps a long
-// prompt out of the argument list.
-func TestCommandProviderIsGivenThePrompt(t *testing.T) {
- c := Command{Argv: []string{"cat"}}
- answer, err := c.Ask(context.Background(), "the criteria", "the change")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(answer.Text, "the criteria") || !strings.Contains(answer.Text, "the change") {
- t.Errorf("got %q", answer.Text)
- }
-}
-
-func TestCommandProviderReadsItsField(t *testing.T) {
- c := Command{
- Argv: []string{"sh", "-c", `cat > /dev/null; printf '{"answer":"{\\"findings\\":[]}"}'`},
- Field: "answer",
- }
- answer, err := c.Ask(context.Background(), "", "")
- if err != nil {
- t.Fatal(err)
- }
- if answer.Text != `{"findings":[]}` {
- t.Errorf("got %q", answer.Text)
- }
-}
-
-func TestCommandProviderWithoutACommand(t *testing.T) {
- if _, err := (Command{}).Ask(context.Background(), "", ""); err == nil {
- t.Error("a provider with no command should say so")
- }
-}
-
-// A failing command reports what it printed, since that is the only clue to
-// why it failed.
-func TestCommandProviderReportsFailure(t *testing.T) {
- c := Command{Argv: []string{"sh", "-c", "echo not logged in >&2; exit 1"}}
- _, err := c.Ask(context.Background(), "", "")
- if err == nil {
- t.Fatal("a command that exits non-zero should fail")
- }
- if !strings.Contains(err.Error(), "not logged in") {
- t.Errorf("the reason is missing from %q", err)
- }
-}
-
-func TestShellCancels(t *testing.T) {
- ctx, cancel := context.WithCancel(context.Background())
- cancel()
- if _, err := shell(ctx, "sh", []string{"-c", "sleep 10"}, ""); err == nil {
- t.Error("a cancelled context should stop the command")
- }
-}
-
-// standIn puts a command of the given name on the path, which answers with
-// what it is given and records the arguments and input it was called with.
-// The assistants themselves need credentials this machine does not have.
-func standIn(t *testing.T, dir, name, answer string) string {
- t.Helper()
- path := filepath.Join(dir, name)
- script := "#!/bin/sh\ncat > \"$0.stdin\"\nprintf '%s' \"$*\" > \"$0.args\"\n" + answer
- if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
- t.Fatal(err)
- }
- t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
- return path
-}
-
-// piStream is the shape pi's documented event stream takes: one JSON object
-// per line, the last assistant message carrying the answer.
-const piStream = `{"type":"session","version":3,"id":"01a0","timestamp":"2026-09-21T16:57:13.552Z","cwd":"/tmp"}
-{"type":"agent_start"}
-{"type":"turn_start"}
-{"type":"message_start","message":{"role":"assistant","content":[]}}
-{"type":"message_update","usage":{"input":0,"output":0},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"{"}}
-{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"weighing it up"},{"type":"text","text":"{\"findings\":[]}"}],"usage":{"input":1200,"output":40,"cacheRead":900,"cacheWrite":0,"totalTokens":1240,"cost":{"input":0.001,"output":0.002,"cacheRead":0,"cacheWrite":0,"total":0.003}},"stopReason":"stop"}}
-{"type":"turn_end","message":{"role":"assistant","content":[]},"toolResults":[]}
-{"type":"agent_end","messages":[]}
-`
-
-func TestSpoken(t *testing.T) {
- answer, err := spoken([]byte(piStream))
- if err != nil {
- t.Fatal(err)
- }
- if answer.Text != `{"findings":[]}` {
- t.Errorf("got %q", answer.Text)
- }
- // Thinking is not the answer.
- if strings.Contains(answer.Text, "weighing") {
- t.Errorf("thinking leaked into the answer: %q", answer.Text)
- }
- if answer.In != 1200 || answer.Out != 40 || answer.Cached != 900 {
- t.Errorf("usage: got %+v", answer)
- }
- if answer.Cost != 0.003 {
- t.Errorf("cost: got %v, want the total", answer.Cost)
- }
-}
-
-// pi reports a refusal in the message it stopped on, which is the only place
-// the reason appears.
-func TestSpokenReportsAnError(t *testing.T) {
- stream := `{"type":"message_end","message":{"role":"assistant","content":[],"stopReason":"error","errorMessage":"context length exceeded"}}`
- _, err := spoken([]byte(stream))
- if err == nil || !strings.Contains(err.Error(), "context length exceeded") {
- t.Errorf("got %v", err)
- }
-}
-
-// With no credentials pi says so outside the stream, so that is what the
-// error has to carry.
-func TestSpokenCarriesWhatWasSaidOutsideTheStream(t *testing.T) {
- out := `{"type":"session","version":3,"id":"01a0"}
-No API key found for the selected model.
-`
- _, err := spoken([]byte(out))
- if err == nil || !strings.Contains(err.Error(), "No API key found") {
- t.Errorf("got %v", err)
- }
-}
-
-func TestSpokenWithNothingAtAll(t *testing.T) {
- if _, err := spoken(nil); err == nil {
- t.Error("an empty answer should fail")
- }
-}
-
-// Pi.Ask is exercised against a stand-in on the path, since the assistant
-// itself needs credentials this machine does not have.
-func TestPiAsk(t *testing.T) {
- dir := t.TempDir()
- path := standIn(t, dir, "pi", "cat <<'STREAM'\n"+piStream+"STREAM\n")
-
- p := Pi{Model: "gpt-5", Upstream: "openai"}
- answer, err := p.Ask(context.Background(), "the criteria", "the change")
- if err != nil {
- t.Fatal(err)
- }
- if answer.Text != `{"findings":[]}` {
- t.Errorf("got %q", answer.Text)
- }
- args, err := os.ReadFile(path + ".args")
- if err != nil {
- t.Fatal(err)
- }
- for _, want := range []string{"--mode json", "--no-session", "--no-tools", "--system-prompt the criteria", "--provider openai", "--model gpt-5"} {
- if !strings.Contains(string(args), want) {
- t.Errorf("%q missing from the arguments: %s", want, args)
- }
- }
- // The prompt goes on the input, not the argument list.
- stdin, err := os.ReadFile(path + ".stdin")
- if err != nil {
- t.Fatal(err)
- }
- if strings.TrimSpace(string(stdin)) != "the change" {
- t.Errorf("got %q on the input", stdin)
- }
- if strings.Contains(string(args), "the change") {
- t.Errorf("the prompt reached the argument list: %s", args)
- }
-}
-
-// The tools that answer these jobs are refused rather than left to
-// judgement, and the prompt goes on the input where a variadic flag cannot
-// swallow it.
-func TestClaudeCodeAsk(t *testing.T) {
- dir := t.TempDir()
- path := standIn(t, dir, "claude", "cat <<'ENVELOPE'\n"+
- `{"result":"{\"findings\":[]}","is_error":false,"total_cost_usd":0.05,`+
- `"usage":{"input_tokens":9,"output_tokens":40,"cache_read_input_tokens":800}}`+
- "\nENVELOPE\n")
-
- answer, err := ClaudeCode{Model: "sonnet"}.Ask(context.Background(), "the criteria", "the change")
- if err != nil {
- t.Fatal(err)
- }
- if answer.Text != `{"findings":[]}` || answer.Cost != 0.05 || answer.Cached != 800 {
- t.Errorf("got %+v", answer)
- }
- args, err := os.ReadFile(path + ".args")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(string(args), "--disallowedTools Bash,Read,") {
- t.Errorf("the tools are not refused: %s", args)
- }
- // --disallowedTools is variadic and swallows a positional prompt.
- if strings.Contains(string(args), "the change") {
- t.Errorf("the prompt reached the argument list: %s", args)
- }
- // --bare sets CLAUDE_CODE_SIMPLE=1 and restricts authentication to an
- // API key, which breaks a machine using the logged-in session.
- if strings.Contains(string(args), "--bare") {
- t.Errorf("--bare is back: %s", args)
- }
-}
-
-func TestClaudeCodeReportsAnError(t *testing.T) {
- dir := t.TempDir()
- standIn(t, dir, "claude", "cat <<'ENVELOPE'\n"+
- `{"result":"Not logged in","is_error":true}`+"\nENVELOPE\n")
-
- _, err := ClaudeCode{Model: "sonnet"}.Ask(context.Background(), "", "")
- if err == nil || !strings.Contains(err.Error(), "Not logged in") {
- t.Errorf("got %v", err)
- }
-}
-
-// The default is the middle model: the smallest reads the shortlist and
-// reports the first duplicate rather than all of them.
-func TestDefaultModels(t *testing.T) {
- if got := Providers()["claude"]("").(ClaudeCode).Model; got != "sonnet" {
- t.Errorf("claude defaults to %q", got)
- }
- if got := Providers()["api"]("").(API).Model; got != "claude-sonnet-5" {
- t.Errorf("api defaults to %q", got)
- }
- if got := Providers()["claude"]("haiku").(ClaudeCode).Model; got != "haiku" {
- t.Errorf("a named model is used, got %q", got)
- }
-}
diff --git a/readme.md b/readme.md
@@ -27,6 +27,17 @@ 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
```
+## Building
+
+The tool is written in Odin, on the `jfm` collection (`~/Source/Personal/odin`), and
+reads each language through a sidecar built on that language's own parser. `just build`
+compiles the four binaries into `build/`; `just install` puts them beside each other on
+the path: `review`, `review-go` (Go's parser, from `sidecar/gofront`), `review-vet` (go
+vet's multichecker, from `sidecar/govet`) and `odin-review-extract` (Odin's parser, from
+`sidecar/odin`). `just test` type-checks and runs every package's tests and the Go
+sidecar's. The repository's own `ols.json` names the collection for the editor and for
+the `odin-check` analyser.
+
## The jobs
Each is a separate call, run at once, given only the part of the change it needs. A job
@@ -88,11 +99,8 @@ and skip.
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
-```
+`unusedwrite`, and the `modernize` suite. `just install` builds it and puts it on the
+path beside `review`.
Without it, vet runs its default set. `fieldalignment` is left out on purpose: it is
noise on any struct not on a hot path.
@@ -414,33 +422,21 @@ it worth reading.
## The eval set
-`eval_test.go` holds the cases with a known right answer — two revisions of a corpus
-repository and three written for the purpose — and scores a reading against them. It is
-off unless asked for, because it is the only thing here that spends money:
-
-```
-go test -run TestEval -eval=1 # one reading of each case
-go test -run TestEval -eval=3 -eval.show # three, printing every finding
-go test -run TestEval -eval=1 -eval.cases=duplication
-```
-
-`REVIEW_EVAL_CORPUS` names the repository the corpus cases read; they skip without it.
-More than one run per case is worth having: a single reading walks between the two
-failure directions rather than sitting at one.
-
-A case says what a reading must find and what it must not raise. The silences matter
-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.
+The eval harness went with the Go implementation it was written in: `eval_test.go` held
+the cases with a known right answer and scored a reading against them, and porting it is
+the open item in `handover.md`. Until then the measure of a reading is the side-by-side
+run the port was checked with: both tools on the same change, the reports diffed field
+by field, and the answer cache shared so a replayed answer is compared rather than
+re-asked.
## Languages
-Go is read with the standard library's parser; the same reading ships as the `review-go`
-sidecar, built from `sidecar/gofront`, for a reviewer written in another language — the
-Odin reading under `odin/` drives it and `odin-review-extract` through one JSON shape. TypeScript and JavaScript — `.ts`,
+Go is read through the `review-go` sidecar, built from `sidecar/gofront` on Go's own
+parser, and Odin through `odin-review-extract`, built from `sidecar/odin` on Odin's;
+both print one JSON shape the tool reads. 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.
+is whatever the grammar calls one, and its name is read out of the match.
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
diff --git a/odin/report/report.odin b/report/report.odin
diff --git a/odin/report/report_test.odin b/report/report_test.odin
diff --git a/odin/review/main.odin b/review/main.odin
diff --git a/odin/reviewer/reviewer.odin b/reviewer/reviewer.odin
diff --git a/odin/reviewer/reviewer_test.odin b/reviewer/reviewer_test.odin
diff --git a/rules.go b/rules.go
@@ -1,198 +0,0 @@
-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
@@ -1,90 +0,0 @@
-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
@@ -1,102 +0,0 @@
-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
@@ -1,38 +0,0 @@
-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/odin/main.odin b/sidecar/odin/main.odin
@@ -15,7 +15,6 @@ import "core:encoding/json"
import "core:fmt"
import "core:odin/ast"
import "core:odin/parser"
-import "core:odin/tokenizer"
import "core:os"
import "core:strings"
@@ -58,7 +57,9 @@ main :: proc() {
}
read_file :: proc(path: string, src: []byte) -> File_Result {
- result := File_Result{name = path}
+ result := File_Result {
+ name = path,
+ }
file := new(ast.File)
file.fullpath = path
file.src = string(src)
@@ -73,18 +74,18 @@ read_file :: proc(path: string, src: []byte) -> File_Result {
}
private := is_private(decl.attributes[:])
for name_expr in decl.names {
- ident, ok := name_expr.derived_expr.(^ast.Ident)
- if !ok {
+ ident, is_ident := name_expr.derived_expr.(^ast.Ident)
+ if !is_ident {
continue
}
- entry := Decl{
- name = ident.name,
- kind = kind_of(decl),
- line = int(name_expr.pos.line),
+ entry := Decl {
+ name = ident.name,
+ kind = kind_of(decl),
+ line = int(name_expr.pos.line),
end_line = int(stmt.end.line),
exported = private == false,
- text = line_text(src, name_expr.pos.line),
- doc = doc_text(decl.docs),
+ text = line_text(src, name_expr.pos.line),
+ doc = doc_text(decl.docs),
}
if kind_of(decl) == "func" {
entry.test = is_test(decl.attributes[:])
@@ -117,11 +118,20 @@ expr_kind :: proc(e: ast.Any_Expr) -> string {
return "func"
case ^ast.Proc_Group:
return "func"
- case ^ast.Struct_Type, ^ast.Union_Type, ^ast.Enum_Type,
- ^ast.Bit_Set_Type, ^ast.Distinct_Type, ^ast.Poly_Type,
- ^ast.Typeid_Type, ^ast.Pointer_Type, ^ast.Array_Type,
- ^ast.Dynamic_Array_Type, ^ast.Fixed_Capacity_Dynamic_Array_Type,
- ^ast.Map_Type, ^ast.Relative_Type, ^ast.Matrix_Type,
+ case ^ast.Struct_Type,
+ ^ast.Union_Type,
+ ^ast.Enum_Type,
+ ^ast.Bit_Set_Type,
+ ^ast.Distinct_Type,
+ ^ast.Poly_Type,
+ ^ast.Typeid_Type,
+ ^ast.Pointer_Type,
+ ^ast.Array_Type,
+ ^ast.Dynamic_Array_Type,
+ ^ast.Fixed_Capacity_Dynamic_Array_Type,
+ ^ast.Map_Type,
+ ^ast.Relative_Type,
+ ^ast.Matrix_Type,
^ast.Bit_Field_Type:
return "type"
case:
@@ -154,8 +164,9 @@ is_test :: proc(attributes: []^ast.Attribute) -> bool {
is_private :: proc(attributes: []^ast.Attribute) -> bool {
for attribute in attributes {
for elem in attribute.elems {
- if field, ok := elem.derived_expr.(^ast.Field_Value); ok {
- if name, ok := field.field.derived_expr.(^ast.Ident); ok && name.name == "private" {
+ if field, is_field := elem.derived_expr.(^ast.Field_Value); is_field {
+ if name, named := field.field.derived_expr.(^ast.Ident);
+ named && name.name == "private" {
return true
}
}
diff --git a/static.go b/static.go
@@ -1,774 +0,0 @@
-package main
-
-// The checks here need no model. They measure the commit message and report
-// what fails, before anything is asked of a provider and whether or not one
-// can answer. A check that passes says nothing.
-
-import (
- "bytes"
- "cmp"
- "compress/zlib"
- "fmt"
- "math"
- "path/filepath"
- "regexp"
- "slices"
- "strings"
- "unicode"
-)
-
-// Check measures a change and reports what it noticed.
-type Check func(*Change) []Finding
-
-// 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 {
- 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 (
- // minEntropy is the Shannon entropy, in bits per byte, under which a
- // message is one short phrase said over rather than a description of a
- // change. Ordinary commit messages measure between 3.7 and 4.9.
- minEntropy = 3.2
-
- // entropyFloor is the message length under which entropy says nothing: a
- // subject of a dozen characters is low-entropy whatever it says.
- entropyFloor = 40
-
- // maxCompression is the share of its length a message keeps after zlib,
- // under which the message is one block of text pasted or repeated whole.
- // No written commit message measured keeps less than 0.26.
- maxCompression = 0.20
-
- // compressionFloor is the message length under which zlib cannot beat its
- // own framing, and the ratio means nothing.
- compressionFloor = 400
-
- // commonIDF is the natural log under which a word counts as one of the
- // repository's commonest: a word this common appears in at least a fifth
- // of the repository's commit subjects, however many there are.
- commonIDF = 1.6
-
- // historyFloor is the number of commit subjects under which the word
- // frequencies are too thin to judge a message by, and the check says
- // nothing.
- historyFloor = 100
-
- // digits are the characters whose presence spares a message: a version
- // or an issue number is information however few words carry it.
- digits = "0123456789"
-
- // bodyFloorLines is the size of diff, in changed lines, under which a
- // body is optional: the subject may say enough by itself.
- bodyFloorLines = 50
-
- // maxBodyWords is the length, in words, over which a body is listing
- // what the diff already shows rather than explaining the change.
- maxBodyWords = 150
-
- // temporalCoupling is the Jaccard over which history says two files
- // change together: over the counted commits, at least this share of the
- // commits touching either file touches both. Measured fire rates: 12%
- // of sampled commits at 0.5, 3.6% at 0.7, and the pairs the higher
- // threshold keeps are the changed-without-its-test kind.
- temporalCoupling = 0.7
-
- // temporalSupport is the fewest commits two files must share before
- // their history is a pattern rather than a coincidence.
- temporalSupport = 5
-
- // temporalFindings caps how many pairs one change is asked about, so a
- // change grazing five coupled files starts one conversation, not five.
- temporalFindings = 3
-)
-
-// ventingWords are the exclamations and profanities of a message written in
-// the moment of the mistake rather than about the change. Every word here
-// is unambiguous: stupid, dumb, finally, eventually and annoying all appear
-// in measured history describing the code legitimately, so they are not
-// here.
-var ventingWords = map[string]bool{
- "fuck": true, "fucking": true, "fucked": true,
- "shit": true, "bullshit": true, "wtf": true,
- "damn": true, "dammit": true, "damnit": true,
- "oops": true, "oopsie": true, "whoops": true,
- "ugh": true, "argh": true, "grr": true, "sigh": true,
- "fml": true, "yolo": true, "idk": true,
-}
-
-// runChecks applies every check. It is called before any provider is built,
-// so what the checks find is reported whether or not a model can be asked.
-func runChecks(change *Change) []Finding {
- var out []Finding
- for _, check := range Checks() {
- out = append(out, check(change)...)
- }
- return out
-}
-
-// 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 := measured(c)
- if msg == "" {
- return nil
- }
- if len(msg) < entropyFloor {
- return nil
- }
- h := shannonEntropy(msg)
- if h >= minEntropy {
- return nil
- }
- 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",
- h, minEntropy),
- Fix: "write a message that says what the change does and why",
- }}
-}
-
-// checkCompressibility reports a message zlib keeps only a fraction of: the
-// 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 := measured(c)
- if msg == "" {
- return nil
- }
- if len(msg) < compressionFloor {
- return nil
- }
- ratio := compressionRatio(msg)
- if ratio >= maxCompression {
- return nil
- }
- 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%%",
- 100*ratio, 100*maxCompression),
- Fix: "keep only what the reader needs of the quoted text, and write the rest",
- }}
-}
-
-// checkCommon reports a message made entirely of the words this
-// repository's own history uses most, which names nothing the change
-// touches. A word the history has never used is the one thing a message
-// like this cannot have, which is why a message holding any rarer word is
-// left to the reader.
-func checkCommon(c *Change) []Finding {
- msg := measured(c)
- if msg == "" {
- return nil
- }
- if len(c.History) < historyFloor {
- return nil
- }
- words := contentWords(msg)
- if len(words) == 0 {
- return nil
- }
- if strings.HasPrefix(msg, "Merge ") || strings.HasPrefix(msg, "Squashed ") ||
- strings.ContainsAny(msg, digits) {
- return nil
- }
- freq := frequencies(c.History)
- n := len(c.History)
- for w := range words {
- if math.Log(float64(n)/float64(1+freq[w])) > commonIDF {
- return nil
- }
- }
- ground := diffWords(c)
- for w := range words {
- if ground[w] {
- return nil
- }
- }
- listed := make([]string, 0, len(words))
- for w := range words {
- listed = append(listed, w)
- }
- slices.Sort(listed)
- 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",
- 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 := measured(c)
- if msg == "" {
- return nil
- }
- var hit []string
- for _, piece := range fields(msg) {
- w := strings.ToLower(piece)
- if ventingWords[w] {
- hit = append(hit, w)
- }
- }
- if len(hit) == 0 {
- return nil
- }
- slices.Sort(hit)
- hit = slices.Compact(hit)
- 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",
- strings.Join(hit, ", ")),
- Fix: "describe the change, not the moment",
- }}
-}
-
-// checkMood reports a subject that does not open as a command. The
-// discipline is package: explainer, with the explainer in the imperative
-// mood: a subject that opens in the past tense or on a gerund records that
-// a thing was done, and one that opens on the author narrates the author.
-// The suffix rules cannot hear an adjective from a past participle, and
-// verbs that wear one form for every mood are spared, as are articles: an
-// explainer may be a noun phrase on purpose, as the tool's own subjects
-// are.
-func checkMood(c *Change) []Finding {
- 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
- }
- words := fields(explainer(subject))
- if len(words) == 0 {
- return nil
- }
- first, quoted := strings.ToLower(words[0]), words[0]
- what := ""
- switch {
- case openers[first]:
- what = "narration"
- case invariantVerbs[first]:
- return nil
- case irregularPast[first] || (len(first) >= 4 && strings.HasSuffix(first, "ed") &&
- !strings.HasSuffix(first, "eed")):
- what = "the past tense"
- case len(first) >= 5 && strings.HasSuffix(first, "ing") && !nonVerbIng[first]:
- what = "a gerund"
- default:
- return nil
- }
- 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",
- what, quoted),
- Fix: "open the subject on its verb, in the imperative",
- }}
-}
-
-// explainer is the part of a subject after its package prefix: explainer is
-// the part after "review:" in "review: measure it". A subject without a
-// lowercase prefix is all explainer.
-func explainer(subject string) string {
- i := strings.Index(subject, ": ")
- if i < 2 || i > 23 {
- return subject
- }
- for _, r := range subject[:i] {
- if !('a' <= r && r <= 'z' || '0' <= r && r <= '9' || r == '-' || r == '_') {
- return subject
- }
- }
- return subject[i+2:]
-}
-
-// openers are the words whose presence alone is narration: the author as
-// subject, and the demonstratives of "this fixes".
-var openers = map[string]bool{
- "i": true, "we": true, "my": true,
- "this": true, "these": true, "those": true,
-}
-
-// invariantVerbs are the verbs whose past and imperative share a form, such
-// as read and set: a subject opening on one is a command until the
-// sentence says otherwise, and the suffix rules cannot hear which.
-var invariantVerbs = map[string]bool{
- "read": true, "cut": true, "set": true, "put": true, "let": true,
- "hit": true, "cost": true, "split": true, "shut": true, "cast": true,
- "hurt": true, "quit": true, "burst": true, "spread": true, "slit": true,
-}
-
-// irregularPast are the past forms that no suffix rule could catch, which
-// cannot be imperatives because their imperatives are other words.
-var irregularPast = map[string]bool{
- "wrote": true, "made": true, "kept": true, "went": true, "got": true,
- "ran": true, "brought": true, "built": true, "bought": true, "caught": true,
- "drove": true, "found": true, "held": true, "left": true, "met": true,
- "paid": true, "sent": true, "spent": true, "took": true, "won": true,
- "sold": true, "freed": true,
-}
-
-// nonVerbIng are the words that end in ing without being a verb's gerund.
-var nonVerbIng = map[string]bool{
- "during": true, "nothing": true, "something": true, "anything": true,
- "everything": true, "morning": true, "evening": true, "offing": true,
- "outing": true, "bring": true, "king": true, "ring": true, "sing": true,
- "spring": true, "string": true, "swing": true, "thing": true, "wing": true,
- "cling": true, "sting": true, "fling": true,
-}
-
-// checkBody asks a large change to say something in its body: a diff of
-// more than bodyFloorLines owes a body, however short, and no body may
-// exceed maxBodyWords. The diff records what moved; the body is the only
-// place the change's why is recorded. The diff's size is a weak measure of
-// how much a change needs saying — a small diff can hold the subtle fault,
-// a large one can be a formatting run — so the check asks only for
-// existence, and a change that only moves text around, whose subject says
-// what it did, owes nothing. Subjects of merges and squashes are git's
-// words, not the commit's.
-func checkBody(c *Change) []Finding {
- 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
- }
- n := bodyWords(msg)
- if n > maxBodyWords {
- 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",
- n, maxBodyWords),
- Fix: "cut the body to the change's why",
- }}
- }
- if n > 0 {
- return nil
- }
- changed := changedLines(c.Diff)
- if changed <= bodyFloorLines || moved(c.Diff) {
- return nil
- }
- 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",
- changed),
- Fix: "write the body, saying why the change is what it is",
- }}
-}
-
-// checkTemporal reports a changed file whose history names a partner the
-// change does not touch: over the last thousand commits before the change,
-// at least temporalCoupling of the commits touching either file have
-// touched both, at least temporalSupport times. A coupling like that is
-// not the compiler's to see, and only the author can say whether the pair
-// still changes together; files that change together this reliably usually
-// fail together.
-func checkTemporal(c *Change) []Finding {
- if c.Temporal == nil {
- return nil
- }
- changed := map[string]bool{}
- for _, f := range c.Files {
- changed[f] = true
- }
- type pair struct {
- file string
- partner Partner
- union int
- j float64
- }
- var pairs []pair
- for file, partners := range c.Temporal.Partners {
- for _, p := range partners {
- if changed[p.Name] {
- continue
- }
- union := c.Temporal.Commits[file] + c.Temporal.Commits[p.Name] - p.Shared
- if union <= 0 {
- continue
- }
- j := float64(p.Shared) / float64(union)
- if j < temporalCoupling {
- // The list is nearest first, so the rest of this file's
- // partners are further away still.
- break
- }
- if p.Shared < temporalSupport {
- continue
- }
- pairs = append(pairs, pair{file, p, union, j})
- }
- }
- slices.SortFunc(pairs, func(a, b pair) int {
- if c := cmp.Compare(b.partner.Shared, a.partner.Shared); c != 0 {
- return c
- }
- if c := cmp.Compare(b.j, a.j); c != 0 {
- return c
- }
- if c := cmp.Compare(a.file, b.file); c != 0 {
- return c
- }
- return cmp.Compare(a.partner.Name, b.partner.Name)
- })
- if len(pairs) > temporalFindings {
- pairs = pairs[:temporalFindings]
- }
- findings := make([]Finding, 0, len(pairs))
- for _, p := range pairs {
- findings = append(findings, Finding{
- Job: "static", Rule: "history-coupled-file", Severity: MustFix,
- File: p.file,
- Message: fmt.Sprintf(
- "history ties %s to %s: %d of the %d commits touching either file have touched both, and this change touches %s without %s; files that change together this reliably usually fail together, and the half of the pair left out is where a forgotten change usually is",
- p.file, p.partner.Name, p.partner.Shared, p.union, p.file, p.partner.Name),
- Fix: fmt.Sprintf("touch %s too, or be sure it stands without this change", p.partner.Name),
- })
- }
- return findings
-}
-
-// moved is whether a diff's added and removed sides hold the same lines:
-// the change rearranged text rather than changing it. Whitespace may be
-// load-bearing in a few languages; the exemption errs toward silence, as
-// the others do.
-func moved(diff string) bool {
- counts := map[string]int{}
- n := 0
- for _, line := range strings.Split(diff, "\n") {
- if !strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "-") {
- continue
- }
- if strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "---") {
- continue
- }
- s := strings.TrimSpace(line[1:])
- if s == "" {
- continue
- }
- n++
- if strings.HasPrefix(line, "+") {
- counts[s]++
- } else {
- counts[s]--
- }
- }
- for _, v := range counts {
- if v != 0 {
- return false
- }
- }
- return n > 0
-}
-
-// bodyWords counts the words below the subject line.
-func bodyWords(msg string) int {
- parts := strings.SplitN(msg, "\n", 2)
- if len(parts) == 1 {
- return 0
- }
- return len(fields(parts[1]))
-}
-
-// changedLines counts the lines a diff adds or removes, without the diff's
-// own framing. A diff truncated by the packet's cap undercounts, which
-// asks less of the body and errs toward silence.
-func changedLines(diff string) int {
- n := 0
- for _, line := range strings.Split(diff, "\n") {
- if !strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "-") {
- continue
- }
- if strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "---") {
- continue
- }
- n++
- }
- return n
-}
-
-// contentWords is the vocabulary of a text: lowercased, split on anything
-// that is not a letter or digit, split again at camel humps, depluralised,
-// and stripped of the words that name nothing.
-func contentWords(text string) map[string]bool {
- out := map[string]bool{}
- for _, piece := range fields(text) {
- for _, part := range humps(piece) {
- w := depluralise(strings.ToLower(part))
- if len(w) > 2 && !stopWords[w] {
- out[w] = true
- }
- }
- }
- return out
-}
-
-// fields splits on anything that is not a letter or a digit.
-func fields(s string) []string {
- return strings.FieldsFunc(s, func(r rune) bool {
- return !unicode.IsLetter(r) && !unicode.IsDigit(r)
- })
-}
-
-// stopWords are the function words, which name nothing: both a message and
-// the change it describes are full of them.
-var stopWords = map[string]bool{}
-
-func init() {
- for _, w := range strings.Fields(`a an the and or but if then than that this these those
- to of in on at by for with from into up out it its is are was were be been being am
- as so not no nor do does did done doing can could will would shall should may might
- must have has had having i we you they he she him her them his hers their ours your
- my me us what which who whom when where why how all any both each few more most
- other some such only own same too very just also now here there over under again
- further once about between through during before after above below because while
- until against`) {
- stopWords[w] = true
- }
-}
-
-// humps splits containerSniff into container and sniff, so that a message
-// naming a thing meets the identifier for it in the diff.
-func humps(s string) []string {
- var out []string
- var cur strings.Builder
- rs := []rune(s)
- for i, r := range rs {
- if i > 0 && unicode.IsUpper(r) && unicode.IsLower(rs[i-1]) {
- out = append(out, cur.String())
- cur.Reset()
- }
- cur.WriteRune(r)
- }
- if cur.Len() > 0 {
- out = append(out, cur.String())
- }
- return out
-}
-
-// depluralise drops a trailing s, except where dropping it would leave
-// another: class stays class.
-func depluralise(w string) string {
- if len(w) > 3 && strings.HasSuffix(w, "s") && w[len(w)-2] != 's' {
- return w[:len(w)-1]
- }
- return w
-}
-
-// frequencies counts, over the repository's subjects, in how many of them
-// each content word appears.
-func frequencies(history []string) map[string]int {
- out := map[string]int{}
- for _, s := range history {
- for w := range contentWords(s) {
- out[w]++
- }
- }
- return out
-}
-
-// diffWords is the vocabulary of what a change touches: the file paths, and
-// the words of every line it adds or removes, so that a message naming its
-// ground meets the ground it names.
-func diffWords(c *Change) map[string]bool {
- out := map[string]bool{}
- for _, path := range c.Files {
- for w := range contentWords(strings.TrimSuffix(path, filepath.Ext(path))) {
- out[w] = true
- }
- }
- for _, line := range strings.Split(c.Diff, "\n") {
- if strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "---") ||
- strings.HasPrefix(line, "@@") || strings.HasPrefix(line, "diff ") ||
- strings.HasPrefix(line, "index ") || strings.HasPrefix(line, "\\ ") {
- continue
- }
- if strings.HasPrefix(line, "+") || strings.HasPrefix(line, "-") {
- for w := range contentWords(line[1:]) {
- out[w] = true
- }
- }
- }
- return out
-}
-
-// 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
-// message holding runes outside ASCII measures higher than its characters
-// alone would, which errs toward silence.
-func shannonEntropy(s string) float64 {
- n := len(s)
- if n == 0 {
- return 0
- }
- var counts [256]int
- for i := range n {
- counts[s[i]]++
- }
- var h float64
- for _, c := range counts {
- if c == 0 {
- continue
- }
- p := float64(c) / float64(n)
- h -= p * math.Log2(p)
- }
- return h
-}
-
-// compressionRatio is the share of its length s keeps after zlib. The
-// framing counts, which is why the check applies only past compressionFloor.
-func compressionRatio(s string) float64 {
- var buf bytes.Buffer
- w := zlib.NewWriter(&buf)
- w.Write([]byte(s))
- w.Close()
- return float64(buf.Len()) / float64(len(s))
-}
diff --git a/static_test.go b/static_test.go
@@ -1,665 +0,0 @@
-package main
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "slices"
- "strings"
- "testing"
-)
-
-// The checks are pure over the message, so they are tested directly; the
-// Gathered shape of a message is tested where it is gathered.
-
-func TestLowEntropyIsReported(t *testing.T) {
- change := &Change{Message: strings.Repeat("asdf asdf ", 6)}
- findings := runChecks(change)
- if len(findings) != 1 {
- t.Fatalf("got %d findings, want one:\n%v", len(findings), findings)
- }
- f := findings[0]
- if f.Job != "static" || f.Rule != "message-low-entropy" || f.Severity != MustFix {
- t.Errorf("got %s/%s/%s", f.Job, f.Rule, f.Severity)
- }
- // A failure explains itself: the measurement, the threshold, and why a
- // message can measure that low.
- for _, want := range []string{"2.3", "Shannon", "3.2", "phrase repeated"} {
- if !strings.Contains(f.Message, want) {
- t.Errorf("%q missing from: %s", want, f.Message)
- }
- }
-}
-
-func TestBoilerplateIsReported(t *testing.T) {
- change := &Change{Message: strings.Repeat(licenceLine, 9)}
- findings := runChecks(change)
- if len(findings) != 1 {
- t.Fatalf("got %d findings, want one:\n%v", len(findings), findings)
- }
- f := findings[0]
- if f.Rule != "message-boilerplate" {
- t.Errorf("got %s", f.Rule)
- }
- for _, want := range []string{"compresses", "20%", "pasted"} {
- if !strings.Contains(f.Message, want) {
- t.Errorf("%q missing from: %s", want, f.Message)
- }
- }
-}
-
-// A message can fail both at once, and each check answers for itself.
-func TestBothChecksFireOnOneMessage(t *testing.T) {
- change := &Change{Message: strings.Repeat("asdf asdf asdf asdf asdf asdf\n", 20)}
- var got []string
- for _, f := range runChecks(change) {
- got = append(got, f.Rule)
- }
- if len(got) != 2 || got[0] == got[1] {
- t.Errorf("got %v, want both rules once each", got)
- }
-}
-
-func TestChecksStaySilentOnAMessageThatSaysSomething(t *testing.T) {
- change := &Change{Message: goodMessage}
- if findings := runChecks(change); len(findings) != 0 {
- t.Errorf("got %v", findings)
- }
-}
-
-// Below the floors a measurement says nothing either way: a short subject is
-// low-entropy whatever it says, and zlib cannot beat its own framing.
-func TestShortMessagesAreNotMeasured(t *testing.T) {
- for _, msg := range []string{"wip", strings.Repeat(licenceLine, 4)} {
- if findings := runChecks(&Change{Message: msg}); len(findings) != 0 {
- t.Errorf("%q: got %v", msg, 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 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)
- }
- }
-}
-
-// The checks read what Gather read, including a message git itself produced.
-func TestStaticAgainstARealCommit(t *testing.T) {
- r := newRepo(t)
- r.write("x.go", "package x\n")
- r.commit("first", "x.go")
- r.write("y.go", "package x\n")
- r.commit(strings.Repeat("asdf asdf ", 6), "y.go")
-
- change, err := Gather("HEAD^", r.Root)
- if err != nil {
- t.Fatal(err)
- }
- findings := runChecks(change)
- if len(findings) != 1 || findings[0].Rule != "message-low-entropy" {
- t.Errorf("got %v", findings)
- }
-}
-
-func TestStaticAgainstALicensedCommitMessage(t *testing.T) {
- r := newRepo(t)
- r.write("x.go", "package x\n")
- r.commit(strings.Repeat(licenceLine, 9), "x.go")
-
- change, err := Gather("HEAD", r.Root)
- if err != nil {
- t.Fatal(err)
- }
- findings := runChecks(change)
- if len(findings) != 1 || findings[0].Rule != "message-boilerplate" {
- t.Errorf("got %v", findings)
- }
-}
-
-// commonHistory is a history in which fix and build are common and nothing
-// else is: enough subjects, said over and over, for the frequencies to mean
-// something.
-func commonHistory() []string {
- var h []string
- for i := 0; i < 60; i++ {
- h = append(h, "fix the failing build", "build: fix the build", "fix build")
- }
- return h
-}
-
-func TestCommonWordsAreReported(t *testing.T) {
- change := &Change{
- Message: "fix build",
- History: commonHistory(),
- Files: []string{"auth_service.go"},
- Diff: "+type JWTParser struct{}\n",
- }
- findings := runChecks(change)
- if len(findings) != 1 {
- t.Fatalf("got %d findings, want one:\n%v", len(findings), findings)
- }
- f := findings[0]
- if f.Rule != "message-common-words" {
- t.Errorf("got %s", f.Rule)
- }
- for _, want := range []string{"fix", "build", "180", "names nothing"} {
- if !strings.Contains(f.Message, want) {
- t.Errorf("%q missing from: %s", want, f.Message)
- }
- }
-}
-
-// One word the history has never used is enough to spare a message: a
-// message like this cannot have written that word without reading the
-// change.
-func TestAWordTheHistoryHasNotUsedIsSilent(t *testing.T) {
- change := &Change{
- Message: "fix the parser deadlock",
- History: commonHistory(),
- Files: []string{"auth_service.go"},
- }
- if findings := runChecks(change); len(findings) != 0 {
- t.Errorf("got %v", findings)
- }
-}
-
-// Naming what the change touches spares a message even when every word is
-// common: Update requests.ts is not updated the thing.
-func TestNamingTheChangeIsSilent(t *testing.T) {
- history := commonHistory()
- for i := 0; i < 40; i++ {
- history = append(history, "update the parser", "parser: update")
- }
- change := &Change{
- Message: "update parser",
- History: history,
- Files: []string{"parser.go"},
- Diff: "+func Parse() {}\n",
- }
- if findings := runChecks(change); len(findings) != 0 {
- t.Errorf("got %v", findings)
- }
-}
-
-// A version or an issue number is information however few words carry it.
-func TestVersionNumberSparesAMessage(t *testing.T) {
- history := commonHistory()
- for i := 0; i < 40; i++ {
- history = append(history, "bump version")
- }
- change := &Change{
- Message: "bump to 2.0.26",
- History: history,
- Files: []string{"version.go"},
- }
- if findings := runChecks(change); len(findings) != 0 {
- t.Errorf("got %v", findings)
- }
-}
-
-// Below a hundred subjects the frequencies are too thin to judge by, and a
-// repository that young has not earned the accusation.
-func TestThinHistoryIsSilent(t *testing.T) {
- change := &Change{
- Message: "fix build",
- History: commonHistory()[:99],
- Files: []string{"auth_service.go"},
- }
- if findings := runChecks(change); len(findings) != 0 {
- t.Errorf("got %v", findings)
- }
-}
-
-// Merge and squash subjects are git's words, not the commit's.
-func TestMergeMessagesAreSilent(t *testing.T) {
- for _, msg := range []string{
- "Merge branch 'main' into dev",
- "Squashed 'vendor/x/' content from branch main",
- } {
- change := &Change{Message: msg, History: commonHistory()}
- if findings := runChecks(change); len(findings) != 0 {
- t.Errorf("%q: got %v", msg, findings)
- }
- }
-}
-
-func TestVentingIsReported(t *testing.T) {
- for _, msg := range []string{
- "whoops",
- "oops, missed a comma",
- "damn, ran the formatter with spaces instead of tabs",
- "WHOOPS, left the debug print in",
- } {
- findings := runChecks(&Change{Message: msg})
- if len(findings) != 1 || findings[0].Rule != "message-frustration" {
- t.Errorf("%q: got %v", msg, findings)
- continue
- }
- if !strings.Contains(findings[0].Message, "exclamation") {
- t.Errorf("%q: %s", msg, findings[0].Message)
- }
- }
-}
-
-// 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.
-func TestTechnicalWordsAreSilent(t *testing.T) {
- for _, msg := range []string{
- "Change >> behaviour in LLVM to prevent stupid UB",
- "Respect TERM=dumb in the test runner",
- "Implement dumb PtrMap",
- "Temporarily fix the syscall (eventually to be replaced)",
- "finally fixed the flaky resize test",
- } {
- if findings := runChecks(&Change{Message: msg}); len(findings) != 0 {
- t.Errorf("%q: got %v", msg, findings)
- }
- }
-}
-
-// A real commit that is only an admission is caught through Gather too.
-func TestVentingAgainstARealCommit(t *testing.T) {
- r := newRepo(t)
- r.write("x.go", "package x\n")
- r.commit("whoops", "x.go")
-
- change, err := Gather("HEAD", r.Root)
- if err != nil {
- t.Fatal(err)
- }
- findings := runChecks(change)
- if len(findings) != 1 || findings[0].Rule != "message-frustration" {
- t.Errorf("got %v", findings)
- }
-}
-
-func TestMoodIsChecked(t *testing.T) {
- for _, msg := range []string{
- "Added readme.",
- "Fixing the build",
- "This fixes the crash",
- "i can't spell",
- "web: solarized palette at artifact-page contrast",
- "deps: upgraded the webp",
- } {
- findings := runChecks(&Change{Message: msg})
- if len(findings) != 1 || findings[0].Rule != "message-not-imperative" {
- t.Errorf("%q: got %v", msg, findings)
- continue
- }
- if !strings.Contains(findings[0].Message, "imperative mood") {
- t.Errorf("%q: %s", msg, findings[0].Message)
- }
- }
-}
-
-// Verbs whose past and imperative share a form are spared, articles are
-// spared because an explainer may be a noun phrase on purpose, and the
-// suffix rules must not hear speed and feed as past.
-func TestMoodIsSpared(t *testing.T) {
- for _, msg := range []string{
- "review: measure the message without a model",
- "icnsify: read icons out of a Windows binary",
- "Add caching for responses",
- "Bump to 2.0.x",
- "review: an eval set, and the criteria tuned against it",
- "docs: the vocabulary, the voices section, and the three providers",
- "Speed up the build",
- "Merge branch 'main' into dev",
- "Squashed 'vendor/x/' content from branch main",
- } {
- if findings := runChecks(&Change{Message: msg}); len(findings) != 0 {
- t.Errorf("%q: got %v", msg, findings)
- }
- }
-}
-
-// A past-tense commit in a real history is caught through Gather too.
-func TestMoodAgainstARealCommit(t *testing.T) {
- r := newRepo(t)
- r.write("x.go", "package x\n")
- r.commit("Added readme.", "x.go")
-
- change, err := Gather("HEAD", r.Root)
- if err != nil {
- t.Fatal(err)
- }
- findings := runChecks(change)
- if len(findings) != 1 || findings[0].Rule != "message-not-imperative" {
- t.Errorf("got %v", findings)
- }
-}
-
-func TestBodyIsOwed(t *testing.T) {
- findings := runChecks(&Change{Message: "feat: add the thing", Diff: strings.Repeat("+line\n", 51)})
- if len(findings) != 1 || findings[0].Rule != "message-no-body" {
- t.Errorf("got %v", findings)
- return
- }
- if !strings.Contains(findings[0].Message, "carries no body") {
- t.Errorf("%s", findings[0].Message)
- }
-}
-
-func TestBodyIsSpared(t *testing.T) {
- for _, tt := range []struct {
- diff, subject, body string
- }{
- {strings.Repeat("+line\n", 10), "feat: add the thing", ""},
- {strings.Repeat("+line\n", 500), "feat: add the thing", "one two three four five"},
- {strings.Repeat("+line\n", 500), "feat: add the thing", variedWords(150)},
- {strings.Repeat("+line\n", 500), "Merge branch 'main'", ""},
- // A formatting run: the same lines removed and added in another
- // order, changed not at all.
- {strings.Repeat("+x\n", 30) + strings.Repeat("-x\n", 30), "format: run gofmt", ""},
- } {
- msg := tt.subject
- if tt.body != "" {
- msg += "\n\n" + tt.body
- }
- if findings := runChecks(&Change{Message: msg, Diff: tt.diff}); len(findings) != 0 {
- t.Errorf("%q over %d diff lines: got %v", tt.subject, len(tt.diff)/6, findings)
- }
- }
-}
-
-// variedWords are n distinct words, so that a fixture body of a given
-// length does not read to the other checks as one phrase repeated.
-func variedWords(n int) string {
- words := make([]string, 0, n)
- for i := range n {
- words = append(words, fmt.Sprintf("word%d", i))
- }
- return strings.Join(words, " ")
-}
-
-func TestBodyIsCapped(t *testing.T) {
- msg := "feat: add the thing\n\n" + variedWords(151)
- findings := runChecks(&Change{Message: msg, Diff: strings.Repeat("+line\n", 10)})
- if len(findings) != 1 || findings[0].Rule != "message-long-body" {
- t.Errorf("got %v", findings)
- }
- if findings := runChecks(&Change{Message: "feat: add\n\n" + variedWords(150)}); len(findings) != 0 {
- t.Errorf("150 words: got %v", findings)
- }
-}
-
-// 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")
- r.commit("seed", "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)
- }
- 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)
- }
-}
-
-// TestTemporalIsReported runs the whole check set over a change whose
-// history names an untouched partner, so the finding survives the others.
-func TestTemporalIsReported(t *testing.T) {
- findings := runChecks(&Change{
- Files: []string{"login.go"},
- Temporal: &Temporal{
- Commits: map[string]int{"login.go": 10, "session.go": 9},
- Partners: map[string][]Partner{"login.go": {{Name: "session.go", Shared: 9}}},
- },
- })
- if len(findings) != 1 || findings[0].Rule != "history-coupled-file" {
- t.Errorf("got %v", findings)
- return
- }
- for _, want := range []string{"login.go", "session.go", "9 of the 10"} {
- if !strings.Contains(findings[0].Message, want) {
- t.Errorf("message lacks %q: %s", want, findings[0].Message)
- }
- }
-}
-
-func TestTemporalIsSpared(t *testing.T) {
- tests := []struct {
- name string
- files []string
- temporal *Temporal
- }{
- {"no history", []string{"a.go"}, nil},
- {"partner touched too", []string{"a.go", "b.go"}, &Temporal{
- Commits: map[string]int{"a.go": 10, "b.go": 9},
- Partners: map[string][]Partner{"a.go": {{Name: "b.go", Shared: 9}}},
- }},
- {"below the coupling", []string{"a.go"}, &Temporal{
- Commits: map[string]int{"a.go": 10, "b.go": 10},
- Partners: map[string][]Partner{"a.go": {{Name: "b.go", Shared: 5}}},
- }},
- {"below the support", []string{"a.go"}, &Temporal{
- Commits: map[string]int{"a.go": 3, "b.go": 3},
- Partners: map[string][]Partner{"a.go": {{Name: "b.go", Shared: 3}}},
- }},
- }
- for _, tt := range tests {
- if findings := checkTemporal(&Change{Files: tt.files, Temporal: tt.temporal}); len(findings) != 0 {
- t.Errorf("%s: got %v", tt.name, findings)
- }
- }
-}
-
-// Through Gather, the check counts the history git holds, and a change that
-// touches both halves of a coupled pair is left alone.
-func TestTemporalAgainstARealCommit(t *testing.T) {
- r := newRepo(t)
- r.write("seed.txt", "seed\n")
- r.commit("seed", "seed.txt")
- for i := 0; i < 8; i++ {
- r.write("a.go", fmt.Sprintf("package a\n\nvar v%d = %d\n", i, i))
- r.write("b.go", fmt.Sprintf("package b\n\nvar w%d = %d\n", i, i))
- r.commit(fmt.Sprintf("grow: the pair, round %d", i), "a.go", "b.go")
- }
- r.write("a.go", "package a\n\nvar v8 = 8\n\nvar v9 = 9\n")
- r.stage("a.go")
-
- change, err := Gather("", r.Root)
- if err != nil {
- t.Fatal(err)
- }
- findings := only(runChecks(change), "history-")
- if len(findings) != 1 || findings[0].Rule != "history-coupled-file" {
- t.Errorf("got %v", findings)
- return
- }
- if findings[0].File != "a.go" || !strings.Contains(findings[0].Message, "b.go") {
- t.Errorf("%s\n%s", findings[0].File, findings[0].Message)
- }
-
- // The pair touched together is the coupling honoured, and the check
- // says nothing.
- r.write("b.go", "package b\n\nvar w8 = 8\n\nvar w9 = 9\n")
- r.stage("b.go")
- change, err = Gather("", r.Root)
- if err != nil {
- t.Fatal(err)
- }
- if findings := only(runChecks(change), "history-"); len(findings) != 0 {
- t.Errorf("got %v", findings)
- }
-}
-
-// A partner the tree no longer holds is history's partner, not the
-// change's, and the check says nothing of it.
-func TestTemporalSparesAGonePartner(t *testing.T) {
- r := newRepo(t)
- r.write("a.go", "package a\n")
- r.write("b.go", "package b\n")
- for i := 0; i < 8; i++ {
- r.write("a.go", fmt.Sprintf("package a\n\nvar v%d = %d\n", i, i))
- r.write("b.go", fmt.Sprintf("package b\n\nvar w%d = %d\n", i, i))
- r.commit(fmt.Sprintf("grow: the pair, round %d", i), "a.go", "b.go")
- }
- r.write("b.go", "gone")
- r.commit("retire: the partner", "b.go")
- if err := os.Remove(filepath.Join(r.Root, "b.go")); err != nil {
- t.Fatal(err)
- }
- r.write("a.go", "package a\n\nvar v8 = 8\n\nvar v9 = 9\n")
- r.stage("a.go")
-
- change, err := Gather("", r.Root)
- if err != nil {
- t.Fatal(err)
- }
- if findings := only(runChecks(change), "history-"); len(findings) != 0 {
- t.Errorf("got %v", findings)
- }
-}
-
-// 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++ {
- r.run("commit", "--allow-empty", "-q", "-m", "fix build")
- }
- r.write("auth_service.go", "package auth\n\ntype JWTParser struct{}\n")
- r.stage("auth_service.go")
-
- change, err := Gather("", r.Root)
- if err != nil {
- t.Fatal(err)
- }
- change.Message = "fix build"
- findings := only(runChecks(change), "message-")
- if len(findings) != 1 || findings[0].Rule != "message-common-words" {
- t.Fatalf("got %v", findings)
- }
-}
-
-const licenceLine = "Copyright 2026 Example Corp. All rights reserved. Licensed under the Apache License, Version 2.0.\n"
-
-const goodMessage = `review: say which findings the source dismissed
-
-A dismissal answered the finding above it and was then thrown away with
-the finding, so a reading that met a third of the change reported less
-than it read. Kept beside the finding they answered, a dismissal is
-visible where a silent one was not, and --verbose names the reason it
-gave rather than leaving a gap in the list for nobody to explain.
-
-The window a dismissal covers stays as written: a few lines either side
-of the comment, because narrowing it waits on a case that shows the
-narrowing losing a finding it should have kept.`
-
-// The gaming checks are wired into the ordinary run: a change that
-// dismisses what the readers would have found, or deletes the tests that
-// would have failed, is measured before anything is asked of a provider.
-// The ignore comment is assembled at runtime, so this file does not add a
-// dismissal of its own while testing the check that counts them.
-func TestChecksHoldTheChangeToTheReview(t *testing.T) {
- change := &Change{Diff: `--- a/x_test.go
-+++ b/x_test.go
-@@ -1,2 +1,2 @@
- package x
-+//` + `review:ignore all tidy
--func TestA(t *testing.T) {}
-`}
- var got []string
- for _, f := range runChecks(change) {
- if f.Severity != MustFix {
- t.Errorf("%s is %s, want must-fix", f.Rule, f.Severity)
- }
- got = append(got, f.Rule)
- }
- for _, want := range []string{"suppression-added", "test-deleted"} {
- if !slices.Contains(got, want) {
- t.Errorf("%s missing from %v", want, got)
- }
- }
-}
-
-// 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,88 +0,0 @@
-package main
-
-// 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 (
- "context"
- "encoding/json"
- "regexp"
- "strings"
-)
-
-// 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, and the build has already reported it.
-var checkCode = regexp.MustCompile(`^[A-Z]+[0-9]+$`)
-
-// 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
- }
- out, err := execute(ctx, tree, nil, "staticcheck", append([]string{"-f", "json"}, pkgs...)...)
- if err != nil {
- return nil, err
- }
- 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),
- })
- }
- return diagnostics, nil
- },
-}
-
-// 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
-// per line. Only the fields the check reads are named.
-type scProblem struct {
- Code string `json:"code"`
- Location struct {
- File string `json:"file"`
- Line int `json:"line"`
- } `json:"location"`
- Message string `json:"message"`
-}
-
-// staticcheckSeverity maps a staticcheck category onto the report's
-// severities. SA is a fault the analyser argues for; U is code that serves
-// nobody, and S a simplification; ST and QF are style, and anything
-// unfamiliar errs toward a note.
-func staticcheckSeverity(code string) Severity {
- switch {
- case strings.HasPrefix(code, "SA"):
- return MustFix
- case strings.HasPrefix(code, "S") && !strings.HasPrefix(code, "ST"),
- strings.HasPrefix(code, "U"):
- return Consider
- }
- return Note
-}
-
-// 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)
- if len(s) <= n {
- return s
- }
- return "…" + s[len(s)-n:]
-}
diff --git a/staticcheck_test.go b/staticcheck_test.go
@@ -1,141 +0,0 @@
-package main
-
-import (
- "os/exec"
- "testing"
-)
-
-// The staticcheck tests hold the check against the analyser itself, an
-// external authority: when it is absent they fail, they do not skip.
-func needStaticcheck(t *testing.T) {
- t.Helper()
- if _, err := exec.LookPath("staticcheck"); err != nil {
- t.Fatalf("staticcheck is not installed: %v", err)
- }
-}
-
-// goModule is the smallest module a fixture needs.
-const goModule = "module probe\n\ngo 1.27.0\n"
-
-// withFault is a file staticcheck faults at line 4: a comparison with the
-// bool constant it can be simplified past.
-const withFault = "package probe\n\nfunc F(x bool) int {\n\tif x == true {\n\t\treturn 1\n\t}\n\treturn 2\n}\n"
-
-func TestStaticcheckReadsTheChange(t *testing.T) {
- needStaticcheck(t)
- r := newRepo(t)
- r.write("go.mod", goModule)
- r.write("a.go", withFault)
- r.stage("go.mod", "a.go")
- change, err := Gather("", r.Root)
- if err != nil {
- t.Fatal(err)
- }
- findings := checkStaticcheck(r.Root, "", change)
- if len(findings) != 1 {
- t.Fatalf("got %v, want the simplification finding", findings)
- }
- f := findings[0]
- if f.Rule != "staticcheck/S1002" || f.File != "a.go" || f.Line != 4 {
- t.Errorf("got %s at %s:%d", f.Rule, f.File, f.Line)
- }
- if f.Severity != Consider {
- t.Errorf("severity %s, want consider", f.Severity)
- }
- if f.Job != "static" || f.Message == "" {
- t.Errorf("job %q message %q", f.Job, f.Message)
- }
-}
-
-func TestStaticcheckKeepsToTheChange(t *testing.T) {
- needStaticcheck(t)
- r := newRepo(t)
- r.write("go.mod", goModule)
- r.write("a.go", withFault)
- r.commit("a: carry the fault history already holds", "go.mod", "a.go")
- r.write("b.go", "package probe\n\nfunc G() int { return 3 }\n")
- r.stage("b.go")
- change, err := Gather("", r.Root)
- if err != nil {
- t.Fatal(err)
- }
- // The fault in a.go is history's, not this change's; the analyser sees
- // it and the check must say nothing about it.
- if findings := checkStaticcheck(r.Root, "", change); len(findings) != 0 {
- t.Fatalf("got %v, want nothing: the fault is not on a line the change adds", findings)
- }
-}
-
-func TestStaticcheckReadsARange(t *testing.T) {
- needStaticcheck(t)
- r := newRepo(t)
- r.write("go.mod", goModule)
- r.write("a.go", "package probe\n\nfunc A() int { return 1 }\n")
- r.commit("a: begin", "go.mod", "a.go")
- r.write("b.go", withFault)
- r.commit("b: add the fault", "b.go")
- change, err := Gather("HEAD^..HEAD", r.Root)
- if err != nil {
- t.Fatal(err)
- }
- // A range's tree is not the working tree: the analysis has to run on
- // the tree the change arrived at.
- findings := checkStaticcheck(r.Root, "HEAD^..HEAD", change)
- if len(findings) != 1 {
- t.Fatalf("got %v, want the simplification finding", findings)
- }
- if f := findings[0]; f.File != "b.go" || f.Line != 4 || f.Rule != "staticcheck/S1002" {
- t.Errorf("got %s at %s:%d", f.Rule, f.File, f.Line)
- }
-}
-
-func TestStaticcheckSkipsWithoutGoModule(t *testing.T) {
- r := newRepo(t)
- r.write("a.go", "package probe\n")
- r.stage("a.go")
- change, err := Gather("", r.Root)
- if err != nil {
- t.Fatal(err)
- }
- var findings []Finding
- out := captureStderr(t, func() {
- findings = checkStaticcheck(r.Root, "", change)
- })
- if len(findings) != 0 {
- t.Fatalf("got %v, want nothing without a go.mod", findings)
- }
- if want := "skipping staticcheck: no go.mod at the repository root\n"; out != want {
- t.Errorf("stderr %q, want %q", out, want)
- }
-}
-
-func TestStaticcheckSilentWithoutTheBinary(t *testing.T) {
- r := newRepo(t)
- r.write("go.mod", goModule)
- r.write("a.go", withFault)
- r.stage("go.mod", "a.go")
- change, err := Gather("", r.Root)
- if err != nil {
- t.Fatal(err)
- }
- // The path goes once the change is gathered: only the check is meant to
- // look for the binary and find it gone.
- t.Setenv("PATH", "")
- if findings := checkStaticcheck(r.Root, "", change); len(findings) != 0 {
- t.Fatalf("got %v, want nothing without the binary", findings)
- }
-}
-
-func TestStaticcheckSeverity(t *testing.T) {
- for code, want := range map[string]Severity{
- "SA4006": MustFix,
- "S1002": Consider,
- "U1000": Consider,
- "ST1005": Note,
- "QF1003": Note,
- } {
- if got := staticcheckSeverity(code); got != want {
- t.Errorf("%s is %s, want %s", code, got, want)
- }
- }
-}
diff --git a/tests.go b/tests.go
@@ -1,162 +0,0 @@
-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
@@ -1,238 +0,0 @@
-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/odin/tree/tree.odin b/tree/tree.odin
diff --git a/odin/tree/tree_test.odin b/tree/tree_test.odin
diff --git a/tree_test.go b/tree_test.go
@@ -1,86 +0,0 @@
-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
@@ -1,434 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "slices"
- "strconv"
- "strings"
-)
-
-// 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 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 {
- return FeatSymbols | FeatTests | FeatComments | FeatIndex
-}
-
-// tsPatterns is the set of top-level declarations the queries bind. Export
-// status is part of the pattern so a symbol's audience is known to the jobs;
-// the plain forms also match the inner node of an export, which the
-// gatherer resolves by preferring the exported match.
-var tsPatterns = []struct {
- kind string
- exported bool
- pattern string
-}{
- {"value", false, "const $N = $$$V"},
- {"value", true, "export const $N = $$$V"},
- {"value", false, "const $N: $$$T = $$$V"},
- {"value", true, "export const $N: $$$T = $$$V"},
- {"value", false, "let $N = $$$V"},
- {"value", true, "export let $N = $$$V"},
- {"value", true, "export let $N: $$$T = $$$V"},
- {"value", false, "var $N = $$$V"},
- {"value", true, "export var $N = $$$V"},
- {"type", false, "type $N = $$$B"},
- {"type", true, "export type $N = $$$B"},
- {"type", false, "interface $N { $$$B }"},
- {"type", true, "export interface $N { $$$B }"},
- {"type", false, "enum $N { $$$V }"},
- {"type", true, "export enum $N { $$$V }"},
- {"type", false, "class $N { $$$B }"},
- {"type", true, "export class $N { $$$B }"},
- {"func", false, "function $N($$$P) { $$$B }"},
- {"func", true, "export function $N($$$P) { $$$B }"},
- {"func", true, "export function $N($$$P): $$$R { $$$B }"},
- {"func", false, "async function $N($$$P) { $$$B }"},
- {"func", true, "export async function $N($$$P) { $$$B }"},
- {"func", true, "export async function $N($$$P): $$$R { $$$B }"},
- {"func", true, "export default function $N($$$P) { $$$B }"},
- {"func", true, "export default function $N($$$P): $$$R { $$$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 {
- Rule string `json:"ruleId"`
- Text string `json:"text"`
- File string `json:"file"`
- Range struct {
- Start struct {
- Line int `json:"line"`
- } `json:"start"`
- } `json:"range"`
- Meta struct {
- Single map[string]struct {
- Text string `json:"text"`
- } `json:"single"`
- } `json:"metaVariables"`
-}
-
-func (m grepMatch) name() string {
- for _, key := range []string{"N", "S"} {
- if bound, ok := m.Meta.Single[key]; ok {
- return bound.Text
- }
- }
- return ""
-}
-
-// scan materialises the files at the revision into a scratch directory
-// (ast-grep reads the working tree, and a review is a change, not a tree),
-// 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
- }
- dir, err := os.MkdirTemp("", "review-ts")
- if err != nil {
- return nil, err
- }
- defer os.RemoveAll(dir)
-
- if err := os.MkdirAll(filepath.Join(dir, "rules"), 0o755); err != nil {
- return nil, err
- }
- config := "ruleDirs:\n - rules\n"
- if err := os.WriteFile(filepath.Join(dir, "sgconfig.yml"), []byte(config), 0o644); err != nil {
- return nil, err
- }
- if err := rules(filepath.Join(dir, "rules")); err != nil {
- return nil, err
- }
-
- var args []string
- for i, name := range files {
- source, err := at(root, rev, name)
- if err != nil {
- 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, scratchExt(name)))
- if err := os.WriteFile(scratch, source, 0o644); err != nil {
- return nil, err
- }
- args = append(args, scratch)
- }
- cmd := exec.Command("ast-grep", append([]string{"scan", "-c", filepath.Join(dir, "sgconfig.yml"), "--json"}, args...)...)
- stdout, err := cmd.Output()
- if err != nil && len(stdout) == 0 {
- return nil, err // A clean failure; anything else is partial output to read.
- }
- var out []grepMatch
- if err := json.Unmarshal(stdout, &out); err != nil {
- return nil, fmt.Errorf("ast-grep: %w", err)
- }
- for i := range out {
- base := strings.TrimSuffix(filepath.Base(out[i].File), filepath.Ext(out[i].File))
- index, err := strconv.Atoi(base)
- if err == nil && index < len(files) {
- out[i].File = files[index]
- }
- }
- return out, nil
-}
-
-// writeRules emits the query set for both grammars. The top-level rule keeps
-// declarations out of function bodies; the test rules go anywhere.
-func writeRules(dir string) error {
- top := func(pattern string) string {
- return fmt.Sprintf("pattern: %q\nnot:\n inside:\n kind: statement_block\n stopBy: end\n", pattern)
- }
- for _, table := range []struct {
- prefix string
- language string
- patterns []struct {
- kind string
- exported bool
- pattern string
- }
- }{{"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"
- }
- id := fmt.Sprintf("%s-%s-%s-%d", table.prefix, p.kind, status, i)
- body := fmt.Sprintf("id: %s\nlanguage: %s\nseverity: info\nrule:\n %s", id, table.language, strings.ReplaceAll(top(p.pattern), "\n", "\n "))
- if err := os.WriteFile(filepath.Join(dir, id+".yml"), []byte(body), 0o644); err != nil {
- return err
- }
- }
- }
- for _, table := range []struct {
- prefix string
- language string
- }{{"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)
- if err := os.WriteFile(filepath.Join(dir, id+".yml"), []byte(body), 0o644); err != nil {
- return err
- }
- }
- }
- return nil
-}
-
-// Change appends the declarations, tests and prose the added lines of the
-// change's TypeScript files introduce.
-func (g TSFrontend) Change(root, rev string, c *Change, added map[string][]int) error {
- var covered []string
- for _, name := range c.Files {
- if g.Covers(name) {
- covered = append(covered, name)
- }
- }
- matches, err := scan(covered, root, rev)
- if err != nil {
- return err
- }
- sources := map[string][]byte{}
- lines := 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] = source
- lines[name] = strings.Split(string(source), "\n")
- touched[name] = map[int]bool{}
- for _, line := range added[name] {
- touched[name][line] = true
- }
- }
-
- // One declaration, several matches: the plain forms see the inner node of
- // an export. Keep the exported reading of each line.
- best := map[string]grepMatch{}
- for _, m := range matches {
- key := m.File + ":" + fmt.Sprint(m.Range.Start.Line)
- old, ok := best[key]
- if !ok || (strings.Contains(m.Rule, "export") && !strings.Contains(old.Rule, "export")) {
- best[key] = m
- }
- }
- for _, m := range ordered(best) {
- if strings.Contains(m.Rule, "-test-") {
- name := unquote(m.name())
- if name == "" || !touched[m.File][m.Range.Start.Line+1] {
- continue
- }
- c.Tests = append(c.Tests, Function{
- Name: name, File: m.File, Line: m.Range.Start.Line + 1, Body: m.Text,
- })
- continue
- }
- name := m.name()
- if name == "" || !touched[m.File][m.Range.Start.Line+1] {
- continue
- }
- line := m.Range.Start.Line + 1
- signature := ""
- if line-1 < len(lines[m.File]) {
- signature = strings.TrimSpace(lines[m.File][line-1])
- }
- 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])...)
- }
- return nil
-}
-
-// 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) {
- 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 g.Covers(name) {
- files = append(files, name)
- }
- }
- matches, err := scan(files, root, rev)
- if err != nil {
- return nil, err
- }
- best := map[string]grepMatch{}
- for _, m := range matches {
- key := m.File + ":" + fmt.Sprint(m.Range.Start.Line)
- old, ok := best[key]
- if !ok || (strings.Contains(m.Rule, "export") && !strings.Contains(old.Rule, "export")) {
- best[key] = m
- }
- }
- var index []Declared
- for _, m := range ordered(best) {
- name := m.name()
- if name == "" || strings.Contains(m.Rule, "-test-") {
- continue
- }
- line := m.Range.Start.Line + 1
- text := ""
- if source, err := at(root, rev, m.File); err == nil {
- fileLines := strings.Split(string(source), "\n")
- if line-1 < len(fileLines) {
- text = fileLines[line-1]
- }
- }
- 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
-}
-
-// ordered is the best matches in file and line order, so that what is
-// read from them — and the finding anchored on the first of them — is the
-// same on every run.
-func ordered(best map[string]grepMatch) []grepMatch {
- out := make([]grepMatch, 0, len(best))
- for _, m := range best {
- out = append(out, m)
- }
- slices.SortFunc(out, func(a, b grepMatch) int {
- if c := strings.Compare(a.File, b.File); c != 0 {
- return c
- }
- return a.Range.Start.Line - b.Range.Start.Line
- })
- return out
-}
-
-// ruleKind turns a rule id back into the shape the findings report.
-func ruleKind(rule string) string {
- switch {
- case strings.Contains(rule, "-func-"):
- return "func"
- case strings.Contains(rule, "-type-"):
- return "type"
- }
- return "value"
-}
-
-// docAbove collects the comment block ending on the line before the
-// 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, "/*") &&
- (!strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "#!")) {
- break
- }
- trimmed = strings.TrimPrefix(trimmed, "#")
- trimmed = strings.TrimPrefix(trimmed, "///")
- trimmed = strings.TrimPrefix(trimmed, "//")
- trimmed = strings.TrimPrefix(trimmed, "/**")
- trimmed = strings.TrimPrefix(trimmed, "/*")
- trimmed = strings.TrimSuffix(trimmed, "*/")
- part := strings.TrimSpace(strings.TrimPrefix(trimmed, "*"))
- if part != "" {
- parts = append([]string{part}, parts...)
- }
- }
- return strings.Join(parts, " ")
-}
-
-// unquote strips the quotes a string literal keeps in its text.
-func unquote(literal string) string {
- return strings.Trim(literal, "\"'`")
-}
diff --git a/tsfrontend_test.go b/tsfrontend_test.go
@@ -1,293 +0,0 @@
-package main
-
-import (
- "os/exec"
- "slices"
- "strings"
- "testing"
-)
-
-// The TypeScript tests hold the frontend against ast-grep's parsing, an
-// external authority: when it is absent they fail, they do not skip.
-func needAstGrep(t *testing.T) {
- t.Helper()
- if _, err := exec.LookPath("ast-grep"); err != nil {
- t.Fatalf("ast-grep is not installed: %v", err)
- }
-}
-
-func TestTypeScriptReadsDeclarations(t *testing.T) {
- needAstGrep(t)
- r := newRepo(t)
- r.write("src/icons.ts", `export const maxIcons: number = 12;
-const hidden = 4;
-export function readIcons(src: string): Group[] { return []; }
-export default function load(src: string) { return src; }
-export class Reader { size = 0; }
-export interface Wide {}
-export type Group = { id: number };
-export enum Kind { Small = 1 }
-function helper() { return hidden; }
-`)
- r.write("src/.keep", "")
- r.commit("ts: begin", "src/.keep")
- rev := r.commit("ts: first", "src/icons.ts")
-
- 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},
- "load": {"func", true},
- "Reader": {"type", true},
- "Wide": {"type", true},
- "Group": {"type", true},
- "Kind": {"type", true},
- "helper": {"func", false},
- } {
- 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 !strings.HasPrefix(s.Signature, name) && !strings.Contains(s.Signature, name) {
- t.Errorf("%s: signature %q", name, s.Signature)
- }
- }
-}
-
-func TestTypeScriptReadsTestsInsideDescribe(t *testing.T) {
- needAstGrep(t)
- r := newRepo(t)
- r.write("src/icons.test.ts", `import {test, expect} from 'vitest';
-
-describe('icons', () => {
- it('decodes the largest', () => {
- expect(1).toBe(1);
- });
- test('reads a binary', () => {
- expect(2).toBe(2);
- });
-});
-`)
- r.write("src/.keep", "")
- r.commit("ts: begin", "src/.keep")
- rev := r.commit("ts: tests", "src/icons.test.ts")
-
- 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)
- if !strings.Contains(test.Body, "expect") {
- t.Errorf("%s: body %q has no assertion", test.Name, test.Body)
- }
- }
- if len(names) != 2 {
- t.Fatalf("tests %v, want the it and the test", names)
- }
- for _, want := range []string{"decodes the largest", "reads a binary"} {
- if !slices.Contains(names, want) {
- t.Errorf("tests %v, want %q among them", names, want)
- }
- }
-}
-
-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)
- r.write("src/icons.ts", `/** Counts the icons a binary carries.
- * Rejects a binary with none.
- */
-export function countIcons(): number { return 0; }
-`)
- r.write("src/.keep", "")
- r.commit("ts: begin", "src/.keep")
- rev := r.commit("ts: count", "src/icons.ts")
-
- change, err := Gather(rev+"^.."+rev, r.Root)
- if err != nil {
- t.Fatal(err)
- }
- if len(change.Symbols) != 1 {
- t.Fatalf("symbols %v", change.Symbols)
- }
- want := "Counts the icons a binary carries. Rejects a binary with none."
- if change.Symbols[0].Doc != want {
- t.Errorf("doc %q, want %q", change.Symbols[0].Doc, want)
- }
- if len(change.Comments) != 2 {
- t.Fatalf("comments %v, want both comment lines", change.Comments)
- }
- if change.Comments[0].Text != "Counts the icons a binary carries." {
- t.Errorf("comments[0] %q", change.Comments[0].Text)
- }
- if change.Comments[1].Text != "Rejects a binary with none." {
- t.Errorf("comments[1] %q", change.Comments[1].Text)
- }
-}
-
-func TestTypeScriptIndexesTheRepository(t *testing.T) {
- needAstGrep(t)
- r := newRepo(t)
- r.write("src/a.ts", `export const plain = 3;
-const hidden = 4;
-export class Sized { size = 0; }
-function local() {}
-`)
- r.write("src/a.test.ts", `test('local is not a fact with two owners', () => {});
-`)
- r.write("src/.keep", "")
- r.commit("ts: begin", "src/.keep")
- rev := r.commit("ts: index", "src/a.ts", "src/a.test.ts")
-
- declared, err := TSFrontend{}.Whole(r.Root, rev)
- if err != nil {
- t.Fatal(err)
- }
- index := map[string]Declared{}
- for _, d := range declared {
- if _, dup := index[d.Name]; dup {
- t.Errorf("%s indexed twice", d.Name)
- }
- index[d.Name] = d
- }
- if d, ok := index["plain"]; !ok || d.Text != "export const plain = 3;" {
- t.Errorf("plain %v", d)
- }
- if d, ok := index["hidden"]; !ok || d.Text != "const hidden = 4;" {
- t.Errorf("hidden %v", d)
- }
- if d, ok := index["Sized"]; !ok || d.Text != "export class Sized { size = 0; }" {
- t.Errorf("Sized %v", d)
- }
- if _, ok := index["local"]; !ok {
- t.Error("local not indexed")
- }
- if d, ok := index["local"]; ok && d.Text != "function local() {}" {
- t.Errorf("local text %q", d.Text)
- }
- for name := range index {
- if strings.Contains(name, "owners") {
- t.Errorf("test file name %s indexed", name)
- }
- }
-}
-
-// 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)
- }
-}
diff --git a/odin/txt/txt.odin b/txt/txt.odin
diff --git a/verify_test.go b/verify_test.go
@@ -1,179 +0,0 @@
-package main
-
-// The second reading is what stands behind a finding. These tests hold the
-// contract both ways: a verdict that holds keeps the finding and marks it
-// verified, a verdict that falls retracts the finding with its reason, and
-// a verdict that cannot be asked fails open — the finding stands, marked
-// unverified, and the failure joins the others rather than ending the run.
-
-import (
- "context"
- "strings"
- "testing"
-)
-
-func findingsJSON(rules ...string) string {
- var b strings.Builder
- b.WriteString(`{"findings":[`)
- for i, r := range rules {
- if i > 0 {
- b.WriteString(",")
- }
- b.WriteString(`{"rule":"` + r + `","severity":"must-fix","file":"x_test.go","line":4,"message":"m","fix":"f"}`)
- }
- b.WriteString(`]}`)
- return b.String()
-}
-
-func verdictJSON(pairs ...string) string {
- var b strings.Builder
- b.WriteString(`{"verdicts":[`)
- for i, p := range pairs {
- if i > 0 {
- b.WriteString(",")
- }
- b.WriteString(p)
- }
- b.WriteString(`]}`)
- return b.String()
-}
-
-func verifyChange() *Change {
- return &Change{Tests: []Function{{Name: "TestX", File: "x_test.go", Line: 3, Body: "func TestX(t *testing.T) {}"}}}
-}
-
-func TestVerifyLetsAReadingStand(t *testing.T) {
- provider := &relenting{answers: []string{
- findingsJSON("cannot-fail"),
- 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.Failures) != 0 {
- t.Fatalf("failures: %v", result.Failures)
- }
- if len(result.Findings) != 1 || !result.Findings[0].Verified {
- t.Fatalf("got %+v", result.Findings)
- }
- if len(result.Retracted) != 0 {
- t.Fatalf("retracted %+v", result.Retracted)
- }
- // The verdict is asked against the same evidence the finding came from,
- // not against the finding alone.
- if len(provider.asked) != 2 {
- t.Fatalf("asked %d times, want two", len(provider.asked))
- }
- for _, want := range []string{"TestX", "cannot-fail"} {
- if !strings.Contains(provider.asked[1], want) {
- t.Errorf("%q missing from the verdict's evidence:\n%s", want, provider.asked[1])
- }
- }
-}
-
-func TestVerifyRetracts(t *testing.T) {
- provider := &relenting{answers: []string{
- findingsJSON("cannot-fail"),
- verdictJSON(`{"index":0,"holds":false,"reason":"the test cannot fail"}`),
- }}
- jobs, err := chosen("tests")
- if err != nil {
- t.Fatal(err)
- }
- result := Reviewer{Provider: provider, Verify: true}.Run(context.Background(), verifyChange(), jobs)
- if len(result.Failures) != 0 {
- t.Fatalf("failures: %v", result.Failures)
- }
- if len(result.Findings) != 0 {
- t.Fatalf("kept %+v", result.Findings)
- }
- if len(result.Retracted) != 1 {
- t.Fatalf("got %+v", result.Retracted)
- }
- r := result.Retracted[0]
- if r.Reason != "the test cannot fail" || r.Finding.Rule != "cannot-fail" {
- t.Errorf("got %+v", r)
- }
-}
-
-// A verdict that cannot be asked is not a verdict: the findings stand,
-// marked as what they are, and the failure is reported like any other.
-func TestVerifyFailsOpen(t *testing.T) {
- provider := &relenting{answers: []string{findingsJSON("cannot-fail")}}
- 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) != 1 {
- t.Fatalf("kept %+v", result.Findings)
- }
- if result.Findings[0].Verified {
- t.Error("a finding no second reading saw is marked verified")
- }
- if len(result.Failures) != 1 || !strings.Contains(result.Failures[0].Error(), "verify/tests:") {
- t.Fatalf("got %v", result.Failures)
- }
-}
-
-// A verdict list that omits a finding stands rather than falls for it: a
-// strict pass would let one dropped number retract everything.
-func TestVerifyStandsForAnOmittedVerdict(t *testing.T) {
- provider := &relenting{answers: []string{
- findingsJSON("cannot-fail", "skips-in-normal-conditions"),
- 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 {
- t.Fatalf("kept %+v", result.Findings)
- }
- for _, f := range result.Findings {
- if !f.Verified {
- t.Errorf("%s stands unverified", f.Rule)
- }
- }
-}
-
-// A provider that answers the verdict in prose is asked once more, the way
-// the findings are.
-func TestVerifyAsksTwiceForProse(t *testing.T) {
- provider := &relenting{answers: []string{
- findingsJSON("cannot-fail"),
- "The finding holds.",
- verdictJSON(`{"index":0,"holds":false,"reason":"the test can fail"}`),
- }}
- jobs, err := chosen("tests")
- if err != nil {
- t.Fatal(err)
- }
- result := Reviewer{Provider: provider, Verify: true}.Run(context.Background(), verifyChange(), jobs)
- if len(result.Retracted) != 1 || len(result.Findings) != 0 {
- t.Fatalf("got %+v kept, %+v retracted", result.Findings, result.Retracted)
- }
- if len(provider.asked) != 3 {
- t.Fatalf("asked %d times, want three", len(provider.asked))
- }
- if !strings.Contains(provider.asked[2], verdictsAgain) {
- t.Errorf("the retry does not say what was wrong:\n%s", provider.asked[2])
- }
-}
-
-// Without the second reading the findings are what they were read as: seen
-// once, by the reading that reported them.
-func TestVerifyOffLeavesFindingsUnverified(t *testing.T) {
- provider := &relenting{answers: []string{findingsJSON("cannot-fail")}}
- jobs, err := chosen("tests")
- if err != nil {
- t.Fatal(err)
- }
- result := Reviewer{Provider: provider}.Run(context.Background(), verifyChange(), jobs)
- if len(result.Findings) != 1 || result.Findings[0].Verified {
- t.Fatalf("got %+v", result.Findings)
- }
-}