1// Command doclint keeps documentation about the code as it is. It reads2// Go comments and Markdown prose (not code, strings or code blocks) and3// reports wording that narrates how the code got here: what changed,4// what it did before, what's temporary, who did it and when. History5// belongs in commit messages; docs and comments say what is and why.6//7// Saying why something stays compatible with a planned change is fine8// ("kept a pointer so the store can change shape"), since it explains9// the code today. A line that must use a flagged word says so with10// "doclint:allow" and a reason, in a comment on that line (or an HTML11// comment in Markdown).12//13// It also refuses a code block that draws 012's screen as text: a14// picture of 012 is a still drawn from a golden screen.15//16// Usage: go run ./scripts/doclint [dirs or files...]17package main1819import (20 "bufio"21 "fmt"22 "go/parser"23 "go/token"24 "io/fs"25 "os"26 "path/filepath"27 "regexp"28 "strings"29)3031// rules are the phrasings that describe development rather than the code.32var rules = []struct {33 re *regexp.Regexp34 why string35 markdown bool // only in Markdown, where the phrase can't be about code36 notTests bool // not in tests, which narrate the steps they take37}{38 {re: re(`\bno longer (read|supported|used|needed|exists?|works?|possible|available|goes|runs)\b`), why: "describes a past state"},39 {re: re(`\bused to (be|have|take|go|cost|read|write|live|run|do|make|scan|walk|say|show)\b|\bpreviously\b|\bformerly\b|\boriginally\b`), why: "describes a past state"},40 {re: regexp.MustCompile(`\b(it|this|that|they|which|012|[a-z]+s) now (uses?|takes?|goes|lives?|sits?|keeps?|returns?|comes?|reads?|writes?|has|have|is|are|gets?|works?)\b|\b(is|are) now\b`), why: `"now" narrates a change`, notTests: true},41 {re: re(`\b(was|were|has been|have been) (moved|renamed|replaced|rewritten|split|merged)\b|\b(moved|renamed) (from|out of|into)\b`), why: "narrates a change"},42 {re: re(`\bfor now\b|\btemporarily\b|\bat the moment\b|\bfor the time being\b|\buntil (it|this|that) lands\b`), why: "describes a transient state"},43 {re: regexp.MustCompile(`\b(TODO|FIXME|XXX|HACK)\b`), why: "a work note; put it in ROADMAP.md instead"},44 {re: regexp.MustCompile(`\(new\)|\bnew in v?\d|\bas of v\d|\bsince v\d`), why: "dates the text"},45 {re: re(`\bbefore (and|->|→|/) after\b|\bthis (commit|change|PR)\b|\bin the commit that\b`), why: "narrates a change", markdown: true},46 // Agents are a feature of 012, so the phrase alone is fine; an agent47 // that added or fixed something is the code's history.48 {re: re(`\b(the lead|merged with main|merge of main)\b|\ban? agent (added|wrote|changed|fixed|built|removed|moved|renamed|rewrote|made)\b`), why: "narrates who changed it"},49}5051func re(s string) *regexp.Regexp { return regexp.MustCompile(`(?i)` + s) }5253// skip lists paths whose purpose is history or plans: the roadmap tracks54// what's done and ahead, NOTICE and LICENSE are legal text.55var skip = map[string]bool{"ROADMAP.md": true, "NOTICE": true, "LICENSE": true}5657type finding struct {58 file string59 line int60 text string61 why string62}6364func main() {65 roots := os.Args[1:]66 if len(roots) == 0 {67 roots = []string{"."}68 }69 var found []finding70 for _, root := range roots {71 filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {72 if err != nil {73 return err74 }75 name := d.Name()76 if d.IsDir() {77 if path != root && (strings.HasPrefix(name, ".") || name == "testdata" || name == "node_modules") {78 return filepath.SkipDir79 }80 return nil81 }82 if skip[filepath.ToSlash(path)] || skip[name] {83 return nil84 }85 switch filepath.Ext(name) {86 case ".go":87 found = append(found, goComments(path)...)88 case ".md":89 found = append(found, markdown(path)...)90 }91 return nil92 })93 }94 for _, f := range found {95 fmt.Printf("%s:%d: %s: %s\n", f.file, f.line, f.why, strings.TrimSpace(f.text))96 }97 if len(found) > 0 {98 fmt.Printf("doclint: %d findings\n", len(found))99 os.Exit(1)100 }101}102103// check reports the rules text breaks, unless it's allowed.104func check(file string, line int, text string) []finding {105 if strings.Contains(text, "doclint:allow") {106 return nil107 }108 md := strings.HasSuffix(file, ".md")109 test := strings.HasSuffix(file, "_test.go")110 var out []finding111 for _, r := range rules {112 if r.markdown && !md || r.notTests && test {113 continue114 }115 if r.re.MatchString(text) {116 out = append(out, finding{file, line, text, r.why})117 break118 }119 }120 return out121}122123func goComments(path string) []finding {124 fset := token.NewFileSet()125 f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)126 if err != nil {127 return nil // not ours to judge; the compiler reports it128 }129 var out []finding130 for _, g := range f.Comments {131 for _, c := range g.List {132 line := fset.Position(c.Pos()).Line133 for i, l := range strings.Split(c.Text, "\n") {134 out = append(out, check(path, line+i, l)...)135 }136 }137 }138 return out139}140141func markdown(path string) []finding {142 f, err := os.Open(path)143 if err != nil {144 return nil145 }146 defer f.Close()147 var out []finding148 fenced, start := false, 0149 var block []string150 sc := bufio.NewScanner(f)151 sc.Buffer(make([]byte, 1<<20), 1<<20)152 for n := 1; sc.Scan(); n++ {153 l := sc.Text()154 if strings.HasPrefix(strings.TrimSpace(l), "```") {155 if fenced && drawnScreen(block) {156 out = append(out, finding{path, start, "```", screenWhy})157 }158 fenced, start, block = !fenced, n, nil159 continue160 }161 if fenced {162 block = append(block, l)163 continue164 }165 out = append(out, check(path, n, stripCode(l))...)166 }167 return out168}169170// screenWhy is what a code block drawing 012's screen is told.171const screenWhy = "a code block drawing 012's screen; show a still of it instead (docs/contributing/site.md#pictures-of-012)"172173var (174 boxDrawing = regexp.MustCompile(`[\x{2500}-\x{257F}]`)175 // chrome is the menu bar or the notebook's toolbar.176 chrome = regexp.MustCompile(`File\s+Edit\s+View|▶ Run|■ Stop`)177 // columnHeaders are the grid's column letters over its cells.178 columnHeaders = regexp.MustCompile(`(?m)^\s*A\s{2,}B\s{2,}C(\s|$)`)179)180181// drawnScreen reports whether a code block's lines draw 012's screen as182// text: box drawing with the menu bar or the toolbar, or the grid's183// column headers. Command output (tables a shell prints, logs) is none184// of these.185func drawnScreen(lines []string) bool {186 text := strings.Join(lines, "\n")187 return boxDrawing.MatchString(text) && chrome.MatchString(text) || columnHeaders.MatchString(text)188}189190var inlineCode = regexp.MustCompile("`[^`]*`")191192// stripCode drops inline code spans, which quote code rather than narrate.193func stripCode(l string) string { return inlineCode.ReplaceAllString(l, "``") }