Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/wide-glyph-width-lint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"kit": minor
---

feat(cli): lint game source for wide-glyph width-contract violations

`shellcade-kit lint-width <path>...` 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 <gamedir>` 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.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
133 changes: 133 additions & 0 deletions cmd/shellcade-kit/eaw.go
Original file line number Diff line number Diff line change
@@ -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
}
192 changes: 192 additions & 0 deletions cmd/shellcade-kit/lintwidth.go
Original file line number Diff line number Diff line change
@@ -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 <path>...` 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
}
Loading
Loading