cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Add doclint and fix what it finds outside limits.md

Dami committed 2a8d678
doclint reads Go comments and Markdown prose for wording that narrates
development (what changed, what it used to do, what's temporary, who
changed it) instead of describing the code. Also drops jev.LoadConfig, a
shim nothing called.
commit
2a8d678f2f02b9af1dadc0f32649424cbbdb8401
parent
ec408020c239ffc8692a5001b49383fabec67ee6
author
Dami <dami.etoile@gmail.com>
8 files changed, +174 -24
Mcmd/012/main.go4+-
Mdocs/architecture.md2+-
Mdocs/extending.md7+-
Mdocs/jev.md2+-
Minternal/functions/reader.go4+-
Minternal/jev/apikey.go14---
Minternal/ui/pivoteditor.go2+-
Ascripts/doclint/main.go163++++++++++++++++++++++++++++++
M cmd/012/main.go +2 -2
M docs/architecture.md +1 -1
M docs/extending.md +4 -3
M docs/jev.md +1 -1
M internal/functions/reader.go +2 -2
M internal/jev/apikey.go +0 -14
M internal/ui/pivoteditor.go +1 -1
A scripts/doclint/main.go +163 -0
@@ -0,0 +1,163 @@
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+// Usage: go run ./scripts/doclint [dirs or files...]14+package main15+16+import (17+ "bufio"18+ "fmt"19+ "go/parser"20+ "go/token"21+ "io/fs"22+ "os"23+ "path/filepath"24+ "regexp"25+ "strings"26+)27+28+// rules are the phrasings that describe development rather than the code.29+var rules = []struct {30+ re *regexp.Regexp31+ why string32+ markdown bool // only in Markdown, where the phrase can't be about code33+ notTests bool // not in tests, which narrate the steps they take34+}{35+ {re: re(`\bno longer (read|supported|used|needed|exists?|works?|possible|available|goes|runs)\b`), why: "describes a past state"},36+ {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"},37+ {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},38+ {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"},39+ {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"},40+ {re: regexp.MustCompile(`\b(TODO|FIXME|XXX|HACK)\b`), why: "a work note; put it in ROADMAP.md instead"},41+ {re: regexp.MustCompile(`\(new\)|\bnew in v?\d|\bas of v\d|\bsince v\d`), why: "dates the text"},42+ {re: re(`\bbefore (and|->|→|/) after\b|\bthis (commit|change|PR)\b|\bin the commit that\b`), why: "narrates a change", markdown: true},43+ {re: re(`\b(the lead|an? agent|merged with main|merge of main)\b`), why: "narrates who changed it"},44+}45+46+func re(s string) *regexp.Regexp { return regexp.MustCompile(`(?i)` + s) }47+48+// skip lists paths whose purpose is history or plans: the roadmap tracks49+// what's done and ahead, NOTICE and LICENSE are legal text.50+var skip = map[string]bool{"ROADMAP.md": true, "NOTICE": true, "LICENSE": true}51+52+type finding struct {53+ file string54+ line int55+ text string56+ why string57+}58+59+func main() {60+ roots := os.Args[1:]61+ if len(roots) == 0 {62+ roots = []string{"."}63+ }64+ var found []finding65+ for _, root := range roots {66+ filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {67+ if err != nil {68+ return err69+ }70+ name := d.Name()71+ if d.IsDir() {72+ if path != root && (strings.HasPrefix(name, ".") || name == "testdata" || name == "node_modules") {73+ return filepath.SkipDir74+ }75+ return nil76+ }77+ if skip[filepath.ToSlash(path)] || skip[name] {78+ return nil79+ }80+ switch filepath.Ext(name) {81+ case ".go":82+ found = append(found, goComments(path)...)83+ case ".md":84+ found = append(found, markdown(path)...)85+ }86+ return nil87+ })88+ }89+ for _, f := range found {90+ fmt.Printf("%s:%d: %s: %s\n", f.file, f.line, f.why, strings.TrimSpace(f.text))91+ }92+ if len(found) > 0 {93+ fmt.Printf("doclint: %d lines describe development rather than the code\n", len(found))94+ os.Exit(1)95+ }96+}97+98+// check reports the rules text breaks, unless it's allowed.99+func check(file string, line int, text string) []finding {100+ if strings.Contains(text, "doclint:allow") {101+ return nil102+ }103+ md := strings.HasSuffix(file, ".md")104+ test := strings.HasSuffix(file, "_test.go")105+ var out []finding106+ for _, r := range rules {107+ if r.markdown && !md || r.notTests && test {108+ continue109+ }110+ if r.re.MatchString(text) {111+ out = append(out, finding{file, line, text, r.why})112+ break113+ }114+ }115+ return out116+}117+118+func goComments(path string) []finding {119+ fset := token.NewFileSet()120+ f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)121+ if err != nil {122+ return nil // not ours to judge; the compiler reports it123+ }124+ var out []finding125+ for _, g := range f.Comments {126+ for _, c := range g.List {127+ line := fset.Position(c.Pos()).Line128+ for i, l := range strings.Split(c.Text, "\n") {129+ out = append(out, check(path, line+i, l)...)130+ }131+ }132+ }133+ return out134+}135+136+func markdown(path string) []finding {137+ f, err := os.Open(path)138+ if err != nil {139+ return nil140+ }141+ defer f.Close()142+ var out []finding143+ fenced := false144+ sc := bufio.NewScanner(f)145+ sc.Buffer(make([]byte, 1<<20), 1<<20)146+ for n := 1; sc.Scan(); n++ {147+ l := sc.Text()148+ if strings.HasPrefix(strings.TrimSpace(l), "```") {149+ fenced = !fenced150+ continue151+ }152+ if fenced {153+ continue154+ }155+ out = append(out, check(path, n, stripCode(l))...)156+ }157+ return out158+}159+160+var inlineCode = regexp.MustCompile("`[^`]*`")161+162+// stripCode drops inline code spans, which quote code rather than narrate.163+func stripCode(l string) string { return inlineCode.ReplaceAllString(l, "``") }