- commit
- d9b2c7e181e41b023880b0dc0fa34cf9d7d39a8b
- parent
- 909d761740b4ca7998b1321f25168282cca96bfe
- parent
- 23151feaef383a3a622c34c5223c43d5e606754b
- author
- Dami <dami.etoile@gmail.com>
| A | CLAUDE.md | 23 | ++++ |
| M | cmd/ | 4 | +- |
| M | cmd/ | 16 | --- |
| M | docs/ | 2 | +- |
| M | docs/ | 7 | +- |
| M | docs/ | 2 | +- |
| M | docs/ | 6 | + |
| M | internal/ | 1 | - |
| M | internal/ | 10 | -- |
| M | internal/ | 4 | +- |
| M | internal/ | 14 | --- |
| M | internal/ | 3 | +- |
| M | internal/ | 8 | - |
| M | internal/ | 9 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 2 | +- |
| A | scripts/ | 163 | ++++++++++++++++++++++++++++++ |
| M | scripts/ | 19 | +++- |
| A | staticcheck.conf | 4 | + |
@@ -0,0 +1,23 @@
1+# Working on 0122+3+Read [docs/UX.md](docs/UX.md), [docs/extending.md](docs/extending.md) and4+[docs/testing.md](docs/testing.md) before changing code.5+6+## No broken windows7+8+Noise that stays becomes invisible, and then real problems hide in it.9+10+- `make check` passes before every push: gofmt, vet, `make lint`11+ (staticcheck in every module, doclint, cognitive complexity at most 25,12+ Go files at most 500 lines), unit tests, the excelize oracle and the13+ libghostty e2e tests. A red check is fixed first, not worked around.14+- A warning is fixed where it points, or turned off in its tool's config15+ with the reason written next to it (`staticcheck.conf`). Nothing is left16+ standing because "it's always been there".17+- Docs and comments describe the code as it is and why. How it got there18+ (what changed, what it did before, before and after numbers, which commit <!-- doclint:allow: names the phrases the rule forbids -->19+ or merge, who did it) belongs in commit messages; doclint enforces this.20+ Saying why the code stays compatible with a planned change is fine: that21+ explains the code today. Plans go in ROADMAP.md.22+- No scratch files, debug tests or temporary samples in commits.23+- Visual changes are reviewed in the golden gallery (`make screens`).@@ -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@@ -2,7 +2,6 @@ package main
22 33 import (44 "log/slog"5- "os"65 "runtime/debug"76 "time"87 @@ -23,21 +22,6 @@ func telemetryConfig(c *config.Config) telemetry.Config {
2322 return tc2423 }2524 26-// telemetryFlags takes the telemetry flags (--log, --otlp) out of args,27-// and reads the rest of telemetry's settings from the config file and28-// environment. 012 serve uses it before parsing its own flags.29-func telemetryFlags(args []string) (telemetry.Config, []string, error) {30- flags, rest, err := config.ParseFlags(args)31- if err != nil {32- return telemetry.Config{}, nil, err33- }34- path, err := config.DefaultPath()35- if err != nil {36- return telemetry.Config{}, nil, err37- }38- return telemetryConfig(config.Load(path, os.Getenv, flags)), rest, nil39-}40-4125 // startTelemetry opens the log and reports every recalculation and pivot4226 // refresh to it, as spans nested in their workbook's trace.4327 func startTelemetry(c telemetry.Config) (func() error, error) {@@ -201,7 +201,7 @@ and returns plain values instead:
201201 The engine fills a chunk from the occupancy index without a call per cell202202 (`colFill`, `rangeFill`), and keeps each sheet's reader between203203 recalculations, so the buffers are made once. See204-[limits.md](limits.md#the-function-library) for before and after.204+[limits.md](limits.md#the-function-library) for its costs.205205 206206 What stays in the engine is what needs cells or the workbook: the207207 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@@ -93,3 +93,9 @@ It needs vhs 0.12+, ttyd and ffmpeg. The JEV tape talks to `demos/fakejev`, a
9393 local stand-in the target starts on 127.0.0.1, never the real service. On9494 macOS, `demos/lib/ttyd` wraps ttyd so Alt+letter reaches the app. VHS has no9595 mouse commands, so the tapes use the keyboard.96+97+## No broken windows98+99+`make check` must pass before a push, and warnings are fixed or turned100+off with a written reason rather than left standing. The rules are in101+[CLAUDE.md](../CLAUDE.md).@@ -102,16 +102,6 @@ func (w *xlsxWriter) sheet(bw *bufio.Writer, ws string, snap *Snapshot, active b
102102 return r.To.Row - r.From.Row + 1103103 }104104 105-func mapKeys(m map[int]int) func(func(int) bool) {106- return func(yield func(int) bool) {107- for k := range m {108- if !yield(k) {109- return110- }111- }112- }113-}114-115105 // cell appends snapshot cell c at a as a <c> element. A formula's value116106 // is written as its cached result; a formula with no Excel equivalent,117107 // or naming a sheet that isn't written, is written as its value alone,@@ -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@@ -41,8 +41,9 @@ func TestKeysAreDistinct(t *testing.T) {
4141 m[l] = l + "!"4242 }4343 c := sheet.RemoteCall{Kind: "choice", State: "x", Instructions: "q", Criteria: m}44+ want := Key(c)4445 for range 20 {45- if Key(c) != Key(c) {46+ if got := Key(c); got != want {4647 t.Fatal("map order changed the key")4748 }4849 }@@ -130,14 +130,6 @@ func (o *occupancy) colsIn(c0, c1 int) []int {
130130 return o.colIDs[i:j]131131 }132132 133-// colCells is the number of cells stored in column c.134-func (o *occupancy) colCells(c int) int {135- if ci := o.col(c); ci != nil {136- return ci.n137- }138- return 0139-}140-141133 // blockIDs returns the numbers from b0 to b1 of the blocks that any of142134 // cols has, ascending.143135 func (o *occupancy) blockIDs(cols []int, b0, b1 int) []int {@@ -83,10 +83,9 @@ func LookupFunc(name string) (*FuncDef, bool) { return functions.LookupFunc(name
8383 // Funcs returns every function, sorted by name.8484 func Funcs() []*FuncDef { return functions.Funcs() }8585 86-func num(v float64) Value { return value.Num(v) }87-func boolean(b bool) Value { return value.Boolean(b) }88-func toNum(v Value) (float64, *Value) { return value.ToNum(v) }89-func text(v Value) string { return value.AsText(v) }90-func compare(l, r Value) int { return value.Compare(l, r) }86+func num(v float64) Value { return value.Num(v) }87+func boolean(b bool) Value { return value.Boolean(b) }88+func text(v Value) string { return value.AsText(v) }89+func compare(l, r Value) int { return value.Compare(l, r) }9190 9291 func clampInt(v, lo, hi int) int { return max(lo, min(v, hi)) }@@ -210,7 +210,7 @@ func TestChartImages(t *testing.T) {
210210 t.Errorf("unchanged chart sent again")211211 }212212 press(t, m, "<f5>", "B2", "<enter>", "99", "<enter>")213- _, cmd = m.Update(nil)213+ m.Update(nil)214214 raw = nil215215 m.term.sent[firstImageID] = "stale"216216 collect(m.term.syncImages(m.sheet, m.displayCharts, &m.th, m.spans))@@ -72,7 +72,7 @@ func (t *terminal) handle(msg tea.Msg) tea.Cmd {
7272 // Ask for the cell size (for image proportions) and the7373 // palette colors charts draw with.7474 var q strings.Builder75- q.WriteString(ansi.WindowOp(ansi.RequestCellSizeWinOp))75+ q.WriteString(ansi.WindowOp(16)) // 16: report the cell size in pixels7676 for i := range 16 {7777 fmt.Fprintf(&q, "\x1b]4;%d;?\x07", i)7878 }@@ -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, "``") }@@ -1,16 +1,31 @@
11 2-# Code shape limits, checked by make lint:2+# Checked by make lint:3+# - staticcheck, with staticcheck.conf, in every module34 # - no function with cognitive complexity over 25 (gocognit)45 # - no Go file over 500 lines5-# gocognit runs with go run at a pinned version, so it never enters go.mod.6+# The tools run with go run at pinned versions, so they never enter go.mod.67 set -eu78 cd "$(dirname "$0")/.."89 910 GOCOGNIT=github.com/uudashr/gocognit/cmd/gocognit@v1.2.111+STATICCHECK=honnef.co/go/tools/cmd/staticcheck@2026.2.11012 MAX_COGNIT=251113 MAX_LINES=5001214 fail=01315 16+# Warnings are fixed, or turned off in staticcheck.conf with a reason;17+# none are left standing.18+for mod in . oracle e2e; do19+ if [ "$mod" = e2e ] && [ -z "${PKG_CONFIG_PATH:-}" ]; then20+ PKG_CONFIG_PATH="$(pwd)/.deps/ghostty/share/pkgconfig"21+ export PKG_CONFIG_PATH22+ fi23+ if ! (cd "$mod" && go run $STATICCHECK ./...); then24+ echo "staticcheck: findings in $mod"25+ fail=126+ fi27+done28+1429 dirs=$(find cmd internal demos e2e oracle -name '*.go' -exec dirname {} \; | sort -u)1530 over=$(go run $GOCOGNIT -over $MAX_COGNIT $dirs || true)1631 if [ -n "$over" ]; then