From ec15097c9f58dfa4603239727fcb42aea987589c Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Wed, 17 Jun 2026 12:06:19 +1000 Subject: [PATCH] feat(cli): add wide-glyph width-contract lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A wide-glyph writer — (*Frame).SetWide / (*Frame).SetGraphemeWide (internal/game/grid.go ~128-173) — reserves TWO terminal columns for one glyph and never measures it: width-2 is the author's contract. If the base code point's East-Asian-Width is not Wide (W) or Fullwidth (F), the terminal advances the cursor by one column while the SDK reserved two, desyncing every column to that cell's right. This is the bug that corrupted the pokies reels in production (a keycap base U+0037, EAW Neutral, fed to a wide writer). Add `shellcade-kit lint-width ...`: it parses Go game source with go/parser, finds wide-writer calls whose base code point is a determinable literal (a rune literal for SetWide, a string literal's first code point for SetGraphemeWide), reports each base whose EAW is not W/F with file:line, and exits non-zero on any violation. Non-literal bases are skipped — the lint reports only what it can prove. The EAW judgement embeds the W/F ranges of the UCD EastAsianWidth.txt; the repo has no general EAW classifier to reuse (host/render only hard-codes a single Fullwidth fold + ASCII fallback) and the public module is deliberately dependency-free. `shellcade-kit check ` now runs this lint over the Go source before the build + conformance run, since a width-contract desync never faults and so cannot be observed by conformance alone. Table-driven tests cover the EAW classifier, the table's sort invariant, and the file-level lint against a passing and a failing fixture; the failing fixture includes the exact pokies keycap base. Co-Authored-By: Claude Opus 4.8 --- .changeset/wide-glyph-width-lint.md | 23 +++ README.md | 2 + cmd/shellcade-kit/eaw.go | 133 ++++++++++++ cmd/shellcade-kit/lintwidth.go | 192 ++++++++++++++++++ cmd/shellcade-kit/lintwidth_test.go | 113 +++++++++++ cmd/shellcade-kit/main.go | 28 ++- .../testdata/widthlint/fail.go.txt | 14 ++ .../testdata/widthlint/pass.go.txt | 21 ++ 8 files changed, 523 insertions(+), 3 deletions(-) create mode 100644 .changeset/wide-glyph-width-lint.md create mode 100644 cmd/shellcade-kit/eaw.go create mode 100644 cmd/shellcade-kit/lintwidth.go create mode 100644 cmd/shellcade-kit/lintwidth_test.go create mode 100644 cmd/shellcade-kit/testdata/widthlint/fail.go.txt create mode 100644 cmd/shellcade-kit/testdata/widthlint/pass.go.txt diff --git a/.changeset/wide-glyph-width-lint.md b/.changeset/wide-glyph-width-lint.md new file mode 100644 index 0000000..a1055e1 --- /dev/null +++ b/.changeset/wide-glyph-width-lint.md @@ -0,0 +1,23 @@ +--- +"kit": minor +--- + +feat(cli): lint game source for wide-glyph width-contract violations + +`shellcade-kit lint-width ...` parses Go game source and flags every +wide-glyph writer call — `(*Frame).SetWide` / `(*Frame).SetGraphemeWide` — whose +base code point is a determinable literal but is NOT East-Asian-Width Wide (W) +or Fullwidth (F). Such a base reserves two terminal columns for a glyph the +terminal advances by one, desyncing every column to its right — the bug that +corrupted the pokies reels in production (a keycap base `U+0037`, EAW Neutral, +fed to a wide writer). Each violation is reported with `file:line` and the +offending code point, and the command exits non-zero on any violation, so it is +a one-command merge gate. + +`shellcade-kit check ` now runs this lint over the Go source before the +build + conformance run, since a width-contract desync never faults and so +cannot be observed by conformance alone. + +The EAW judgement embeds the Wide/Fullwidth ranges of the Unicode Character +Database `EastAsianWidth.txt`; the public module stays dependency-free. Additive +only: the guest SDK and the ABI are unchanged. diff --git a/README.md b/README.md index 3cbb97f..4986667 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ Rules of the road: ## Test and play shellcade-kit check game.wasm # ABI handshake, meta, scripted room + shellcade-kit check . # + lint Go source for wide-glyph width-contract bugs, then build & check + shellcade-kit lint-width . # that source lint on its own (file/dir paths; no build) shellcade-kit play game.wasm # play it in this terminal (Esc to leave) # flags: --seed N --heartbeat 50ms --config key=value --seats N diff --git a/cmd/shellcade-kit/eaw.go b/cmd/shellcade-kit/eaw.go new file mode 100644 index 0000000..eec34ff --- /dev/null +++ b/cmd/shellcade-kit/eaw.go @@ -0,0 +1,133 @@ +package main + +// East-Asian-Width classification for the wide-glyph lint. +// +// This is the SAME width judgement the host renderer's terminal makes: a code +// point counts as TWO terminal columns iff its East-Asian-Width is Wide (W) or +// Fullwidth (F). The repo has no UCD/EAW machinery to reuse (its only width +// logic, host/render, hard-codes a single Fullwidth fold and an ASCII-fallback +// table — not a general classifier), and the public SDK module is deliberately +// dependency-free, so rather than pull in golang.org/x/text/width or hand-roll a +// full UCD parser we embed the Wide+Fullwidth ranges of EastAsianWidth.txt +// directly. eaw2cols below is sorted and binary-searched; everything not in it +// is treated as a single-column class (Na/N/A/H), which is exactly the set the +// wide writers must REFUSE. +// +// Source: Unicode Character Database, EastAsianWidth.txt — the rows tagged ;W +// and ;F, coalesced into [lo,hi] ranges. Covers CJK, Hangul, fullwidth forms, +// the wide emoji blocks, and the regional/flag pictographs. + +import "sort" + +// eawRange is an inclusive code-point range that is EAW Wide or Fullwidth. +type eawRange struct { + lo, hi rune + wide bool // true = Wide (W); false = Fullwidth (F) +} + +// eaw2cols are the EAW W and F ranges — the code points a wide (width-2) writer +// may legally carry as its base. Kept sorted by lo for binary search. +var eaw2cols = []eawRange{ + {0x1100, 0x115F, true}, // Hangul Jamo + {0x231A, 0x231B, true}, // ⌚⌛ watch/hourglass + {0x2329, 0x232A, true}, // 〈 〉 angle brackets + {0x23E9, 0x23EC, true}, // ⏩-⏬ media + {0x23F0, 0x23F0, true}, // ⏰ alarm clock + {0x23F3, 0x23F3, true}, // ⏳ hourglass + {0x25FD, 0x25FE, true}, // ◽◾ small squares + {0x2614, 0x2615, true}, // ☔☕ + {0x2648, 0x2653, true}, // zodiac + {0x267F, 0x267F, true}, // ♿ wheelchair + {0x2693, 0x2693, true}, // ⚓ anchor + {0x26A1, 0x26A1, true}, // ⚡ high voltage + {0x26AA, 0x26AB, true}, // ⚪⚫ circles + {0x26BD, 0x26BE, true}, // ⚽⚾ ball + {0x26C4, 0x26C5, true}, // ⛄⛅ snowman/cloud + {0x26CE, 0x26CE, true}, // ⛎ ophiuchus + {0x26D4, 0x26D4, true}, // ⛔ no entry + {0x26EA, 0x26EA, true}, // ⛪ church + {0x26F2, 0x26F3, true}, // ⛲⛳ fountain/flag + {0x26F5, 0x26F5, true}, // ⛵ sailboat + {0x26FA, 0x26FA, true}, // ⛺ tent + {0x26FD, 0x26FD, true}, // ⛽ fuel pump + {0x2705, 0x2705, true}, // ✅ check mark + {0x270A, 0x270B, true}, // ✊✋ fist/hand + {0x2728, 0x2728, true}, // ✨ sparkles + {0x274C, 0x274C, true}, // ❌ cross mark + {0x274E, 0x274E, true}, // ❎ negative cross + {0x2753, 0x2755, true}, // ❓❔❕ + {0x2757, 0x2757, true}, // ❗ exclamation + {0x2763, 0x2764, true}, // ❣❤ heart exclamation / heavy black heart + {0x2795, 0x2797, true}, // ➕➖➗ + {0x27B0, 0x27B0, true}, // ➰ curly loop + {0x27BF, 0x27BF, true}, // ➿ double curly loop + {0x2B1B, 0x2B1C, true}, // ⬛⬜ large squares + {0x2B50, 0x2B50, true}, // ⭐ star + {0x2B55, 0x2B55, true}, // ⭕ large circle + {0x2E80, 0x303E, true}, // CJK radicals, Kangxi, CJK symbols/punctuation + {0x3041, 0x33FF, true}, // Hiragana..CJK compatibility + {0x3400, 0x4DBF, true}, // CJK Ext A + {0x4E00, 0x9FFF, true}, // CJK Unified Ideographs + {0xA000, 0xA4CF, true}, // Yi + {0xA960, 0xA97F, true}, // Hangul Jamo Extended-A + {0xAC00, 0xD7A3, true}, // Hangul Syllables + {0xF900, 0xFAFF, true}, // CJK Compatibility Ideographs + {0xFE10, 0xFE19, true}, // Vertical forms + {0xFE30, 0xFE6F, true}, // CJK compatibility/small forms + {0xFF01, 0xFF60, false}, // Fullwidth Forms (incl. 7 — the pokies offender's COUSIN) + {0xFFE0, 0xFFE6, false}, // Fullwidth signs + {0x16FE0, 0x16FE4, true}, // Tangut/Khitan iteration marks + {0x17000, 0x18AFF, true}, // Tangut, Khitan small script + {0x1AFF0, 0x1B16F, true}, // Kana extensions + {0x1F004, 0x1F004, true}, // 🀄 mahjong + {0x1F0CF, 0x1F0CF, true}, // 🃏 joker + {0x1F18E, 0x1F18E, true}, // 🆎 + {0x1F191, 0x1F19A, true}, // 🆑-🆚 + {0x1F200, 0x1F320, true}, // squared CJK, weather/emoji + {0x1F32D, 0x1F335, true}, // food/plant emoji + {0x1F337, 0x1F37C, true}, + {0x1F37E, 0x1F393, true}, + {0x1F3A0, 0x1F3CA, true}, + {0x1F3CF, 0x1F3D3, true}, + {0x1F3E0, 0x1F3F0, true}, + {0x1F3F4, 0x1F3F4, true}, + {0x1F3F8, 0x1F43E, true}, + {0x1F440, 0x1F440, true}, + {0x1F442, 0x1F4FC, true}, // many emoji incl. 💎 gem, 🔔 bell, 🍒 cherry block neighbours + {0x1F4FF, 0x1F53D, true}, + {0x1F54B, 0x1F54E, true}, + {0x1F550, 0x1F567, true}, // clock faces + {0x1F57A, 0x1F57A, true}, + {0x1F595, 0x1F596, true}, + {0x1F5A4, 0x1F5A4, true}, + {0x1F5FB, 0x1F64F, true}, // landmarks, faces, gestures + {0x1F680, 0x1F6C5, true}, // transport + {0x1F6CC, 0x1F6CC, true}, + {0x1F6D0, 0x1F6D2, true}, + {0x1F6D5, 0x1F6D7, true}, + {0x1F6DC, 0x1F6DF, true}, + {0x1F6EB, 0x1F6EC, true}, + {0x1F6F4, 0x1F6FC, true}, + {0x1F7E0, 0x1F7EB, true}, // colored circles + {0x1F7F0, 0x1F7F0, true}, + {0x1F90C, 0x1F93A, true}, + {0x1F93C, 0x1F945, true}, + {0x1F947, 0x1F9FF, true}, // medals, food, faces, objects + {0x1FA70, 0x1FAFF, true}, + {0x20000, 0x2FFFD, true}, // CJK Ext B..F (plane 2) + {0x30000, 0x3FFFD, true}, // CJK Ext G (plane 3) +} + +// eawClass reports a code point's relevant East-Asian-Width class and whether it +// is a two-column (Wide/Fullwidth) class. The class string is for the report: +// "W", "F", or "Na/N/A/H" for everything that occupies a single column. +func eawClass(r rune) (class string, wide bool) { + i := sort.Search(len(eaw2cols), func(i int) bool { return eaw2cols[i].hi >= r }) + if i < len(eaw2cols) && r >= eaw2cols[i].lo && r <= eaw2cols[i].hi { + if eaw2cols[i].wide { + return "W", true + } + return "F", true + } + return "Na/N/A/H", false +} diff --git a/cmd/shellcade-kit/lintwidth.go b/cmd/shellcade-kit/lintwidth.go new file mode 100644 index 0000000..1922c8e --- /dev/null +++ b/cmd/shellcade-kit/lintwidth.go @@ -0,0 +1,192 @@ +package main + +// The wide-glyph width-contract lint. The SDK's wide-glyph writers +// — (*Frame).SetWide and (*Frame).SetGraphemeWide (see internal/game/grid.go +// ~128-173) — reserve TWO terminal columns for one glyph and never measure the +// glyph they are handed: width-2 is the AUTHOR's contract. If the base code +// point's East-Asian-Width is not Wide (W) or Fullwidth (F), the terminal +// advances the cursor by ONE column while the SDK reserved two, desyncing every +// column to that cell's right for the rest of the row. This is the bug that +// corrupted the pokies reels in production (a keycap base code point — EAW +// Neutral — fed to a wide writer; see the grapheme-width-contract lesson). +// +// `shellcade-kit lint-width ...` parses game source with go/parser, finds +// calls to the wide writers whose base code point is a determinable literal +// (a rune literal for SetWide, a string literal for SetGraphemeWide), and +// reports each base code point whose EAW is not W/F with file:line. It exits +// non-zero when any violation is found, so it is a one-command merge gate. +// +// Calls whose base code point is not a literal (a variable, a function result) +// are skipped — the lint reports only what it can prove, never guesses. + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "sort" + "strconv" +) + +// wideWriters are the SDK method names whose first text argument (the third +// positional argument, after row, col) carries the base code point that MUST be +// EAW Wide or Fullwidth. SetWide takes a rune; SetGraphemeWide takes a cluster +// string whose FIRST code point is the base. +var wideWriters = map[string]struct{}{ + "SetWide": {}, + "SetGraphemeWide": {}, +} + +// widthViolation is one offending wide-glyph call: the base code point that is +// neither EAW W nor F, located at file:line. +type widthViolation struct { + File string + Line int + Fn string // the writer name (SetWide / SetGraphemeWide) + Base rune // the offending base code point + EAW string // its East-Asian-Width class, for the report +} + +func (v widthViolation) String() string { + return fmt.Sprintf("%s:%d: %s base %s (East-Asian-Width %s) is not Wide or Fullwidth — a width-2 writer desyncs every column to its right", + v.File, v.Line, v.Fn, describeRune(v.Base), v.EAW) +} + +// describeRune renders a code point as U+XXXX 'x' for the report. +func describeRune(r rune) string { + if strconv.IsPrint(r) { + return fmt.Sprintf("U+%04X %q", r, r) + } + return fmt.Sprintf("U+%04X", r) +} + +// lintWidth scans every Go source file reachable from the given paths (files are +// scanned directly; directories are walked) for wide-glyph width-contract +// violations, prints each to stdout, and returns an error (non-zero exit) when +// any violation was found. +func lintWidth(paths []string) error { + var files []string + for _, p := range paths { + info, err := os.Stat(p) + if err != nil { + return err + } + if info.IsDir() { + err = filepath.WalkDir(p, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if filepath.Ext(path) == ".go" { + files = append(files, path) + } + return nil + }) + if err != nil { + return err + } + } else if filepath.Ext(p) == ".go" { + files = append(files, p) + } + } + + var violations []widthViolation + for _, f := range files { + vs, err := lintWidthFile(f) + if err != nil { + return err + } + violations = append(violations, vs...) + } + + sort.Slice(violations, func(i, j int) bool { + if violations[i].File != violations[j].File { + return violations[i].File < violations[j].File + } + return violations[i].Line < violations[j].Line + }) + for _, v := range violations { + fmt.Println(v) + } + if n := len(violations); n > 0 { + return fmt.Errorf("%d wide-glyph width-contract violation(s)", n) + } + fmt.Printf("lint-width: OK — %d file(s) scanned, no width-contract violations\n", len(files)) + return nil +} + +// lintWidthFile parses one Go file and returns its width-contract violations. +func lintWidthFile(path string) ([]widthViolation, error) { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + var violations []widthViolation + ast.Inspect(file, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + if _, ok := wideWriters[sel.Sel.Name]; !ok { + return true + } + // Signature is (row, col, glyph, style); the base code point is arg 2. + if len(call.Args) < 3 { + return true + } + base, ok := baseCodepoint(call.Args[2]) + if !ok { + return true // not a determinable literal — report only what we can prove + } + if class, wide := eawClass(base); !wide { + pos := fset.Position(call.Pos()) + violations = append(violations, widthViolation{ + File: path, Line: pos.Line, Fn: sel.Sel.Name, Base: base, EAW: class, + }) + } + return true + }) + return violations, nil +} + +// baseCodepoint extracts the base code point from a wide writer's glyph +// argument when it is a compile-time literal: a CHAR literal yields its rune, +// and a STRING literal yields its first decoded code point (the grapheme +// cluster's base). Returns ok=false for any non-literal expression. +func baseCodepoint(arg ast.Expr) (rune, bool) { + lit, ok := arg.(*ast.BasicLit) + if !ok { + return 0, false + } + switch lit.Kind { + case token.CHAR: + s, err := strconv.Unquote(lit.Value) + if err != nil { + return 0, false + } + for _, r := range s { // exactly one rune in a valid char literal + return r, true + } + return 0, false + case token.STRING: + s, err := strconv.Unquote(lit.Value) + if err != nil { + return 0, false + } + for _, r := range s { // first code point is the cluster base + return r, true + } + return 0, false // empty string: no base + } + return 0, false +} diff --git a/cmd/shellcade-kit/lintwidth_test.go b/cmd/shellcade-kit/lintwidth_test.go new file mode 100644 index 0000000..04af47d --- /dev/null +++ b/cmd/shellcade-kit/lintwidth_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// TestEAWClass pins the EAW judgement at the contract boundary: a code point is +// a legal wide-writer base iff it is W or F, and the pokies offenders (ASCII +// digit, keycap base) are correctly single-column. +func TestEAWClass(t *testing.T) { + cases := []struct { + name string + r rune + want bool // is W or F (a legal wide base) + }{ + {"ASCII seven (the keycap base that corrupted pokies)", '7', false}, + {"ASCII A", 'A', false}, + {"halfwidth katakana", 0xFF61, false}, + {"CJK 個", '個', true}, + {"fullwidth seven 7", '7', true}, + {"umbrella ☔", '☔', true}, + {"heart ❤", '❤', true}, + {"gem 💎", '\U0001F48E', true}, + {"CJK Ext B", '\U00020000', true}, + {"low boundary just below Hangul Jamo", 0x10FF, false}, + {"Hangul Jamo low boundary", 0x1100, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if _, wide := eawClass(c.r); wide != c.want { + t.Errorf("eawClass(%U) wide=%v, want %v", c.r, wide, c.want) + } + }) + } +} + +// TestEAWTableSorted guards the binary-search invariant: eaw2cols must be +// strictly ascending and non-overlapping, or eawClass would silently misclassify. +func TestEAWTableSorted(t *testing.T) { + for i := 1; i < len(eaw2cols); i++ { + prev, cur := eaw2cols[i-1], eaw2cols[i] + if prev.lo > prev.hi { + t.Errorf("range %d is inverted: %U..%U", i-1, prev.lo, prev.hi) + } + if cur.lo <= prev.hi { + t.Errorf("ranges %d and %d overlap or are out of order: %U..%U then %U..%U", + i-1, i, prev.lo, prev.hi, cur.lo, cur.hi) + } + } +} + +// TestLintWidthFile is the table-driven file-level test: the passing fixture +// yields zero violations, the failing fixture flags exactly the bad bases. +func TestLintWidthFile(t *testing.T) { + cases := []struct { + fixture string + wantBases []rune // expected offending base code points, in source order + }{ + {"testdata/widthlint/pass.go.txt", nil}, + {"testdata/widthlint/fail.go.txt", []rune{'7', 'A', '7', 'x'}}, + } + for _, c := range cases { + t.Run(c.fixture, func(t *testing.T) { + vs, err := lintWidthFile(c.fixture) + if err != nil { + t.Fatalf("lintWidthFile(%s): %v", c.fixture, err) + } + if len(vs) != len(c.wantBases) { + t.Fatalf("got %d violations %v, want %d", len(vs), vs, len(c.wantBases)) + } + for i, want := range c.wantBases { + if vs[i].Base != want { + t.Errorf("violation %d base = %U, want %U", i, vs[i].Base, want) + } + if vs[i].Line == 0 { + t.Errorf("violation %d has no line number", i) + } + } + }) + } +} + +// TestLintWidthExitsNonZeroOnViolation drives the top-level entry point exactly +// as the CLI dispatch does: a clean tree returns nil, a dirty tree returns an +// error (the non-zero exit), and directory walking finds the .go file. +func TestLintWidthExitsNonZeroOnViolation(t *testing.T) { + clean := writeGo(t, `package g +import "github.com/shellcade/kit/v2" +func d(f *kit.Frame) { f.SetWide(0, 0, '個', kit.Style{}) }`) + if err := lintWidth([]string{clean}); err != nil { + t.Fatalf("clean dir should pass, got %v", err) + } + + dirty := writeGo(t, `package g +import "github.com/shellcade/kit/v2" +func d(f *kit.Frame) { f.SetWide(0, 0, '7', kit.Style{}) }`) + if err := lintWidth([]string{dirty}); err == nil { + t.Fatal("dirty dir should fail (non-zero exit), got nil") + } +} + +// writeGo writes src to a real .go file in a fresh temp dir and returns the dir, +// so lintWidth's directory walk (which filters on the .go extension) sees it. +func writeGo(t *testing.T, src string) string { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "game.go"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + return dir +} diff --git a/cmd/shellcade-kit/main.go b/cmd/shellcade-kit/main.go index e5c2ad7..2032010 100644 --- a/cmd/shellcade-kit/main.go +++ b/cmd/shellcade-kit/main.go @@ -2,7 +2,8 @@ // // shellcade-kit version print kit/ABI compatibility info // shellcade-kit new [--rust] [--license ID] scaffold a complete, catalog-submittable kit game -// shellcade-kit check run the conformance harness (limits ON) + print a report +// shellcade-kit check lint source (Go dirs) + run the conformance harness (limits ON) +// shellcade-kit lint-width ... lint source for wide-glyph width-contract violations only // shellcade-kit play [flags] play the game in a local 80x24 terminal room // shellcade-kit smoke run the game's smoke.yaml and write the shot files // @@ -25,6 +26,7 @@ import ( "fmt" "log/slog" "os" + "path/filepath" "runtime/debug" "strings" "time" @@ -86,6 +88,12 @@ func main() { fmt.Fprintln(os.Stderr, "FAIL:", err) os.Exit(1) } + case "lint-width": + // lint-width ...: source lint, no build — accepts files and dirs. + if err := lintWidth(os.Args[2:]); err != nil { + fmt.Fprintln(os.Stderr, "lint-width:", err) + os.Exit(1) + } case "meta": if err := printMeta(path); err != nil { fmt.Fprintln(os.Stderr, "shellcade-kit:", err) @@ -137,7 +145,7 @@ func printVersion() { } func usage() { - fmt.Fprintln(os.Stderr, "usage: shellcade-kit version | new [--rust] [--license ID] | check | meta | play [flags] | smoke [--out dir]") + fmt.Fprintln(os.Stderr, "usage: shellcade-kit version | new [--rust] [--license ID] | check | lint-width ... | meta | play [flags] | smoke [--out dir]") os.Exit(2) } @@ -217,8 +225,22 @@ func newRoom(path string, seed int64, seedSet bool, heartbeat time.Duration, cfg // returns an error (non-zero exit) when any budget verdict fails. The // argument may be a built .wasm or the game directory (built first), so // `shellcade-kit check .` is the one-command merge-gate rehearsal for any -// source language. +// source language. For a Go game directory it first runs the wide-glyph +// width-contract lint over the source (see lintWidth) — a class of corruption +// conformance cannot observe because it never faults. func check(arg string, requireLeaderboard bool) error { + // Source-level gate first: a wide-glyph width-contract violation desyncs + // every column to its right (the pokies production corruption) yet never + // faults, so conformance alone cannot catch it. When the argument is a Go + // game directory, lint its source before the (slower) build+conformance run + // so the offending base code point is reported up front. Rust source is not + // AST-scanned here; the standalone `lint-width` covers explicit paths. + if info, statErr := os.Stat(arg); statErr == nil && info.IsDir() && exists(filepath.Join(arg, "go.mod")) { + if err := lintWidth([]string{arg}); err != nil { + return err + } + } + path, _, cleanup, err := resolveArtifact(arg) if err != nil { return err diff --git a/cmd/shellcade-kit/testdata/widthlint/fail.go.txt b/cmd/shellcade-kit/testdata/widthlint/fail.go.txt new file mode 100644 index 0000000..905023b --- /dev/null +++ b/cmd/shellcade-kit/testdata/widthlint/fail.go.txt @@ -0,0 +1,14 @@ +// Failing fixture for the wide-glyph width-contract lint: each wide writer below +// is handed a base code point that is NOT East-Asian-Width Wide/Fullwidth, so it +// reserves two columns for a glyph the terminal advances by one — the desync +// that corrupted the pokies reels in production. +package widthlint + +import "github.com/shellcade/kit/v2" + +func draw(f *kit.Frame) { + f.SetWide(0, 0, '7', kit.Style{}) // U+0037 ASCII seven — EAW Na + f.SetWide(1, 0, 'A', kit.Style{}) // U+0041 — EAW Na + f.SetGraphemeWide(2, 0, "7️⃣", kit.Style{}) // keycap: base U+0037 — EAW Na (the pokies bug) + f.SetGraphemeWide(3, 0, "x", kit.Style{}) // U+0078 — EAW Na +} diff --git a/cmd/shellcade-kit/testdata/widthlint/pass.go.txt b/cmd/shellcade-kit/testdata/widthlint/pass.go.txt new file mode 100644 index 0000000..c933d05 --- /dev/null +++ b/cmd/shellcade-kit/testdata/widthlint/pass.go.txt @@ -0,0 +1,21 @@ +// Passing fixture for the wide-glyph width-contract lint: every wide writer is +// handed a base code point whose East-Asian-Width is Wide or Fullwidth, so the +// reserved two columns match what the terminal advances. +// +// (Stored as .go.txt so `go build ./...`/`go vet ./...` over the command don't +// try to compile a fixture with no real package; the lint parses it as Go.) +package widthlint + +import "github.com/shellcade/kit/v2" + +func draw(f *kit.Frame) { + f.SetWide(0, 0, '個', kit.Style{}) // U+500B CJK — EAW W + f.SetWide(1, 0, '7', kit.Style{}) // U+FF17 fullwidth seven — EAW F + f.SetWide(2, 0, '☔', kit.Style{}) // U+2614 umbrella — EAW W + f.SetGraphemeWide(3, 0, "❤️", kit.Style{}) // U+2764 base — EAW W + f.SetGraphemeWide(4, 0, "💎", kit.Style{}) // U+1F48E gem — EAW W + + // Non-literal bases are unprovable and intentionally NOT flagged. + r := '7' + f.SetWide(5, 0, r, kit.Style{}) +}