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>
| M | cmd/ | 4 | +- |
| M | docs/ | 2 | +- |
| M | docs/ | 7 | +- |
| M | docs/ | 2 | +- |
| M | internal/ | 4 | +- |
| M | internal/ | 14 | --- |
| M | internal/ | 2 | +- |
| A | scripts/ | 163 | ++++++++++++++++++++++++++++++ |
@@ -133,8 +133,8 @@ func jevConfig(c *config.Config, key string) jev.Config {
133133 }134134 135135 // startJEV finds the API key and connects, returning notes for the136-// context line: why the key couldn't be read, or that a .env file's key137-// is no longer read.136+// context line: why the key couldn't be read, or that a .env file holds137+// a key, which 012 doesn't read.138138 //139139 // jev-api-key-command runs only when the environment and the credential140140 // store have no key, and then only when a sheet first asks JEV@@ -198,7 +198,7 @@ and returns plain values instead:
198198 The engine fills a chunk from the occupancy index without a call per cell199199 (`colFill`, `rangeFill`), and keeps each sheet's reader between200200 recalculations, so the buffers are made once. See201-[limits.md](limits.md#the-function-library) for before and after.201+[limits.md](limits.md#the-function-library) for its costs.202202 203203 What stays in the engine is what needs cells or the workbook: the204204 running aggregates' storage and extension (`rangememo.go`), the links a@@ -169,7 +169,8 @@ builds can safely ignore (decimal arithmetic) don't raise the version.
169169 170170 Every seam above has a telemetry span and a `-tags stress` benchmark171171 (`make stress`, `make stress-report`), and [limits.md](limits.md) records172-the bounds. A change to a seam reports before and after numbers.172+the bounds. A change to a seam comes with its numbers, measured against173+the code it replaces.173174 174175 Spans nest without a `context.Context` through the engine: start a UI175176 span through the model's trace (`m.spans.Start`), so it holds what it@@ -184,8 +185,8 @@ global: `012 serve` runs many programs in one process (see
184185 `make lint`: no function over cognitive complexity 25, no Go file over 500185186 lines. A package that keeps growing past a few thousand lines gets split186187 along a boundary where dependencies point one way, as the function187-library (`internal/functions`) and values (`internal/value`) were split188-from the engine: the lower package defines the small interface it needs188+library (`internal/functions`) and values (`internal/value`) sit below189+the engine: the lower package defines the small interface it needs189190 (`functions.Book`) and the engine implements it, keeping its API through190191 aliases so callers don't change.191192 @@ -65,7 +65,7 @@ environment only: `jev-base-url` (or `TYPESAFE_BASE_URL`), which must be
6565 https unless it's this machine, and `jev-model` (or6666 `TYPESAFE_DEFAULT_MODEL`). See [config.md](config.md).6767 68-**`.env` files are no longer read.** A `.env` next to a downloaded sheet68+**012 doesn't read `.env` files.** A `.env` next to a downloaded sheet6969 could set `TYPESAFE_BASE_URL` and send your key to someone else's server.7070 If the directory 012 starts in has a `.env` with `TYPESAFE_API_KEY`, the7171 context line suggests `012 config set-key`; 012 never reads the key from@@ -8,8 +8,8 @@ import "github.com/FelineStateMachine/012/internal/value"
88 //99 // Every method takes and returns plain values, never a callback: a1010 // function value or pointer passed through an interface escapes to the11-// heap, so a callback per range read would cost an allocation where the12-// engine used to cost none. Ranges are read instead in chunks, into11+// heap, so a callback per range read would cost an allocation that an12+// engine reading its own cells doesn't pay. Ranges are read instead in chunks, into1313 // buffers the Reader keeps and reuses (Scan), and each function walks1414 // them with callbacks of its own that stay on the stack.1515 type Book interface {@@ -144,20 +144,6 @@ func DotEnvHasKey(dir string) bool {
144144 return false145145 }146146 147-// LoadConfig reads TYPESAFE_API_KEY, TYPESAFE_BASE_URL and148-// TYPESAFE_DEFAULT_MODEL from the environment only, reporting false when149-// there's no key or the base URL isn't https (or localhost). The dirs150-// are ignored: .env files are no longer read. New code resolves the key151-// with ResolveKey and takes the rest from internal/config.152-func LoadConfig(dirs ...string) (Config, bool) {153- c := Config{APIKey: strings.TrimSpace(os.Getenv(KeyEnv)), BaseURL: strings.TrimSpace(os.Getenv("TYPESAFE_BASE_URL")),154- Model: strings.TrimSpace(os.Getenv("TYPESAFE_DEFAULT_MODEL"))}155- if c.BaseURL != "" && config.CheckBaseURL(c.BaseURL) != nil {156- return c, false157- }158- return c, c.APIKey != ""159-}160-161147 // Lazy is a Client that connects the first time a question is asked,162148 // with connect, and remembers the result, error included. 012 uses it for163149 // jev-api-key-command, so a password manager is only asked for the key@@ -109,7 +109,7 @@ func (e *pivotEditor) pivot(m pivotHost) sheet.Pivot {
109109 return p110110 }111111 112-// items lists the editor's lines for the pivot as it is now.112+// items lists the editor's lines for the pivot's current definition.113113 func (e *pivotEditor) items(m pivotHost) []pivotItem {114114 p := e.pivot(m)115115 items := []pivotItem{{kind: itemSource}}@@ -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, "``") }