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{}) +}