go: types.sx — literal synth + binop + assignability; canonical pitfall handled + 16 tests [shapes-static-types-bidirectional]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 29s

Phase 3 cont. Adds:

  * go-classify-literal-string — heuristic detection of literal kind
    from the value-string (parser strips lexer's kind tag; flagged for
    follow-up to extend AST shape).
  * go-synth-literal — :ty-untyped-int / -float / -imag / -string.
  * go-synth-binop — arithmetic, bitwise, comparison, logical ops with
    untyped-constant unification:
      untyped-int + untyped-float → untyped-float
      untyped + typed              → typed
      comparison ops               → bool
      logical ops                  → bool
  * go-untyped? + go-type-assignable? — pluggable assignability that
    swaps in where structural equality used to gate go-check. Untyped
    int assignable to any numeric type; untyped float assignable to
    float/complex; untyped string to string.

**Canonical Go pitfall handled correctly**: `var x float64 = 42 / 7`
parses to a binop, synth produces :ty-untyped-int (since BOTH operands
are untyped, the int division stays in the int domain), and check
against float64 returns :ok via assignability. Wrong implementations
that float-coerce eagerly would give 6.0; the right behaviour is
"compute 6 as int, then convert to float64 = 6.0".

Verified by test "binop: 42 / 7 assignable to float64 (canonical
pitfall)" and the type-only test "binop: 42 / 7 — untyped int".

Sister-plan static-types-bidirectional diary updated with the
**pluggable-assignable-predicate** kit-API proposal:

  (check-with assignable? CTX EXPR EXPECTED)

Each consumer plugs in its own variance discipline (Go untyped-flow,
TS structural subtyping, Rust lifetime-aware identity) without
rewriting synth or the judgment skeleton.

types 28/28, total 333/333.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 20:46:03 +00:00
parent 6c3b7d1cf9
commit 86ddaf255c
6 changed files with 348 additions and 24 deletions

View File

@@ -1,11 +1,11 @@
{ {
"language": "go", "language": "go",
"total_pass": 317, "total_pass": 333,
"total": 317, "total": 333,
"suites": [ "suites": [
{"name":"lex","pass":129,"total":129,"status":"ok"}, {"name":"lex","pass":129,"total":129,"status":"ok"},
{"name":"parse","pass":176,"total":176,"status":"ok"}, {"name":"parse","pass":176,"total":176,"status":"ok"},
{"name":"types","pass":12,"total":12,"status":"ok"}, {"name":"types","pass":28,"total":28,"status":"ok"},
{"name":"eval","pass":0,"total":0,"status":"pending"}, {"name":"eval","pass":0,"total":0,"status":"pending"},
{"name":"runtime","pass":0,"total":0,"status":"pending"}, {"name":"runtime","pass":0,"total":0,"status":"pending"},
{"name":"stdlib","pass":0,"total":0,"status":"pending"}, {"name":"stdlib","pass":0,"total":0,"status":"pending"},

View File

@@ -1,12 +1,12 @@
# Go-on-SX Scoreboard # Go-on-SX Scoreboard
**Total: 317 / 317 tests passing** **Total: 333 / 333 tests passing**
| | Suite | Pass | Total | | | Suite | Pass | Total |
|---|---|---|---| |---|---|---|---|
| ✅ | lex | 129 | 129 | | ✅ | lex | 129 | 129 |
| ✅ | parse | 176 | 176 | | ✅ | parse | 176 | 176 |
| ✅ | types | 12 | 12 | | ✅ | types | 28 | 28 |
| ⬜ | eval | 0 | 0 | | ⬜ | eval | 0 | 0 |
| ⬜ | runtime | 0 | 0 | | ⬜ | runtime | 0 | 0 |
| ⬜ | stdlib | 0 | 0 | | ⬜ | stdlib | 0 | 0 |

View File

@@ -106,6 +106,91 @@
(list :type-error :unbound "ghost")) (list :type-error :unbound "ghost"))
;; ── report ────────────────────────────────────────────────────── ;; ── report ──────────────────────────────────────────────────────
(go-types-test
"synth: int literal — untyped int"
(gtsy go-ctx-empty "42")
(list :ty-untyped-int))
(go-types-test
"synth: float literal — untyped float"
(gtsy go-ctx-empty "3.14")
(list :ty-untyped-float))
(go-types-test
"synth: imag literal — untyped imag"
(gtsy go-ctx-empty "2i")
(list :ty-untyped-imag))
(go-types-test
"synth: string literal — untyped string"
(gtsy go-ctx-empty "\"hello\"")
(list :ty-untyped-string))
(go-types-test
"synth: hex int — untyped int"
(gtsy go-ctx-empty "0xFF")
(list :ty-untyped-int))
(go-types-test
"binop: 42 + 7 — untyped int"
(gtsy go-ctx-empty "42 + 7")
(list :ty-untyped-int))
(go-types-test
"binop: 42 / 7 — untyped int (canonical pitfall LHS)"
(gtsy go-ctx-empty "42 / 7")
(list :ty-untyped-int))
(go-types-test
"binop: 42 / 7 assignable to float64 (canonical pitfall)"
(gtchk go-ctx-empty "42 / 7" (list :ty-name "float64"))
:ok)
(go-types-test
"binop: 3.14 * 2.0 — untyped float"
(gtsy go-ctx-empty "3.14 * 2.0")
(list :ty-untyped-float))
(go-types-test
"binop: 1 + 2.5 — untyped int + untyped float → untyped float"
(gtsy go-ctx-empty "1 + 2.5")
(list :ty-untyped-float))
(go-types-test
"binop: comparison produces bool"
(gtsy go-ctx-empty "1 < 2")
(list :ty-name "bool"))
(go-types-test
"binop: typed-var + untyped-int — propagates var's type"
(go-synth
(go-ctx-extend go-ctx-empty "x" (list :ty-name "int64"))
(go-parse "x + 1"))
(list :ty-name "int64"))
(go-types-test
"assign: untyped-int → int"
(gtchk go-ctx-empty "42" (list :ty-name "int"))
:ok)
(go-types-test
"assign: untyped-int → float32"
(gtchk go-ctx-empty "42" (list :ty-name "float32"))
:ok)
(go-types-test
"assign: untyped-int → string fails"
(gtchk go-ctx-empty "42" (list :ty-name "string"))
(list
:type-error :mismatch
(list :ty-name "string")
(list :ty-untyped-int)))
(go-types-test
"assign: untyped-string → string"
(gtchk go-ctx-empty "\"hi\"" (list :ty-name "string"))
:ok)
(define (define
go-types-test-summary go-types-test-summary
(str "types " go-types-test-pass "/" go-types-test-count)) (str "types " go-types-test-pass "/" go-types-test-count))

View File

@@ -89,26 +89,214 @@
(define go-type-equal? (fn (a b) (= a b))) (define go-type-equal? (fn (a b) (= a b)))
;; ── untyped constants ────────────────────────────────────────────
;; Go spec § Constants: literals carry an "untyped" type until they're
;; used in a context that forces a type. The canonical pitfall is
;; `var x float64 = 42 / 7` — both 42 and 7 are *untyped int*, so the
;; division stays untyped int (= 6), and only THEN is converted to
;; float64. (Wrong implementations float-coerce first, getting 6.0 from
;; what was meant to round.) The :ty-untyped-* tags below model this.
(define ty-untyped-int (list :ty-untyped-int))
(define ty-untyped-float (list :ty-untyped-float))
(define ty-untyped-imag (list :ty-untyped-imag))
(define ty-untyped-string (list :ty-untyped-string))
(define ty-untyped-rune (list :ty-untyped-rune))
(define
go-str-any?
(fn (pred s)
(define
gsa-loop
(fn (i)
(cond
(>= i (len s)) false
(pred (nth s i)) true
:else (gsa-loop (+ i 1)))))
(gsa-loop 0)))
(define
go-str-contains?
(fn (s ch) (go-str-any? (fn (c) (= c ch)) s)))
(define
go-classify-literal-string
;; Heuristic detection of Go literal kind from the value-string.
;; This is a stopgap until the parser preserves literal kind in the
;; AST shape itself; the canonical `(:literal VALUE)` from the AST kit
;; drops the lexer's "int"/"float"/"string"/"rune"/"imag" tag.
;; Rune vs single-char-string is the headline ambiguity here —
;; both have value strings of length 1; we default to string.
(fn (v)
(cond
(or (not (string? v)) (= (len v) 0)) :string
(or (and (>= (nth v 0) "0") (<= (nth v 0) "9"))
(and (= (nth v 0) ".") (>= (len v) 2)
(>= (nth v 1) "0") (<= (nth v 1) "9")))
(cond
(= (nth v (- (len v) 1)) "i") :imag
(go-str-contains? v ".") :float
(and (or (go-str-contains? v "e") (go-str-contains? v "E"))
(not (and (>= (len v) 2) (= (nth v 0) "0")
(or (= (nth v 1) "x") (= (nth v 1) "X")))))
:float
:else :int)
:else :string)))
(define
go-synth-literal
(fn (v)
(let ((k (go-classify-literal-string v)))
(cond
(= k :int) ty-untyped-int
(= k :float) ty-untyped-float
(= k :imag) ty-untyped-imag
(= k :rune) ty-untyped-rune
:else ty-untyped-string))))
(define
go-untyped?
(fn (t)
(and (list? t) (not (= (len t) 0))
(or (= (first t) :ty-untyped-int)
(= (first t) :ty-untyped-float)
(= (first t) :ty-untyped-imag)
(= (first t) :ty-untyped-string)
(= (first t) :ty-untyped-rune)
(= (first t) :ty-untyped-nil)))))
(define
go-numeric-name?
;; Built-in numeric type names per Go spec § Numeric types.
(fn (name)
(some (fn (n) (= n name))
(list "int" "int8" "int16" "int32" "int64"
"uint" "uint8" "uint16" "uint32" "uint64" "uintptr"
"byte" "rune"
"float32" "float64"
"complex64" "complex128"))))
(define
go-floating-name?
(fn (name)
(or (= name "float32") (= name "float64"))))
(define
go-complex-name?
(fn (name)
(or (= name "complex64") (= name "complex128"))))
(define
go-type-assignable?
;; Can a value of type GOT be assigned to a slot of type EXPECTED?
;; Go spec § Assignability is intricate; v0 covers:
;; exact structural equality
;; untyped-int → any numeric (int, int64, float32/64, complex)
;; untyped-float → floating or complex
;; untyped-imag → complex
;; untyped-string → string
;; untyped-rune → numeric (treated as int32)
;; untyped-nil → pointer / interface / map / chan / slice / func
(fn (got expected)
(cond
(go-type-equal? got expected) true
(and (list? expected) (not (= (len expected) 0))
(= (first expected) :ty-name))
(let ((tn (nth expected 1)))
(cond
(= (first got) :ty-untyped-int) (go-numeric-name? tn)
(= (first got) :ty-untyped-float)
(or (go-floating-name? tn) (go-complex-name? tn))
(= (first got) :ty-untyped-imag) (go-complex-name? tn)
(= (first got) :ty-untyped-rune) (go-numeric-name? tn)
(= (first got) :ty-untyped-string) (= tn "string")
:else false))
:else false)))
;; ── synth ──────────────────────────────────────────────────────── ;; ── synth ────────────────────────────────────────────────────────
(define (define
go-synth go-arith-binops (list "+" "-" "*" "/" "%"))
(fn (define
(ctx expr) go-bitwise-binops (list "&" "|" "^" "<<" ">>" "&^"))
(define
go-compare-binops (list "==" "!=" "<" "<=" ">" ">="))
(define
go-logical-binops (list "&&" "||"))
(define
go-unify-untyped
;; When two untyped types meet in a binop, return their unified
;; untyped result, or nil if incompatible.
(fn (a b)
(cond (cond
(go-type-equal? a b) a
(and (= (first a) :ty-untyped-int) (= (first b) :ty-untyped-float))
ty-untyped-float
(and (= (first a) :ty-untyped-float) (= (first b) :ty-untyped-int))
ty-untyped-float
:else nil)))
(define
go-synth
(fn (ctx expr)
(cond
(and (list? expr) (= (first expr) :literal))
(go-synth-literal (nth expr 1))
(and (list? expr) (= (first expr) :var)) (and (list? expr) (= (first expr) :var))
(let (let ((name (nth expr 1)))
((name (nth expr 1))) (let ((pre (go-predeclared-lookup name)))
(let
((pre (go-predeclared-lookup name)))
(cond (cond
(not (= pre nil)) (not (= pre nil)) pre
pre :else
:else (let (let ((t (go-ctx-lookup ctx name)))
((t (go-ctx-lookup ctx name))) (cond
(cond (= t nil) (list :type-error :unbound name) :else t))))) (= t nil) (list :type-error :unbound name)
:else t)))))
;; (:app (:var OP) [LHS RHS]) — binary operator
(and (list? expr) (= (first expr) :app)
(list? (nth expr 1)) (= (first (nth expr 1)) :var)
(= (len (nth expr 2)) 2))
(let ((op (nth (nth expr 1) 1))
(args (nth expr 2)))
(go-synth-binop ctx op (first args) (nth args 1)))
:else (list :type-error :unsupported-synth expr)))) :else (list :type-error :unsupported-synth expr))))
(define
go-synth-binop
(fn (ctx op lhs rhs)
(let ((lt (go-synth ctx lhs)) (rt (go-synth ctx rhs)))
(cond
(go-type-error? lt) lt
(go-type-error? rt) rt
;; Comparison ops always produce bool (untyped-bool, simplified
;; here to :ty-name "bool" until we model untyped-bool).
(some (fn (o) (= o op)) go-compare-binops)
(list :ty-name "bool")
(some (fn (o) (= o op)) go-logical-binops)
(list :ty-name "bool")
;; Arithmetic / bitwise: types must unify.
(or (some (fn (o) (= o op)) go-arith-binops)
(some (fn (o) (= o op)) go-bitwise-binops))
(cond
(and (go-untyped? lt) (go-untyped? rt))
(let ((unified (go-unify-untyped lt rt)))
(cond
(= unified nil)
(list :type-error :binop-untyped-mismatch op lt rt)
:else unified))
(and (go-untyped? lt) (not (go-untyped? rt)))
(cond
(go-type-assignable? lt rt) rt
:else (list :type-error :binop-mismatch op lt rt))
(and (not (go-untyped? lt)) (go-untyped? rt))
(cond
(go-type-assignable? rt lt) lt
:else (list :type-error :binop-mismatch op lt rt))
(go-type-equal? lt rt) lt
:else (list :type-error :binop-mismatch op lt rt))
:else (list :type-error :unsupported-binop op)))))
;; ── check ──────────────────────────────────────────────────────── ;; ── check ────────────────────────────────────────────────────────
(define (define
@@ -120,6 +308,6 @@
(cond (cond
(go-type-error? got) (go-type-error? got)
got got
(go-type-equal? got expected) (go-type-assignable? got expected)
:ok :else :ok :else
(list :type-error :mismatch expected got))))) (list :type-error :mismatch expected got)))))

View File

@@ -217,11 +217,17 @@ Progress-log line → push `origin/loops/go`.
- [x] Scaffold: `go-synth` / `go-check` skeletons; context-as-value - [x] Scaffold: `go-synth` / `go-check` skeletons; context-as-value
(`go-ctx-empty` / `-extend` / `-lookup` / `-extend-field`); (`go-ctx-empty` / `-extend` / `-lookup` / `-extend-field`);
predeclared `true`/`false`/`nil`; structural type equality. predeclared `true`/`false`/`nil`; structural type equality.
- [ ] Literal kinds in AST (parser change: `(:literal KIND VALUE)`) - [/] Literal synth: heuristic kind detection from value strings
+ literal synth (`:ty-untyped-int`/`-float`/`-string`/`-rune`). (`go-classify-literal-string`) → `:ty-untyped-int`/`-float`/
- [ ] Binary-op synth with untyped-constant flow (canonical pitfall: `-imag`/`-string` (`-rune` deferred — value-shape ambiguous with
`var x float64 = 42 / 7` must compute as untyped int / int = 6, single-char string). Parser-shape change to `(:literal KIND VALUE)`
then convert to float64). flagged as future work; the heuristic stopgap avoids breaking 66
existing parse tests.
- [x] Binary-op synth with untyped-constant flow. **Canonical pitfall
handled**: `42 / 7` synthesises to `:ty-untyped-int`, then checks
successfully against `float64`. Untyped int + untyped float
unifies to untyped float. Typed-var + untyped-int propagates the
var's type. Comparison/logical ops produce `bool`.
- [ ] Var/const declaration checking (`var x T = expr`, `var x = expr`, - [ ] Var/const declaration checking (`var x T = expr`, `var x = expr`,
`const Pi = 3.14`). `const Pi = 3.14`).
- [ ] Function declaration: extend ctx with params via `:field` group, - [ ] Function declaration: extend ctx with params via `:field` group,
@@ -233,7 +239,7 @@ Progress-log line → push `origin/loops/go`.
- [ ] Short variable declaration `:=` (synth RHS into LHS bindings). - [ ] Short variable declaration `:=` (synth RHS into LHS bindings).
- Defer: generics (Phase 7), full conversion rules, type assertions, - Defer: generics (Phase 7), full conversion rules, type assertions,
type switches. type switches.
- **Acceptance:** types/ suite at 60+ tests. Current: 12/12. Chisel note - **Acceptance:** types/ suite at 60+ tests. Current: 28/28. Chisel note
`shapes-static-types-bidirectional` — sister-plan design diary is the `shapes-static-types-bidirectional` — sister-plan design diary is the
cross-language record. cross-language record.
@@ -534,6 +540,18 @@ Minimal repro: see `lib/go/lex.sx#gl-oct-digit?` and `#gl-match-op`.
_Newest first. Append one dated entry per commit._ _Newest first. Append one dated entry per commit._
- 2026-05-27 — Phase 3 cont.: literal synth + binop synth + assignability.
Heuristic `go-classify-literal-string` decodes the parser's untagged
literal values back into `:int`/`:float`/`:imag`/`:string` kinds
(rune defers); these become `:ty-untyped-*` types. `go-synth-binop`
handles arithmetic / bitwise / comparison / logical operators with
untyped-constant unification: untyped int + untyped float → untyped
float; untyped + typed → typed. **Canonical Go pitfall now handled**:
`42 / 7` synthesises to `:ty-untyped-int`, then `go-check` against
`float64` returns `:ok` via `go-type-assignable?`. +16 tests, types
28/28, total 333/333. `[shapes-static-types-bidirectional]` — sister
plan diary updated with the assignable-relation insight (kit's
`check` should accept a `subtype?`/`assignable?` predicate parameter).
- 2026-05-27 — **Phase 3 scaffold.** First `lib/go/types.sx` cut: context - 2026-05-27 — **Phase 3 scaffold.** First `lib/go/types.sx` cut: context
as an association list (`go-ctx-empty` + `-extend` + `-lookup`), a as an association list (`go-ctx-empty` + `-extend` + `-lookup`), a
load-bearing `go-ctx-extend-field` that consumes the `:field` binding- load-bearing `go-ctx-extend-field` that consumes the `:field` binding-

View File

@@ -282,6 +282,39 @@ The kits compose; design accordingly.
_Newest first. Append one dated entry per milestone landed._ _Newest first. Append one dated entry per milestone landed._
- 2026-05-27 — Follow-up from Phase 3 scaffold: **assignability** has
landed as a separate relation from structural equality. Go's
untyped-constant flow (`var x float64 = 42 / 7` — 42/7 stays untyped
int, then converts to float64) is one instance of a broader pattern:
the value's "natural" type isn't quite the slot's type, but they're
compatible under a per-language relation.
**Design insight for the kit**: `check` should *not* call `equal?`
on the synthesised vs expected types. It should call a pluggable
`assignable?` predicate that each consumer supplies:
```
(check CTX EXPR EXPECTED) →
let GOT = (synth CTX EXPR)
if (assignable? GOT EXPECTED) :ok else (:mismatch EXPECTED GOT)
```
Go's `assignable?` handles untyped constants → numeric-type
conversion. TS would supply structural subtyping (`{a: number, b:
string}` assignable to `{a: number}`). Rust supplies lifetime-aware
type identity with implicit `&T -> &U` where `T: Deref<U>`. None of
the consumers need to rewrite synth or the judgment skeleton — only
swap in their variance discipline.
Concretely the kit interface looks like:
```
(check-with assignable? CTX EXPR EXPECTED) — kit primitive
```
Source: Go-on-SX commit landing `go-type-assignable?` in
`lib/go/types.sx`.
- 2026-05-27 — From Go-on-SX Phase 3 scaffold (`lib/go/types.sx` first - 2026-05-27 — From Go-on-SX Phase 3 scaffold (`lib/go/types.sx` first
cut): the **independent synth/check shape** has landed. Two judgments, cut): the **independent synth/check shape** has landed. Two judgments,
both consuming a context-as-value: both consuming a context-as-value: