From db22cd346bc8aa0d1ebc22d97cada9779611ca7c Mon Sep 17 00:00:00 2001 From: Glenn Fiedler Date: Sat, 5 Sep 2026 07:18:07 +1000 Subject: [PATCH 1/9] wip: unbounded arrays, the front end and the ir (uncommitted at the stall) --- internal/ast/ast.go | 4 ++ internal/check/check.go | 11 +++++ internal/check/tablelist.go | 73 ++++++++++++++++++++++++++++ internal/parser/parser.go | 23 ++++++++- ir/ir.go | 8 ++++ ir/table.go | 8 ++++ ir/tablekind.go | 2 + ir/tablelist.go | 96 +++++++++++++++++++++++++++++++++++++ 8 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 internal/check/tablelist.go create mode 100644 ir/tablelist.go diff --git a/internal/ast/ast.go b/internal/ast/ast.go index 7515096e3..a2037e16e 100644 --- a/internal/ast/ast.go +++ b/internal/ast/ast.go @@ -153,6 +153,10 @@ const ( ArrayFixed ArrayKind = iota // [N]T ArrayUpTo // [..N]T — sugar for [0..N] (SPEC §4.3) ArrayRange // [Min..N]T + // ArrayList is `[]T` — an UNBOUNDED ARRAY, a counted array whose count + // the data decides (docs/SPEC-TABLES.md §2.9). The bracket carries no + // expression, so Lo and Hi are both nil. + ArrayList ) type ArrayBound struct { diff --git a/internal/check/check.go b/internal/check/check.go index 7f118cd14..cef0d6d7e 100644 --- a/internal/check/check.go +++ b/internal/check/check.go @@ -1240,6 +1240,17 @@ func (c *checker) resolveField(owner string, f *ast.Field, inTable bool) *ir.Fie c.errf(f.Pos, "an array of %s is not supported in v1 — wrap the element in a type", scalarName(out.Type.Kind)) return nil } + // `placements []Placement` — an UNBOUNDED ARRAY + // (docs/SPEC-TABLES.md §2.9). The ELEMENT is an array element like any + // other, so it has already resolved through the path above and the + // bounded array's own refusals have already fired; what is left is the + // construct's own rules, and there is no bound to evaluate. + if f.Array.Kind == ast.ArrayList { + if !c.checkListSpelling(f, inTable) { + return nil + } + out.Array = ir.ArrayList + } else { // an ENUM-KEYED array: the bound NAMES a declared enum rather than // evaluating to a count — `ships [ShipType]ShipConfig`, one slot per // variant, indexed by the variant (docs/SPEC-TABLES.md §2.4) diff --git a/internal/check/tablelist.go b/internal/check/tablelist.go new file mode 100644 index 000000000..b8f66b790 --- /dev/null +++ b/internal/check/tablelist.go @@ -0,0 +1,73 @@ +// Unbounded arrays in the checker (docs/SPEC-TABLES.md §2.9): the `[]T` +// spelling, the placements it takes and the refusals §11 states, each by name. +// +// An unbounded array is §2.8's map with the KEY and the SORT taken out, so +// almost nothing here is new machinery: the ELEMENT resolves through the +// ordinary array path, so whatever `[..N]T` admits `[]T` admits and whatever +// `[..N]T` refuses `[]T` refuses on the bounded array's own diagnostic. What +// this file adds is the placements the construct is refused in and the +// qualifications it does not take. +package check + +import ( + "github.com/mas-bandwidth/schema/v2/internal/ast" + "github.com/mas-bandwidth/schema/v2/ir" +) + +// checkListSpelling holds every rule a `[]T` field carries that is not the +// element's own (docs/SPEC-TABLES.md §2.9, §11). It runs BEFORE the element is +// resolved, so a refused placement draws one diagnostic rather than the +// element's too, and it returns false when the field is refused. +func (c *checker) checkListSpelling(f *ast.Field, inTable bool) bool { + if !inTable { + c.errf(f.Pos, "field %s: an UNBOUNDED ARRAY is a table-only construct — a `type`'s wire is positional and same-or-refuse, so nothing riding it can be unbounded; hold the []T in a `table` body, or bound it as [..N]T (docs/SPEC-TABLES.md §2.9, §11)", + f.Name) + return false + } + if c.arm != "" { + // AN ARM MAY NOT (docs/SPEC-TABLES.md §2.9, §2.6, §11), on the ground + // a `map` is refused there: both put their elements in the holder's + // NODE EXTENT, and an arm's storage is overlaid, so the extent would + // depend on the union's tag + c.errf(f.Pos, "%s: a []T is not an arm — its elements live in the holder's NODE EXTENT and an arm's storage is OVERLAID, so the extent would depend on the tag; wrap the list in a table and make THAT the arm, which is the refusal a map takes here on the same ground (docs/SPEC-TABLES.md §2.9, §2.6, §11)", + c.arm) + return false + } + if f.Type.Optional { + c.errf(f.Pos, "field %s: there is no ?[]T — a fresh list is empty, an empty list is elided, and its count's zero IS its absence, so a presence bit beside it would be two answers to one question; drop the ? (docs/SPEC-TABLES.md §2.9, §11)", + f.Name) + return false + } + if f.Default != nil { + c.errf(f.Pos, "field %s: a []T takes no specified default — a fresh list is empty, and empty is the only value a default could name (docs/SPEC-TABLES.md §2.9, §11)", + f.Name) + return false + } + for i := range f.Attrs { + a := &f.Attrs[i] + switch a.Key { + case "was", "json": + // a list is renamed under `was` as any field is, and takes a + // `json` key as any field does: both are about the field, not the + // construct (docs/SPEC-TABLES.md §2.9, §5, §16.4) + default: + c.errf(a.Pos, "field %s: %s does not apply to an unbounded array — THE COUNT IS THE DATA'S, and a bound would buy only a CLAMP, which drops a tail; drop the qualification, or declare [..N]%s, which is the same bytes with a bound (docs/SPEC-TABLES.md §2.9, §11)", + f.Name, a.Key, scalarSpelling(f.Type)) + return false + } + } + return true +} + +// listClaims registers the three names an unbounded array CLAIMS against its +// field (docs/SPEC-TABLES.md §2.9, §11): `` followed by the PascalCase +// of the field's name, then `Add`, `Each` or `Erase`. It claims THREE where a +// map claims eight, and the difference is the key on both sides. +func listClaims(table string, f *ir.Field) []string { + base := table + ir.GoExportName(f.Name) + out := make([]string, 0, len(ir.ListFieldVerbs)) + for _, verb := range ir.ListFieldVerbs { + out = append(out, base+verb) + } + return out +} diff --git a/internal/parser/parser.go b/internal/parser/parser.go index 5fddd1450..604d70eba 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -606,15 +606,36 @@ func (p *parser) parseArrayBound() *ast.ArrayBound { p.advance() b.Kind = ast.ArrayUpTo b.Hi = p.parseExpr() + case scanner.RBrack: + // [] — an UNBOUNDED ARRAY (docs/SPEC-TABLES.md §2.9). The bracket is + // the one every extent uses and an EMPTY bracket is the absence of an + // extent, which is what the construct is. + b.Kind = ast.ArrayList case scanner.DotDot: - // [..N] — sugar for [0..N], reads "up to N" (SPEC §4.3) p.advance() + if p.kind() == scanner.RBrack { + // [..]T — refused by name (docs/SPEC-TABLES.md §2.9): `[..N]` is a + // BOUND, so dropping its N reads as a bound someone failed to + // finish rather than a bound nobody declared, and the grammar's own + // Bound production has no such form + p.errf(p.tok().Pos, "[..]T is not the unbounded array — `[..N]` is a BOUND, so dropping its N reads as a bound left unfinished rather than a bound nobody declared, and a count bound is a range LITERAL and never a truncated one; spell an unbounded array [] (docs/SPEC-TABLES.md §2.9, SPEC §4.2)") + b.Kind = ast.ArrayList + break + } + // [..N] — sugar for [0..N], reads "up to N" (SPEC §4.3) b.Kind = ast.ArrayUpTo b.Hi = p.parseExpr() default: first := p.parseExpr() if p.kind() == scanner.DotDot { p.advance() + if p.kind() == scanner.RBrack { + // [0..]T — refused by name (docs/SPEC-TABLES.md §2.9): it + // states a minimum and hides the missing maximum behind it + p.errf(p.tok().Pos, "[Min..]T is not the unbounded array — it states a minimum and hides the missing maximum behind it, and a count bound is a range LITERAL and never a truncated one; spell an unbounded array [], or complete the bound as [Min..N] (docs/SPEC-TABLES.md §2.9, SPEC §4.2)") + b.Kind = ast.ArrayList + break + } b.Kind = ast.ArrayRange b.Lo = first b.Hi = p.parseExpr() diff --git a/ir/ir.go b/ir/ir.go index 57da56452..59b054462 100644 --- a/ir/ir.go +++ b/ir/ir.go @@ -268,6 +268,14 @@ const ( ArrayNone ArrayKind = iota ArrayFixed ArrayCounted // [..N]T and [Min..N]T + // ArrayList is `[]T` — an UNBOUNDED ARRAY (docs/SPEC-TABLES.md §2.9): a + // counted array whose count the DATA decides. On the wire it is a kind-14 + // body exactly as ArrayCounted is; what it drops is the declared bound, + // and with the bound goes the inline storage, so the slot holds a + // reference and a count and the elements live in the holder's node + // extent. ArrayBound stays 0, which is what the descriptors publish as + // "no declared bound" (§8.1). + ArrayList ) // Field is one storage-carrying member of a struct, with its resolved wire diff --git a/ir/table.go b/ir/table.go index afdc6534a..e6e73e34d 100644 --- a/ir/table.go +++ b/ir/table.go @@ -183,6 +183,14 @@ func VariableTables(u *Unit) map[string]bool { variable[name] = true break } + if f.IsList() { + // an UNBOUNDED ARRAY is a variable edge whatever its + // element is (docs/SPEC-TABLES.md §2.9), on the map's own + // terms: its elements live in the arena on the authoring + // side and in the node's own extent in a region + variable[name] = true + break + } if f.Type.Kind != TNamed { continue } diff --git a/ir/tablekind.go b/ir/tablekind.go index dd7fdb423..3b8aeab34 100644 --- a/ir/tablekind.go +++ b/ir/tablekind.go @@ -392,6 +392,8 @@ func FieldTypeSpelling(f *Field) string { switch { case f.KeyEnum != "": prefix = "[" + f.KeyEnum + "]" + case f.Array == ArrayList: + prefix = "[]" // an UNBOUNDED ARRAY (docs/SPEC-TABLES.md §2.9) case f.Array == ArrayFixed: prefix = "[" + itoa64(f.ArrayBound) + "]" case f.Array == ArrayCounted && f.ArrayMin > 0: diff --git a/ir/tablelist.go b/ir/tablelist.go new file mode 100644 index 000000000..6fa266b40 --- /dev/null +++ b/ir/tablelist.go @@ -0,0 +1,96 @@ +// Unbounded arrays in the IR (docs/SPEC-TABLES.md §2.9): the derived facts +// every backend and the tool's engines read off a `[]T` field. An unbounded +// array is a COUNTED ARRAY whose count the DATA decides — §2.8's map with the +// KEY and the SORT taken out — so nothing here describes a new wire construct. +// It names the surface the construct claims, the fields a backend without it +// refuses, and the one question every walk asks: is this field's storage +// inline, or a reference into the holder's node extent. +package ir + +import ( + "fmt" + "sort" + "strings" +) + +// IsList reports a `[]T` field — an UNBOUNDED ARRAY +// (docs/SPEC-TABLES.md §2.9). +func (f *Field) IsList() bool { return f != nil && f.Array == ArrayList } + +// ListFieldVerbs is the SURFACE an unbounded array claims on the table that +// declares it (docs/SPEC-TABLES.md §2.9, §11): `
` followed by +// each of these. There is no entry type and no lookup, so the list is the +// three calls a builder needs and nothing beside them. +// +// The list is claimed with the CONSTRUCT and not with the codec, on the rule +// §11 already follows for the map's surface and the block form's row +// accessors: a name free today must not become a collision the day a backend +// emits it. +var ListFieldVerbs = []string{"Add", "Each", "Erase"} + +// ListFields lists the unbounded arrays an author WROTE, as `Table.field`, +// sorted — the names a backend that does not carry the construct puts in its +// refusal (docs/SPEC-TABLES.md §2.9, §11). +func ListFields(u *Unit) []string { + var out []string + for name := range TableClosure(u) { + st := memberStruct(u, name) + if st == nil { + continue + } + for _, f := range st.Fields { + if f.IsList() { + out = append(out, name+"."+f.Name) + } + } + } + sort.Strings(out) + return out +} + +// UnitHasList reports whether any member of a unit's table closure declares an +// unbounded array. It is what gates the list runtime: not one symbol of it +// appears in a list-free unit's generated header (docs/SPEC-TABLES.md §2.2, +// §2.9). +func UnitHasList(u *Unit) bool { + for name := range TableClosure(u) { + st := memberStruct(u, name) + if st == nil { + continue + } + for _, f := range st.Fields { + if f.IsList() { + return true + } + } + } + return false +} + +// ListFieldsOf lists one member's unbounded arrays in declaration order. +func ListFieldsOf(st *Struct) []*Field { + var out []*Field + if st == nil { + return nil + } + for _, f := range st.Fields { + if f.IsList() { + out = append(out, f) + } + } + return out +} + +// RefuseTableLists is the refusal a backend without the construct owes a unit +// that declares one (docs/SPEC-TABLES.md §2.9, §11, §15): BY NAME, naming +// every field, so no port emits a second answer for a construct it does not +// carry. A backend that carries it does not call this. +func RefuseTableLists(u *Unit, backend string) error { + fields := ListFields(u) + if len(fields) == 0 { + return nil + } + return fmt.Errorf("the %s table backend does not carry UNBOUNDED ARRAYS yet — %s "+ + "(docs/SPEC-TABLES.md §2.9, §11: the C++ reference and the tool land `[]T` first, "+ + "and each port lands it as a row on schema#366)", backend, strings.Join(fields, ", ")) +} From 89bfbb98f550fd19884b879dd4d9fc67c1ddafcb Mon Sep 17 00:00:00 2001 From: Glenn Fiedler Date: Sat, 5 Sep 2026 07:25:56 +1000 Subject: [PATCH 2/9] tool: the unbounded-array front end, the IR facts and the ported refusals (#531) The `[]T` and `[]*T` spellings reach the IR as ArrayList, the near-miss spellings `[..]T` and `[0..]T` are refused by name with `[]T` as the fix, and arrays of arrays are refused where a second bracket stands. The construct's own refusals land beside the element's: a `type` body, a union arm, `?[]T`, a specified default, a qualification, and the three claimed names `
{Add,Each,Erase}`. `TableList` joins the unit-level runtime claim. Every port refuses a unit that declares one, naming the fields and cpp as the carrier, and the tool's cook surfaces refuse one on the map's terms. Co-Authored-By: Claude Fable 5.1 --- compiler/cook.go | 9 +++ compiler/tableslists.go | 58 ++++++++++++++++ compiler/target_c.go | 3 + compiler/target_cpp.go | 3 +- compiler/target_cs.go | 3 + compiler/target_dart.go | 3 + compiler/target_elixir.go | 3 + compiler/target_go.go | 3 + compiler/target_java.go | 3 + compiler/target_javascript.go | 3 + compiler/target_rust.go | 3 + internal/check/check.go | 123 +++++++++++++++++++--------------- internal/check/tablelist.go | 18 +---- internal/parser/parser.go | 16 +++++ internal/tablenames/cpp.go | 5 ++ 15 files changed, 186 insertions(+), 70 deletions(-) create mode 100644 compiler/tableslists.go diff --git a/compiler/cook.go b/compiler/cook.go index 2d0b8cb79..107e86ed1 100644 --- a/compiler/cook.go +++ b/compiler/cook.go @@ -60,6 +60,9 @@ func (c *Compiler) Cook(u *ir.Unit, root string, wire []byte, opts CookOptions) if err := refuseToolMaps(u); err != nil { return nil, CookReport{}, TableReport{}, err } + if err := refuseToolLists(u); err != nil { + return nil, CookReport{}, TableReport{}, err + } var rep CookReport m := tabletext.NewModel(u) st := m.Lookup(root) @@ -117,6 +120,9 @@ func (c *Compiler) CookCheck(u *ir.Unit, root string, file []byte) (CookReport, if err := refuseToolMaps(u); err != nil { return CookReport{}, err } + if err := refuseToolLists(u); err != nil { + return CookReport{}, err + } m := tabletext.NewModel(u) res, err := tablecook.Check(m, file) if err != nil { @@ -150,6 +156,9 @@ func (c *Compiler) Uncook(u *ir.Unit, root string, file []byte) ([]byte, error) if err := refuseToolMaps(u); err != nil { return nil, err } + if err := refuseToolLists(u); err != nil { + return nil, err + } m := tabletext.NewModel(u) st := m.Lookup(root) if st == nil || !st.IsTable { diff --git a/compiler/tableslists.go b/compiler/tableslists.go new file mode 100644 index 000000000..5c1fe52f4 --- /dev/null +++ b/compiler/tableslists.go @@ -0,0 +1,58 @@ +// The UNBOUNDED ARRAY cross-target refusal (docs/SPEC-TABLES.md §2.9, §11): +// its own file, per the registry split — a construct's carrier registry and +// its refusal add a file beside builtin.go rather than growing it. A target +// that carries the construct registers through [registerListCarrier] from its +// own file's init; every other target's Generate calls [refuseLists]. +package compiler + +import ( + "fmt" + + "github.com/mas-bandwidth/schema/v2/ir" +) + +// listTargets is the canonical name of every built-in target whose table +// backend carries an UNBOUNDED ARRAY (docs/SPEC-TABLES.md §2.9); refuseLists +// names them. +var listTargets []string + +// registerListCarrier is what a carrying target's file calls from its init, +// beside its registerBuiltin call. +func registerListCarrier(name string) { listTargets = append(listTargets, name) } + +// refuseLists is the named refusal every PORT gives a unit whose table closure +// declares a `[]T` (docs/SPEC-TABLES.md §2.9, §11, §15). +// +// An unbounded array is a VARIABLE-CLASS construct, and the variable class is +// the C++ reference's alone — the arena, the region, the node extent and the +// walks a list's elements ride in are all the reference's. So the reference +// carries the codec, registers through [registerListCarrier] from its own +// init and never reaches here, and every port refuses loudly rather than +// emitting a codec that never met the element array or its count. +func refuseLists(u *ir.Unit, target string) error { + fields := ir.ListFields(u) + if len(fields) == 0 { + return nil + } + carry, flags := carriers(listTargets) + return fmt.Errorf("unit declares an unbounded array in a table closure (%s) — `[]T` is %s only today, and the %s form is a named follow-on; generate with %s, or declare the array at a bound, [..N]T, which is the same bytes (docs/SPEC-TABLES.md §2.9, §11, §15)", + englishList(fields), englishList(carry), target, englishList(flags)) +} + +// refuseToolLists is the TOOL's COOK refusal (docs/SPEC-TABLES.md §2.9, §15), +// the map's own, one construct over: a unit whose table closure declares a +// `[]T` is refused by name at the tool's COOK surfaces, because +// internal/tablecook does not lay out the element arrays yet. +// +// It is here, at the surface, rather than in the engine, and it is NAMED +// rather than left to the layout. Without it the engine lays out a region +// short of the element arrays and a reader meets a slot pointing past its +// holder's extent, which is a corrupt file with nothing saying who wrote it. +func refuseToolLists(u *ir.Unit) error { + fields := ir.ListFields(u) + if len(fields) == 0 { + return nil + } + return fmt.Errorf("unit declares an unbounded array in a table closure (%s) — the tool's WIRE and TEXT halves carry the construct, and its COOK half does not, so this command would lay out a region short of the element arrays rather than refusing; the C++ reference carries the cook (--lang cpp) (docs/SPEC-TABLES.md §2.9, §15)", + englishList(fields)) +} diff --git a/compiler/target_c.go b/compiler/target_c.go index be8ffefc0..3e400aec8 100644 --- a/compiler/target_c.go +++ b/compiler/target_c.go @@ -28,6 +28,9 @@ func (cTarget) Generate(u *ir.Unit, _ Options) (map[string][]byte, error) { if err := refuseMaps(u, "c"); err != nil { return nil, err } + if err := refuseLists(u, "c"); err != nil { + return nil, err + } files, err := cgen.Generate(u) if err != nil { return nil, err diff --git a/compiler/target_cpp.go b/compiler/target_cpp.go index 9f6b60aef..1a7d6900f 100644 --- a/compiler/target_cpp.go +++ b/compiler/target_cpp.go @@ -45,5 +45,6 @@ func (cppTarget) Generate(u *ir.Unit, _ Options) (map[string][]byte, error) { func init() { registerBuiltin(cppTarget{}, true, true, true, true) registerOptionalArrayCarrier("cpp") - registerMapCarrier("cpp") // the C++ reference carries the map codecs (docs/SPEC-TABLES.md §2.8) + registerMapCarrier("cpp") // the C++ reference carries the map codecs (docs/SPEC-TABLES.md §2.8) + registerListCarrier("cpp") // and the unbounded array's (docs/SPEC-TABLES.md §2.9) } diff --git a/compiler/target_cs.go b/compiler/target_cs.go index 89534c0b8..d97b66928 100644 --- a/compiler/target_cs.go +++ b/compiler/target_cs.go @@ -25,6 +25,9 @@ func (csTarget) Generate(u *ir.Unit, _ Options) (map[string][]byte, error) { if err := refuseMaps(u, "cs"); err != nil { return nil, err } + if err := refuseLists(u, "cs"); err != nil { + return nil, err + } files, err := csharp.Generate(u) if err != nil { return nil, err diff --git a/compiler/target_dart.go b/compiler/target_dart.go index 6d807f512..86e10d92c 100644 --- a/compiler/target_dart.go +++ b/compiler/target_dart.go @@ -25,6 +25,9 @@ func (dartTarget) Generate(u *ir.Unit, _ Options) (map[string][]byte, error) { if err := refuseMaps(u, "dart"); err != nil { return nil, err } + if err := refuseLists(u, "dart"); err != nil { + return nil, err + } files, err := dart.Generate(u) if err != nil { return nil, err diff --git a/compiler/target_elixir.go b/compiler/target_elixir.go index 98e567169..3a9e536c3 100644 --- a/compiler/target_elixir.go +++ b/compiler/target_elixir.go @@ -25,6 +25,9 @@ func (elixirTarget) Generate(u *ir.Unit, _ Options) (map[string][]byte, error) { if err := refuseMaps(u, "elixir"); err != nil { return nil, err } + if err := refuseLists(u, "elixir"); err != nil { + return nil, err + } files, err := elixir.Generate(u) if err != nil { return nil, err diff --git a/compiler/target_go.go b/compiler/target_go.go index 618b6cc39..6a1930e11 100644 --- a/compiler/target_go.go +++ b/compiler/target_go.go @@ -25,6 +25,9 @@ func (goTarget) Generate(u *ir.Unit, _ Options) (map[string][]byte, error) { if err := refuseMaps(u, "go"); err != nil { return nil, err } + if err := refuseLists(u, "go"); err != nil { + return nil, err + } files, err := golang.Generate(u) if err != nil { return nil, err diff --git a/compiler/target_java.go b/compiler/target_java.go index 77ecad42f..2678a8266 100644 --- a/compiler/target_java.go +++ b/compiler/target_java.go @@ -25,6 +25,9 @@ func (javaTarget) Generate(u *ir.Unit, _ Options) (map[string][]byte, error) { if err := refuseMaps(u, "java"); err != nil { return nil, err } + if err := refuseLists(u, "java"); err != nil { + return nil, err + } files, err := java.Generate(u) if err != nil { return nil, err diff --git a/compiler/target_javascript.go b/compiler/target_javascript.go index 58ed16a37..001dc79db 100644 --- a/compiler/target_javascript.go +++ b/compiler/target_javascript.go @@ -27,6 +27,9 @@ func (jsTarget) Generate(u *ir.Unit, _ Options) (map[string][]byte, error) { if err := refuseMaps(u, "js"); err != nil { return nil, err } + if err := refuseLists(u, "js"); err != nil { + return nil, err + } files, err := js.Generate(u) if err != nil { return nil, err diff --git a/compiler/target_rust.go b/compiler/target_rust.go index 7bbc5981a..41742a75b 100644 --- a/compiler/target_rust.go +++ b/compiler/target_rust.go @@ -25,6 +25,9 @@ func (rustTarget) Generate(u *ir.Unit, _ Options) (map[string][]byte, error) { if err := refuseMaps(u, "rust"); err != nil { return nil, err } + if err := refuseLists(u, "rust"); err != nil { + return nil, err + } files, err := rust.Generate(u) if err != nil { return nil, err diff --git a/internal/check/check.go b/internal/check/check.go index cef0d6d7e..195540959 100644 --- a/internal/check/check.go +++ b/internal/check/check.go @@ -1251,67 +1251,68 @@ func (c *checker) resolveField(owner string, f *ast.Field, inTable bool) *ir.Fie } out.Array = ir.ArrayList } else { - // an ENUM-KEYED array: the bound NAMES a declared enum rather than - // evaluating to a count — `ships [ShipType]ShipConfig`, one slot per - // variant, indexed by the variant (docs/SPEC-TABLES.md §2.4) - if !c.resolveKeyBound(f, out) { - return nil - } - switch { - case out.KeyEnum != "": - if out.Type.Pointer { - // a KEYED array of pointers is a named follow-on (docs/SPEC-TABLES.md - // §2.4, §15); the bounded spellings `[N]*T` and `[..N]*T` serve (§2.1) - c.errf(f.Type.Pos, "field %s: [%s]*%s is a named follow-on — an enum-keyed array of pointers; declare [..N]*%s or [N]*%s, or key an array of tables by value (docs/SPEC-TABLES.md §2.4, §15)", - f.Name, out.KeyEnum, f.Type.Name, f.Type.Name, f.Type.Name) - return nil - } - if _, isUnion := out.Type.Ref.(*ir.Union); isUnion { - // a KEYED array of unions is a named follow-on (docs/SPEC-TABLES.md - // §2.6, §15); the bounded spellings `[N]U` and `[..N]U` serve - c.errf(f.Type.Pos, "field %s: [%s]%s is a named follow-on — an enum-keyed array of unions; declare [..N]%s or [N]%s (docs/SPEC-TABLES.md §2.6, §15)", - f.Name, out.KeyEnum, f.Type.Name, f.Type.Name, f.Type.Name) + // an ENUM-KEYED array: the bound NAMES a declared enum rather than + // evaluating to a count — `ships [ShipType]ShipConfig`, one slot per + // variant, indexed by the variant (docs/SPEC-TABLES.md §2.4) + if !c.resolveKeyBound(f, out) { return nil } - // ONE SLOT PER NAMED VARIANT and not one more: None is the null - // key, so nothing is stored for it and the storage SHIFTS LEFT — - // the key k lives at index k-1 (docs/SPEC-TABLES.md §2.4). The bound is - // E.Max, the same count `[E.Max]T` resolves to, so the two - // spellings share one projection and one protocol id. - out.Array = ir.ArrayFixed - out.ArrayBound = out.KeyEnumRef.Max - out.ArrayExpr = f.Array.Hi - default: - hi, ok := c.evalInt(f.Array.Hi) - if !ok { - return nil - } - if !hi.IsInt64() || hi.Int64() < 1 { - c.errf(f.Pos, "array bound %s below 1 (SPEC §4.6)", hi) - return nil - } - if hi.Int64() > math.MaxInt32 { - c.errf(f.Pos, "array bound %s above %d — counts live in int32 storage (SPEC §4.3, §6.1)", hi, math.MaxInt32) - return nil - } - out.ArrayBound = hi.Int64() - out.ArrayExpr = f.Array.Hi - switch f.Array.Kind { - case ast.ArrayFixed: + switch { + case out.KeyEnum != "": + if out.Type.Pointer { + // a KEYED array of pointers is a named follow-on (docs/SPEC-TABLES.md + // §2.4, §15); the bounded spellings `[N]*T` and `[..N]*T` serve (§2.1) + c.errf(f.Type.Pos, "field %s: [%s]*%s is a named follow-on — an enum-keyed array of pointers; declare [..N]*%s or [N]*%s, or key an array of tables by value (docs/SPEC-TABLES.md §2.4, §15)", + f.Name, out.KeyEnum, f.Type.Name, f.Type.Name, f.Type.Name) + return nil + } + if _, isUnion := out.Type.Ref.(*ir.Union); isUnion { + // a KEYED array of unions is a named follow-on (docs/SPEC-TABLES.md + // §2.6, §15); the bounded spellings `[N]U` and `[..N]U` serve + c.errf(f.Type.Pos, "field %s: [%s]%s is a named follow-on — an enum-keyed array of unions; declare [..N]%s or [N]%s (docs/SPEC-TABLES.md §2.6, §15)", + f.Name, out.KeyEnum, f.Type.Name, f.Type.Name, f.Type.Name) + return nil + } + // ONE SLOT PER NAMED VARIANT and not one more: None is the null + // key, so nothing is stored for it and the storage SHIFTS LEFT — + // the key k lives at index k-1 (docs/SPEC-TABLES.md §2.4). The bound is + // E.Max, the same count `[E.Max]T` resolves to, so the two + // spellings share one projection and one protocol id. out.Array = ir.ArrayFixed - case ast.ArrayUpTo: - out.Array = ir.ArrayCounted - case ast.ArrayRange: - out.Array = ir.ArrayCounted - lo, ok := c.evalInt(f.Array.Lo) + out.ArrayBound = out.KeyEnumRef.Max + out.ArrayExpr = f.Array.Hi + default: + hi, ok := c.evalInt(f.Array.Hi) if !ok { return nil } - if lo.Sign() < 0 || !lo.IsInt64() || lo.Int64() >= hi.Int64() { - c.errf(f.Pos, "array count range [%s..%s] requires 0 <= Min < N (SPEC §4.6)", lo, hi) + if !hi.IsInt64() || hi.Int64() < 1 { + c.errf(f.Pos, "array bound %s below 1 (SPEC §4.6)", hi) + return nil + } + if hi.Int64() > math.MaxInt32 { + c.errf(f.Pos, "array bound %s above %d — counts live in int32 storage (SPEC §4.3, §6.1)", hi, math.MaxInt32) return nil } - out.ArrayMin = lo.Int64() + out.ArrayBound = hi.Int64() + out.ArrayExpr = f.Array.Hi + switch f.Array.Kind { + case ast.ArrayFixed: + out.Array = ir.ArrayFixed + case ast.ArrayUpTo: + out.Array = ir.ArrayCounted + case ast.ArrayRange: + out.Array = ir.ArrayCounted + lo, ok := c.evalInt(f.Array.Lo) + if !ok { + return nil + } + if lo.Sign() < 0 || !lo.IsInt64() || lo.Int64() >= hi.Int64() { + c.errf(f.Pos, "array count range [%s..%s] requires 0 <= Min < N (SPEC §4.6)", lo, hi) + return nil + } + out.ArrayMin = lo.Int64() + } } } } @@ -3272,6 +3273,22 @@ func (c *checker) checkClaimedNames() { add(base+verb, whyMap, d.DeclPos()) } } + // AND AN UNBOUNDED ARRAY claims three names on the table + // that declares it:
followed by Add, Each and + // Erase (docs/SPEC-TABLES.md §2.9, §11). Three where a map + // claims eight, and the difference is the key on both sides: + // an append needs none, so there is no entry to name, no + // insert, no find and no index to accelerate. + whyList := fmt.Sprintf("%s's generated unbounded-array surface (docs/SPEC-TABLES.md §2.9, §11)", name) + for _, f := range st.Fields { + if !f.IsList() { + continue + } + base := name + ir.GoExportName(f.Name) + for _, verb := range ir.ListFieldVerbs { + add(base+verb, whyList, d.DeclPos()) + } + } } } } diff --git a/internal/check/tablelist.go b/internal/check/tablelist.go index b8f66b790..8fc23d688 100644 --- a/internal/check/tablelist.go +++ b/internal/check/tablelist.go @@ -11,7 +11,6 @@ package check import ( "github.com/mas-bandwidth/schema/v2/internal/ast" - "github.com/mas-bandwidth/schema/v2/ir" ) // checkListSpelling holds every rule a `[]T` field carries that is not the @@ -51,23 +50,10 @@ func (c *checker) checkListSpelling(f *ast.Field, inTable bool) bool { // `json` key as any field does: both are about the field, not the // construct (docs/SPEC-TABLES.md §2.9, §5, §16.4) default: - c.errf(a.Pos, "field %s: %s does not apply to an unbounded array — THE COUNT IS THE DATA'S, and a bound would buy only a CLAMP, which drops a tail; drop the qualification, or declare [..N]%s, which is the same bytes with a bound (docs/SPEC-TABLES.md §2.9, §11)", - f.Name, a.Key, scalarSpelling(f.Type)) + c.errf(a.Pos, "field %s: %s does not apply to an unbounded array — THE COUNT IS THE DATA'S, and a bound would buy only a CLAMP, which drops a tail; drop the qualification, or declare the array at a bound, [..N]T, which is the same bytes with a bound (docs/SPEC-TABLES.md §2.9, §11)", + f.Name, a.Key) return false } } return true } - -// listClaims registers the three names an unbounded array CLAIMS against its -// field (docs/SPEC-TABLES.md §2.9, §11): `
` followed by the PascalCase -// of the field's name, then `Add`, `Each` or `Erase`. It claims THREE where a -// map claims eight, and the difference is the key on both sides. -func listClaims(table string, f *ir.Field) []string { - base := table + ir.GoExportName(f.Name) - out := make([]string, 0, len(ir.ListFieldVerbs)) - for _, verb := range ir.ListFieldVerbs { - out = append(out, base+verb) - } - return out -} diff --git a/internal/parser/parser.go b/internal/parser/parser.go index 604d70eba..e004bc428 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -502,6 +502,7 @@ func (p *parser) parseFieldLine(t scanner.Token) ast.Item { } if p.kind() == scanner.LBrack { f.Array = p.parseArrayBound() + p.refuseArrayOfArrays() } if p.kind() == scanner.KwMap { // `ships map[string(32)]ShipConfig` — a MAP (docs/SPEC-TABLES.md @@ -579,6 +580,7 @@ func (p *parser) parseMapType() *ast.MapType { } if p.kind() == scanner.LBrack { value.Array = p.parseArrayBound() + p.refuseArrayOfArrays() } if p.kind() == scanner.KwMap { optional := value.Type.Optional @@ -593,6 +595,20 @@ func (p *parser) parseMapType() *ast.MapType { return m } +// refuseArrayOfArrays refuses a SECOND bracket where the element type stands +// — `[][]T`, `[][..N]T`, `[..N][]T` and `[N][]T` — by name, and then consumes +// the inner bound so the rest of the file's diagnostics still land. Arrays of +// arrays are not in v1 (SPEC §4.3), and the fix an unbounded array's element +// takes is a TABLE wrapper rather than a `type` wrapper, because a `type` body +// refuses a `[]T` (docs/SPEC-TABLES.md §2.9, §11). +func (p *parser) refuseArrayOfArrays() { + if p.kind() != scanner.LBrack { + return + } + p.errf(p.tok().Pos, "an array of arrays is not supported in v1 — wrap the inner array in a TABLE and declare an array of that table, which is the wrapper an unbounded array's element takes because a `type` body refuses a []T (SPEC §4.3, docs/SPEC-TABLES.md §2.9, §11)") + p.parseArrayBound() // consumed so the rest of the file's diagnostics land +} + func (p *parser) parseArrayBound() *ast.ArrayBound { p.expect(scanner.LBrack, "[") b := &ast.ArrayBound{} diff --git a/internal/tablenames/cpp.go b/internal/tablenames/cpp.go index 76eeefd87..90877eabd 100644 --- a/internal/tablenames/cpp.go +++ b/internal/tablenames/cpp.go @@ -36,6 +36,11 @@ func init() { // freed now is a collision the day the codec lands. Name{Name: "TableMap", What: "a map field's sorted entry array and its count"}, Name{Name: "TableMapIndex", What: "a map's side index, built once and searched in place"}, + // the UNBOUNDED ARRAY's storage (docs/SPEC-TABLES.md §2.9), claimed on + // TableMap's exact terms and for its reason: the map's slot with the + // key taken out, emitted only into a unit that declares a `[]T` and + // claimed in every unit that declares a table. + Name{Name: "TableList", What: "an unbounded array's element reference and its count"}, // C++'s float <-> IEEE-754 bit pattern helpers Name{Name: "table_bits_to_float", What: "u32 bits -> float"}, Name{Name: "table_float_to_bits", What: "float -> u32 bits"}, From 0edbf90b88f09579628d7f8cc042d38d07d6be81 Mon Sep 17 00:00:00 2001 From: Glenn Fiedler Date: Sat, 5 Sep 2026 07:31:16 +1000 Subject: [PATCH 3/9] tool: the unbounded array on the wire, in the text and in the projections (#531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire engine writes and reads a `[]T` as the kind 14 body a `[..N]T` writes: the same element kind, the same count, the same elision of an empty one. A count above the int32 storage cap is the refusal LoadBuilder answers NULL for, and the slots are grown against the body's own length so a count no body can cover allocates nothing. `clamped` cannot fire on the count, because there is no bound to clamp against. The text form is the JSON array it already was, with every element the text carries read. The node walk reaches a list at its field's position, in index order, so a `[]*T` declared before a pointer field numbers the shared node first. The baseline renders `array=unbounded` with no `bound=`, which is what makes a bound added warn and a bound removed pass, and the cook projection renders `kind=14 array=unbounded elem=` the element's own storage size beside the sixteen-byte slot's `size=`. Proved end to end through `pack` and `unpack` over §2.9's own example, and the same content under `[..8]T` packs to byte-identical bytes. Co-Authored-By: Claude Fable 5.1 --- internal/baseline/baseline.go | 9 +++++++++ internal/tablepack/pack.go | 2 +- internal/tabletext/read.go | 12 ++++++++++++ internal/tabletext/value.go | 6 ++++++ internal/tabletext/write.go | 4 ++-- internal/tablewire/decode.go | 27 +++++++++++++++++++++++++-- internal/tablewire/encode.go | 9 ++++++--- internal/tablewire/nodes.go | 13 ++++++++----- ir/blocklayout.go | 12 ++++++++++++ ir/buildversion.go | 14 ++++++++++++++ ir/tablelist.go | 9 +++++++++ 11 files changed, 104 insertions(+), 13 deletions(-) diff --git a/internal/baseline/baseline.go b/internal/baseline/baseline.go index a64efd39d..91f1b7f08 100644 --- a/internal/baseline/baseline.go +++ b/internal/baseline/baseline.go @@ -350,6 +350,15 @@ func renderField(f *ir.Field) Field { case f.Array == ir.ArrayCounted: add("array", "bounded") add("bound", strconv.FormatInt(f.ArrayBound, 10)) + case f.Array == ir.ArrayList: + // AN UNBOUNDED ARRAY RENDERS AS `array=unbounded` WITH NO `bound=` + // (docs/SPEC-TABLES.md §2.9): the count is the data's, and the only + // ceiling is the int32 extent cap every array shares (§2.2). The + // missing token IS the capacity fact — a `bound=` appearing on an + // `array=` move is a capacity SHRINK and warns, and one vanishing is + // a capacity GROWTH and passes (§18.2) — so the token's absence + // carries the verdict the token itself would have carried. + add("array", "unbounded") } if f.Type.Kind == ir.TFixed { // a fixed field's SCALE. The kind fixes the width and the signedness; diff --git a/internal/tablepack/pack.go b/internal/tablepack/pack.go index b1d36d201..e6f640747 100644 --- a/internal/tablepack/pack.go +++ b/internal/tablepack/pack.go @@ -246,7 +246,7 @@ func (p *packer) arrayDir(fv *tabletext.Field, dir string, entries []os.DirEntry } placed++ } - if f.Array == ir.ArrayCounted { + if f.CountedOnWire() { fv.Count = placed } } diff --git a/internal/tabletext/read.go b/internal/tabletext/read.go index 292d78ba8..cc0d22212 100644 --- a/internal/tabletext/read.go +++ b/internal/tabletext/read.go @@ -924,6 +924,15 @@ func (in *reader) readArray(fv *Field, depth int) bool { placed := 0 shape := ElementShape(f) bound := int(f.ArrayBound) + if f.Array == ir.ArrayList { + // EVERY ELEMENT THE TEXT CARRIES IS READ, because there is no bound to + // drop a tail against (docs/SPEC-TABLES.md §2.9, §16.2): the bounded + // array's "more than N are dropped, counted" row has no counterpart + // here. The int32 storage cap is the only limit, and it is the cap + // §2.2 gives every count. + bound = math.MaxInt32 + fv.Elems = fv.Elems[:0] + } for { c := in.peek() if c == ']' { @@ -934,6 +943,9 @@ func (in *reader) readArray(fv *Field, depth int) bool { in.bad = true return false } + if f.Array == ir.ArrayList && placed == len(fv.Elems) { + fv.Elems = append(fv.Elems, in.m.elementZero(f)) + } switch { case placed >= bound: // more elements than the reader's bound: the bounded prefix is diff --git a/internal/tabletext/value.go b/internal/tabletext/value.go index d8e299b1b..1d2ebc89e 100644 --- a/internal/tabletext/value.go +++ b/internal/tabletext/value.go @@ -538,3 +538,9 @@ func WideValue(cell *Cell) *big.Int { } return cell.Wide } + +// ElementZero is one value-initialized array slot — what an UNBOUNDED ARRAY's +// decode appends per element it reaches (docs/SPEC-TABLES.md §2.9), since a +// list's slots are grown against the body rather than sized from the +// declaration. +func (m *Model) ElementZero(f *ir.Field) Cell { return m.elementZero(f) } diff --git a/internal/tabletext/write.go b/internal/tabletext/write.go index e5ced9074..3000c0312 100644 --- a/internal/tabletext/write.go +++ b/internal/tabletext/write.go @@ -121,7 +121,7 @@ func (m *Model) countField(g *graphOut, fv *Field, open map[*Instance]bool) erro return nil case f.Array != ir.ArrayNone: count := int(f.ArrayBound) - if f.Array == ir.ArrayCounted { + if f.CountedOnWire() { count = fv.Count } for i := 0; i < count; i++ { @@ -315,7 +315,7 @@ func (m *Model) writeField(w *writer, fv *Field, depth int) error { return m.writeKeyed(w, fv, depth) case f.Array != ir.ArrayNone: count := int(f.ArrayBound) - if f.Array == ir.ArrayCounted { + if f.CountedOnWire() { count = fv.Count } if count == 0 { diff --git a/internal/tablewire/decode.go b/internal/tablewire/decode.go index 6931f504b..d113c21c5 100644 --- a/internal/tablewire/decode.go +++ b/internal/tablewire/decode.go @@ -631,7 +631,7 @@ func (r *wireReader) arrayBody(fv *tabletext.Field, framed int) (ok, selected bo if f.Type.Kind == ir.TBytes { bound = int(f.Type.Size) } - counted := f.Type.Kind == ir.TBytes || f.Array == ir.ArrayCounted + counted := f.Type.Kind == ir.TBytes || f.CountedOnWire() bodyLen := framed if framed < 0 { n, good := r.leb() @@ -678,7 +678,24 @@ func (r *wireReader) arrayBody(fv *tabletext.Field, framed int) (ok, selected bo return true, false } keep := int(count) - if count > uint64(bound) { + switch { + case f.Array == ir.ArrayList: + // AN UNBOUNDED ARRAY HAS NO BOUND TO CLAMP AGAINST, and that is + // the one counter this construct removes (§2.9): a count is read, + // or refused before the read, or damaged. A count above the int32 + // STORAGE CAP is the refusal LoadBuilder answers NULL for — the + // partial value is discarded and the report holds what it held — + // so the walk stops here rather than sizing a slot list from a + // number the wire chose. + if count > uint64(math.MaxInt32) { + r.report.Malformed = true + r.off = end + return false, false + } + // the slots are grown ONE AT A TIME below, against the body's own + // length, so a count no body can cover allocates nothing + fv.Elems = fv.Elems[:0] + case count > uint64(bound): keep = bound r.report.Clamped++ } @@ -700,11 +717,17 @@ func (r *wireReader) arrayBody(fv *tabletext.Field, framed int) (ok, selected bo fv.Cell.Str = payload } else { for i := 0; i < keep; i++ { + if f.Array == ir.ArrayList { + fv.Elems = append(fv.Elems, r.m.ElementZero(f)) + } if !sub.element(fv, i) { break } decoded = i + 1 } + if f.Array == ir.ArrayList { + fv.Elems = fv.Elems[:decoded] // the prefix the body covered + } } if counted { fv.Count = decoded diff --git a/internal/tablewire/encode.go b/internal/tablewire/encode.go index c78569553..8f32a916d 100644 --- a/internal/tablewire/encode.go +++ b/internal/tablewire/encode.go @@ -179,7 +179,7 @@ func encodeField(e *encoder, w *buf, inst *tabletext.Instance, fv *tabletext.Fie // body — and every declared element for the fixed one. No content // test anywhere: presence already decided. count := int(f.ArrayBound) - if f.Array == ir.ArrayCounted { + if f.CountedOnWire() { count = fv.Count } return encodeArray(e, w, fv, id, kind, count) @@ -223,7 +223,10 @@ func encodeField(e *encoder, w *buf, inst *tabletext.Instance, fv *tabletext.Fie w.raw(fv.Cell.Str) return nil - case f.Array == ir.ArrayCounted: + case f.CountedOnWire(): + // AN UNBOUNDED ARRAY IS THE COUNTED ARRAY'S OWN BYTES (§2.9): the + // same kind 14 body, the same element kind, the same count, and the + // same elision of an empty one under §3's by-value elision rule. if fv.Count == 0 { return nil } @@ -542,7 +545,7 @@ func encodeArm(e *encoder, arm ir.UnionVariant, cell *tabletext.Cell) ([]byte, e w.raw(fv.Cell.Str) case f.Array != ir.ArrayNone: count := int(f.ArrayBound) - if f.Array == ir.ArrayCounted { + if f.CountedOnWire() { count = fv.Count } elemKind := kind diff --git a/internal/tablewire/nodes.go b/internal/tablewire/nodes.go index 92c3697bb..dc66194ca 100644 --- a/internal/tablewire/nodes.go +++ b/internal/tablewire/nodes.go @@ -196,7 +196,7 @@ func visitEdges(m *tabletext.Model, inst *tabletext.Instance, visit func(target return } } - case ir.ArrayCounted: + case ir.ArrayCounted, ir.ArrayList: for k := 0; k < fv.Count && k < len(fv.Elems); k++ { if fv.Elems[k].Node != nil && !visit(Node{Inst: fv.Elems[k].Node}, f.Name) { return @@ -215,7 +215,7 @@ func visitEdges(m *tabletext.Model, inst *tabletext.Instance, visit func(target for k := range fv.Elems { visitArmEdges(m, &fv.Elems[k], un, f.Name, visit) } - case ir.ArrayCounted: + case ir.ArrayCounted, ir.ArrayList: for k := 0; k < fv.Count && k < len(fv.Elems); k++ { visitArmEdges(m, &fv.Elems[k], un, f.Name, visit) } @@ -241,8 +241,11 @@ func visitEdges(m *tabletext.Model, inst *tabletext.Instance, visit func(target visitEdges(m, sub, visit) } } - case f.Array == ir.ArrayCounted: - // only the LIVE elements ride, so only they carry edges + case f.CountedOnWire(): + // only the LIVE elements ride, so only they carry edges. An + // UNBOUNDED ARRAY is a by-value edge at its FIELD'S POSITION, + // its elements visited in index order and each descended before + // the next is reached (§2.9, §3.1) for k := 0; k < fv.Count && k < len(fv.Elems); k++ { if sub := fv.Elems[k].Tab; sub != nil { visitEdges(m, sub, visit) @@ -289,7 +292,7 @@ func visitArmEdges(m *tabletext.Model, cell *tabletext.Cell, un *ir.Union, field } case f.Type.Pointer: live := len(fv.Elems) - if f.Array == ir.ArrayCounted && fv.Count < live { + if f.CountedOnWire() && fv.Count < live { live = fv.Count } for k := 0; k < live; k++ { diff --git a/ir/blocklayout.go b/ir/blocklayout.go index 502cbda6b..4b762c80c 100644 --- a/ir/blocklayout.go +++ b/ir/blocklayout.go @@ -253,6 +253,8 @@ func variableEdge(u *Unit, st *Struct) string { return where + " is a pointer, and a pointer in the by-value closure means no fixed pitch anywhere in it" case f.IsMap(): return where + " is a map, and a map's entries are an extent inside the holder's own node, so there is no fixed pitch anywhere in it" + case f.IsList(): + return where + " is an unbounded array, and its elements are an extent inside the holder's own node, so there is no fixed pitch anywhere in it" } if f.Type.Kind != TNamed { continue @@ -642,6 +644,16 @@ func fieldPieces(u *Unit, f *Field, projection bool) []storagePiece { e := elementPiece(u, f) pieces = append(pieces, storagePiece{size: mulSize(e.size, f.ArrayBound), align: e.align}) pieces = append(pieces, storagePiece{size: 4, align: 4}) // int32 count + case f.Array == ArrayList: + // AN UNBOUNDED ARRAY FIELD IS SIXTEEN BYTES (docs/SPEC-TABLES.md + // §2.9): it is the map's slot exactly, because it is the same two + // facts — an int64 self-relative reference to the element array and + // an int32 count — then padding to eight. ONE piece for the map's own + // reason: both spellings are one member of a TableList type, and a + // port that walked two would account for twelve bytes where sixteen + // are written. The ELEMENTS are not here: they are by-value records + // inside the holder's node extent, after the record's own storage. + pieces = append(pieces, storagePiece{size: 16, align: 8}) default: pieces = append(pieces, elementPiece(u, f)) } diff --git a/ir/buildversion.go b/ir/buildversion.go index c767e9e44..a9613aef8 100644 --- a/ir/buildversion.go +++ b/ir/buildversion.go @@ -191,6 +191,12 @@ func cookFieldLine(u *Unit, fl FieldLayout, enums map[string]*Enum, flags map[st // which is where a key edit moves the id (§20.1). kind = TableKindArray } + if f.IsList() { + // AN UNBOUNDED ARRAY FIELD IS AN ARRAY LINE (docs/SPEC-TABLES.md + // §20.2): `kind=14`, `array=unbounded` and the element's own storage + // size in `elem=`, plus the `size=` its sixteen-byte slot produces. + kind = TableKindArray + } var b strings.Builder fmt.Fprintf(&b, " field %016x kind=%d offset=%d size=%d", TableFieldWireId(f), kind, fl.Offset, fl.Size) b.WriteString(cookFacts(u, fl, enums, flags, unions)) @@ -318,6 +324,14 @@ func cookFacts(u *Unit, fl FieldLayout, enums map[string]*Enum, flags map[string fmt.Fprintf(&b, " elem=%d array=fixed bound=%d", cookElemSize(fl), f.ArrayBound) case f.Array == ArrayCounted: fmt.Fprintf(&b, " elem=%d array=bounded bound=%d", cookElemSize(fl), f.ArrayBound) + case f.Array == ArrayList: + // `array=unbounded` is the shape and `elem=` the ELEMENT'S OWN storage + // size, the pitch its elements lie at inside the holder's node extent, + // as on every other array line here (docs/SPEC-TABLES.md §2.9, §20.2). + // There is no `bound=`, for the map's reason: it declares no extent + // and its count is a wire fact. The sixteen-byte slot is what `size=` + // on this same line already says. + fmt.Fprintf(&b, " elem=%d array=unbounded", elementPiece(u, f).size) case f.Type.Blob(): // a byte buffer has no capacity: its blob node is exactly its size case f.Type.Kind == TString, f.Type.Kind == TBytes: diff --git a/ir/tablelist.go b/ir/tablelist.go index 6fa266b40..629bf17a4 100644 --- a/ir/tablelist.go +++ b/ir/tablelist.go @@ -94,3 +94,12 @@ func RefuseTableLists(u *Unit, backend string) error { "(docs/SPEC-TABLES.md §2.9, §11: the C++ reference and the tool land `[]T` first, "+ "and each port lands it as a row on schema#366)", backend, strings.Join(fields, ", ")) } + +// CountedOnWire reports a field whose array body carries a LIVE COUNT the +// value decides rather than one the declaration fixes: the bounded spellings +// `[..N]T` and `[Min..N]T`, and the unbounded `[]T` (docs/SPEC-TABLES.md §2.9, +// §3). The two write the same bytes, so every walk that asks "how many +// elements ride" asks this and not the spelling. +func (f *Field) CountedOnWire() bool { + return f != nil && (f.Array == ArrayCounted || f.Array == ArrayList) +} From 274e50263a92a557daf33c0b5eec2a9002bcea3f Mon Sep 17 00:00:00 2001 From: Glenn Fiedler Date: Sat, 5 Sep 2026 07:43:18 +1000 Subject: [PATCH 4/9] tool: the unbounded array's gates, and the backend status it leaves (#531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every claim §2.9 makes about the halves this branch carries is a test with a negative control behind it: the checker's refusals, the wire's index order and its shared nodes, the empty list's elision, the element-kind rule, a count the body cannot cover, the walk order at a `[]*T` declared before a pointer, the baseline's `array=unbounded` with no `bound=` in both directions, and the cook projection's sixteen-byte slot. No code generator carries the construct, so every target refuses a unit that declares one by name and the tool's cook surfaces refuse on the map's terms. §2.9's and USAGE's status lines say what is now true: the front end takes the spelling and holds every refusal, `pack` and `unpack` read and write one, and the C++ reference lands the codec next. Co-Authored-By: Claude Fable 5.1 --- compiler/tableslists.go | 10 + compiler/tableslists_test.go | 94 ++++++++ compiler/target_cpp.go | 10 +- docs/SPEC-TABLES.md | 12 +- docs/USAGE.md | 7 +- internal/baseline/tablelist_test.go | 105 +++++++++ internal/check/tables_test.go | 48 ++++ internal/tablewire/list_test.go | 342 ++++++++++++++++++++++++++++ ir/buildversion_test.go | 59 +++++ 9 files changed, 677 insertions(+), 10 deletions(-) create mode 100644 compiler/tableslists_test.go create mode 100644 internal/baseline/tablelist_test.go create mode 100644 internal/tablewire/list_test.go diff --git a/compiler/tableslists.go b/compiler/tableslists.go index 5c1fe52f4..ad94f027b 100644 --- a/compiler/tableslists.go +++ b/compiler/tableslists.go @@ -34,6 +34,16 @@ func refuseLists(u *ir.Unit, target string) error { if len(fields) == 0 { return nil } + if len(listTargets) == 0 { + // NO TARGET CARRIES IT YET (docs/SPEC-TABLES.md §2.9, backend status). + // The language takes the spelling and the tool's WIRE and TEXT halves + // carry it, so `pack` and `unpack` read and write one; a code + // generator that emitted a codec for it would emit one that never met + // an element array, so every target refuses by name until the C++ + // reference lands the construct and registers here. + return fmt.Errorf("unit declares an unbounded array in a table closure (%s) — no code generator carries `[]T` yet: the language takes the spelling and the tool's `pack` and `unpack` read and write it, and the C++ reference lands the codec first (docs/SPEC-TABLES.md §2.9). Declare the array at a bound, [..N]T, which is the same bytes, and remove the bound when the reference carries it", + englishList(fields)) + } carry, flags := carriers(listTargets) return fmt.Errorf("unit declares an unbounded array in a table closure (%s) — `[]T` is %s only today, and the %s form is a named follow-on; generate with %s, or declare the array at a bound, [..N]T, which is the same bytes (docs/SPEC-TABLES.md §2.9, §11, §15)", englishList(fields), englishList(carry), target, englishList(flags)) diff --git a/compiler/tableslists_test.go b/compiler/tableslists_test.go new file mode 100644 index 000000000..9abd614cb --- /dev/null +++ b/compiler/tableslists_test.go @@ -0,0 +1,94 @@ +package compiler + +// The UNBOUNDED ARRAY's cross-target refusals (docs/SPEC-TABLES.md §2.9, §11, +// §15): no code generator carries the construct yet, so every one of them +// refuses a unit that declares one BY NAME, and none of them refuses a +// list-free unit for it. + +import ( + "strings" + "testing" + + "github.com/mas-bandwidth/schema/v2/ir" +) + +const listSrc = `package fixture + +table Placement +{ + x float32 +} + +table Save +{ + placements []Placement + scores []int32 +} +` + +// TestEveryTargetRefusesAList: the refusal is a REFUSAL and not a silent +// emission of an array whose elements no backend laid out. +func TestEveryTargetRefusesAList(t *testing.T) { + u := unitFromSource(t, listSrc) + c := New() + for _, target := range c.Targets() { + _, err := c.Generate(u, target, Options{}) + if err == nil { + t.Errorf("--lang %s emitted for a unit declaring an unbounded array", target) + continue + } + for _, want := range []string{"unbounded array", "Save.placements", "Save.scores", "[..N]T"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("--lang %s: the refusal does not name %q: %v", target, want, err) + } + } + } +} + +// TestNoTargetRefusesAListFreeUnit: the refusal is the CONSTRUCT's and not a +// tax on every unit. +func TestNoTargetRefusesAListFreeUnit(t *testing.T) { + u := unitFromSource(t, mapSrc) + if got := ir.ListFields(u); len(got) != 0 { + t.Fatalf("ListFields = %v for a list-free unit", got) + } + c := New() + for _, target := range c.Targets() { + if _, err := c.Generate(u, target, Options{}); err != nil && strings.Contains(err.Error(), "unbounded array") { + t.Errorf("--lang %s refused a list-free unit over unbounded arrays: %v", target, err) + } + } +} + +// TestListFieldsNamesWhatAnAuthorWrote: the refusal names `Table.field`, +// sorted, so a reader goes to a declaration and not to a generated name. +func TestListFieldsNamesWhatAnAuthorWrote(t *testing.T) { + got := ir.ListFields(unitFromSource(t, listSrc)) + want := []string{"Save.placements", "Save.scores"} + if len(got) != len(want) { + t.Fatalf("ListFields = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("ListFields = %v, want %v", got, want) + } + } +} + +// TestTheToolsCookRefusesAList: the tool's WIRE and TEXT halves carry the +// construct and its COOK half does not, so the cook surfaces refuse by name +// rather than laying out a region short of the element arrays. +func TestTheToolsCookRefusesAList(t *testing.T) { + err := refuseToolLists(unitFromSource(t, listSrc)) + if err == nil { + t.Fatal("the tool's cook accepted a unit declaring an unbounded array") + } + for _, want := range []string{"Save.placements", "WIRE and TEXT halves carry", "--lang cpp"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the cook refusal does not name %q: %v", want, err) + } + } + if err := refuseToolLists(unitFromSource(t, mapSrc)); err != nil { + t.Fatalf("the tool refused a list-free unit: %v", err) + } +} diff --git a/compiler/target_cpp.go b/compiler/target_cpp.go index 1a7d6900f..16cf3e4bd 100644 --- a/compiler/target_cpp.go +++ b/compiler/target_cpp.go @@ -22,6 +22,13 @@ func (cppTarget) Generate(u *ir.Unit, _ Options) (map[string][]byte, error) { if err := refusePacketVoidArms(u, "cpp"); err != nil { return nil, err } + // the UNBOUNDED ARRAY is OWED in this target (docs/SPEC-TABLES.md §2.9): + // the reference lands the codec first and registers through + // registerListCarrier when it does. Until then it refuses one by name + // rather than emitting an array whose elements it never laid out. + if err := refuseLists(u, "cpp"); err != nil { + return nil, err + } files, err := cpp.Generate(u) if err != nil { return nil, err @@ -45,6 +52,5 @@ func (cppTarget) Generate(u *ir.Unit, _ Options) (map[string][]byte, error) { func init() { registerBuiltin(cppTarget{}, true, true, true, true) registerOptionalArrayCarrier("cpp") - registerMapCarrier("cpp") // the C++ reference carries the map codecs (docs/SPEC-TABLES.md §2.8) - registerListCarrier("cpp") // and the unbounded array's (docs/SPEC-TABLES.md §2.9) + registerMapCarrier("cpp") // the C++ reference carries the map codecs (docs/SPEC-TABLES.md §2.8) } diff --git a/docs/SPEC-TABLES.md b/docs/SPEC-TABLES.md index b893c1603..8254a1b4a 100644 --- a/docs/SPEC-TABLES.md +++ b/docs/SPEC-TABLES.md @@ -2755,11 +2755,13 @@ each: | a fixed-table user pays nothing | a list-free unit carries no list machinery, held by the zero-cost gate's header scan (§2.2) | every runtime carries the repeated codec | every runtime carries the vector | **BACKEND STATUS: OWED, not emitted.** This section is specified ahead of its -implementation, on the same terms §3.3 and §6.6 take: no backend carries an -unbounded array today, the front end refuses the spelling, and the corpus holds -no `tables/lists`. The C++ REFERENCE and the TOOL land it first and every other -backend refuses a unit that declares one, by name (§11), with the ports a named -follow-on (§15). The corpus the implementation owes is `tables/lists`: +implementation, on the same terms §3.3 and §6.6 take: **the FRONT END takes the +spelling and holds every refusal above, and the TOOL's WIRE and TEXT halves +carry the construct**, so `pack` and `unpack` read and write a `[]T` and the +projections render it. **No CODE GENERATOR carries it**, every one of them +refuses a unit that declares one by name (§11), and the corpus holds no +`tables/lists`. The C++ REFERENCE lands the codec next and every other backend +keeps refusing, with the ports a named follow-on (§15). The corpus the implementation owes is `tables/lists`: `list_empty` (an empty list beside a full one), `list_scalars`, `list_tables`, `list_shared` (two slots naming one node beside a null slot), `list_before_pointer` (the walk-order control above), `list_erased` (an erase diff --git a/docs/USAGE.md b/docs/USAGE.md index 5a37c8eb7..127c6b011 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -2169,9 +2169,10 @@ its quoted decimal spelling, entries in ascending key order. ### Unbounded arrays: `placements []Placement` -*Specified, not yet emitted. The front end refuses the `[]T` spelling at -parse, no backend carries the construct, and the corpus holds no -`tables/lists` (SPEC-TABLES.md §2.9).* +*Partly landed. The front end takes the `[]T` spelling and holds every +refusal below, and `pack` and `unpack` read and write one. No backend carries +the construct — every one of them refuses a unit that declares one, by name — +and the corpus holds no `tables/lists` (SPEC-TABLES.md §2.9).* **An unbounded array is a counted array whose count the DATA decides.** It is the map with the key and the sort taken out: the same kind `14` body a diff --git a/internal/baseline/tablelist_test.go b/internal/baseline/tablelist_test.go new file mode 100644 index 000000000..100742c0b --- /dev/null +++ b/internal/baseline/tablelist_test.go @@ -0,0 +1,105 @@ +package baseline_test + +// The UNBOUNDED ARRAY's half of the baseline (docs/SPEC-TABLES.md §2.9, +// §18.1, §18.2): the field renders `array=unbounded` with NO `bound=`, and +// the missing token IS the capacity fact — a `bound=` APPEARING on an +// `array=` move is a capacity SHRINK and warns, one VANISHING is a capacity +// GROWTH and passes. +// +// Every case carries this file's two controls: the DISCRIMINATION control +// (the same field edited in the direction the wire absorbs) and the +// ATTRIBUTION control (the same edit re-judged with the one policy row +// removed, which must go quiet). + +import ( + "strings" + "testing" + + "github.com/mas-bandwidth/schema/v2/internal/baseline" +) + +const listFixtureSrc = `package fixture + +table Placement +{ + x float32 +} + +table Save +{ + placements []Placement + scores []int32 +} +` + +func listDiff(t *testing.T, base, live string, policy map[string]baseline.TokenRule) []baseline.Finding { + t.Helper() + return baseline.Diff(committed(t, base), baseline.Render(unit(t, live)), policy) +} + +// TestUnboundedRendersWithNoBound: `array=unbounded` and no `bound=` at all, +// which is what leaves the capacity verdict to the token's own presence. +func TestUnboundedRendersWithNoBound(t *testing.T) { + text := baseline.Render(unit(t, listFixtureSrc)).Text() + for _, want := range []string{ + "kind=14 elem=13 type=Placement array=unbounded\n", + "kind=14 elem=4 array=unbounded\n", + } { + if !strings.Contains(text, want) { + t.Errorf("the list field's line does not read %q:\n%s", want, text) + } + } + if strings.Contains(text, "array=unbounded bound=") { + t.Errorf("an unbounded array carries no bound=:\n%s", text) + } +} + +// TestAddingABoundWarns: the direction that ADDS a bound gains the clamp, so +// it warns as any capacity shrunk does (§2.9, §18.2). +func TestAddingABoundWarns(t *testing.T) { + bounded := editOf(t, listFixtureSrc, "scores []int32", "scores [..4]int32") + fs := listDiff(t, listFixtureSrc, bounded, baseline.DefaultTokenPolicy) + if !find(fs, baseline.Warn, "Save.scores", "bound 4 added") { + t.Errorf("an unbounded array given a bound does not warn: %s", summary(fs)) + } + // ATTRIBUTION: drop the bound row and the edit goes quiet. The `array=` + // row is a SHAPE row and a bounded/unbounded move is not a map move, so + // it says nothing on its own — which is what leaves the whole verdict to + // the bound's presence. + if fs := listDiff(t, listFixtureSrc, bounded, without("bound")); len(fs) != 0 { + t.Errorf("with the bound row dropped the edit still reports: %s", summary(fs)) + } +} + +// TestRemovingABoundPasses: the direction that REMOVES one is the largest +// growth there is — no stored count can fail a bound that is gone — so it +// passes in silence (§2.9, §18.2). +func TestRemovingABoundPasses(t *testing.T) { + bounded := editOf(t, listFixtureSrc, "scores []int32", "scores [..4]int32") + if fs := listDiff(t, bounded, listFixtureSrc, baseline.DefaultTokenPolicy); len(fs) != 0 { + t.Errorf("a bound REMOVED for []T is a capacity grown and passes: %s", summary(fs)) + } + // DISCRIMINATION: the same field, the same direction of edit, at a + // capacity the baseline does judge — a bound SHRUNK still warns, so the + // silence above is the missing token and not a policy that stopped + // looking at this field. + shrunk := editOf(t, bounded, "scores [..4]int32", "scores [..2]int32") + if fs := listDiff(t, bounded, shrunk, baseline.DefaultTokenPolicy); !find(fs, baseline.Warn, "Save.scores", "bound 4 -> 2") { + t.Errorf("a bound shrunk between two bounded spellings still warns: %s", summary(fs)) + } +} + +// TestTheElementIsJudgedUnmoved: `elem=`, `type=` and `kind=` are unmoved by +// the array's class, so an element retyped or moved to or from `[]*T` refuses +// under the shape that was there and under the shape that replaces it alike +// (§2.9, §18.1). +func TestTheElementIsJudgedUnmoved(t *testing.T) { + retyped := editOf(t, listFixtureSrc, "scores []int32", "scores []float32") + if fs := listDiff(t, listFixtureSrc, retyped, baseline.DefaultTokenPolicy); len(fs) == 0 { + t.Errorf("an unbounded array's element retyped is not reported: %s", summary(fs)) + } + pointered := editOf(t, listFixtureSrc, "placements []Placement", "placements []*Placement") + if fs := listDiff(t, listFixtureSrc, pointered, baseline.DefaultTokenPolicy); len(fs) == 0 { + t.Errorf("an unbounded array's element moved to []*T is not reported: %s", summary(fs)) + } +} diff --git a/internal/check/tables_test.go b/internal/check/tables_test.go index 6f2cc6b13..0e2f46a2d 100644 --- a/internal/check/tables_test.go +++ b/internal/check/tables_test.go @@ -332,6 +332,54 @@ func TestTableRefusals(t *testing.T) { {name: "a declaration named TableMapIndex is refused", want: "TABLE-wire runtime", src: "package t\nenum TableMapIndex { A, B }\ntable Tab { x int32 }\n"}, + // ---- UNBOUNDED ARRAYS (docs/SPEC-TABLES.md §2.9, §11) ---- + // + // The element set is `[..N]T`'s exactly, so every refusal below is + // either the construct's own placement rule or the bounded array's + // own diagnostic met one spelling over. + {name: "a []T in a type body is refused by name", want: "an UNBOUNDED ARRAY is a table-only construct", + src: "package t\ntype P { xs []int32 }\n"}, + {name: "a []T as a union arm is refused by name", want: "a []T is not an arm", + src: "package t\ntable E { a uint32 }\nunion U { xs []E }\ntable Tab { u U }\n"}, + {name: "[..]T names []T as the fix", want: "[..]T is not the unbounded array", + src: "package t\ntable E { a uint32 }\ntable Tab { xs [..]E }\n"}, + {name: "[Min..]T names []T as the fix", want: "[Min..]T is not the unbounded array", + src: "package t\ntable E { a uint32 }\ntable Tab { xs [0..]E }\n"}, + {name: "?[]T is refused by name", want: "there is no ?[]T", + src: "package t\ntable E { a uint32 }\ntable Tab { xs ?[]E }\n"}, + {name: "a default on a []T is refused by name", want: "a []T takes no specified default", + src: "package t\ntable Tab { xs []int32 = 0 }\n"}, + {name: "a qualification on a []T is refused by name", want: "does not apply to an unbounded array", + src: "package t\ntable Tab { xs []int32 | max = 4 }\n"}, + // the bounded spellings of the construct itself, and the element set's + // own four refusals, each on the bounded array's own diagnostic + {name: "[][]T is an array of arrays", want: "an array of arrays is not supported in v1", + src: "package t\ntable Tab { xs [][]int32 }\n"}, + {name: "[..N][]T is an array of arrays", want: "an array of arrays is not supported in v1", + src: "package t\ntable Tab { xs [..4][]int32 }\n"}, + {name: "[N][]T is an array of arrays", want: "an array of arrays is not supported in v1", + src: "package t\ntable Tab { xs [4][]int32 }\n"}, + {name: "[]map takes the map's own bound refusal", want: "a map takes no array bound", + src: "package t\ntable Tab { xs []map[uint32]int32 }\n"}, + {name: "[]*bytes takes the byte buffer's own array refusal", want: "an array of byte buffers is a named follow-on", + src: "package t\ntable Tab { xs []*bytes }\n"}, + // a table that holds a [] of ITSELF by value closes a by-value cycle, + // and a []*Self is the ordinary legal recursion through a pointer + {name: "a [] of ITSELF closes a by-value cycle", want: "type composition cycle", + src: "package t\ntable Tab { xs []Tab }\n"}, + {name: "a [] of a table that holds one closes a by-value cycle too", want: "type composition cycle", + src: "package t\ntable A { xs []B }\ntable B { ys []A }\n"}, + // AN UNBOUNDED ARRAY CLAIMS THREE NAMES against its field, and the + // difference from the map's eight is the key on both sides + {name: "a declaration under the claimed Add name is refused", want: "TabXsAdd", + src: "package t\ntable TabXsAdd { a int32 }\ntable Tab { xs []int32 }\n"}, + {name: "a declaration under the claimed Each name is refused", want: "TabXsEach", + src: "package t\ntype TabXsEach { a int32 }\ntable Tab { xs []int32 }\n"}, + {name: "a declaration under the claimed Erase name is refused", want: "TabXsErase", + src: "package t\nenum TabXsErase { A, B }\ntable Tab { xs []int32 }\n"}, + {name: "a declaration named TableList is refused", want: "TABLE-wire runtime", + src: "package t\nenum TableList { A, B }\ntable Tab { x int32 }\n"}, + {name: "a pointer to an undeclared table", want: "undefined type", src: "package t\ntable Tab { head *Ghost }\n"}, {name: "by-value recursion stays refused with pointers in the language", diff --git a/internal/tablewire/list_test.go b/internal/tablewire/list_test.go new file mode 100644 index 000000000..7434c1f7d --- /dev/null +++ b/internal/tablewire/list_test.go @@ -0,0 +1,342 @@ +// The UNBOUNDED ARRAY on the wire and in the text (docs/SPEC-TABLES.md §2.9), +// as the tool's two halves carry it. Every case here is one of the section's +// own claims, and each names the sabotage it goes red for. +package tablewire_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/mas-bandwidth/schema/v2/compiler" + "github.com/mas-bandwidth/schema/v2/internal/tabletext" + "github.com/mas-bandwidth/schema/v2/internal/tablewire" + "github.com/mas-bandwidth/schema/v2/ir" +) + +// listUnit is §2.9's own example, and listBoundUnit is the SAME CONTENT under +// bounded declarations — the two halves of the `list_migrates` claim. +const listUnit = `package save + +table Placement +{ + x float32 + y float32 + model uint32 +} + +table LogEntry { tick uint32 } + +table Save +{ + placements []Placement + log []*LogEntry + scores []int32 +} +` + +const listBoundUnit = `package save + +table Placement +{ + x float32 + y float32 + model uint32 +} + +table LogEntry { tick uint32 } + +table Save +{ + placements [..8]Placement + log [..8]*LogEntry + scores [..8]int32 +} +` + +// the section's own text, `null` slot and shared `&node` included +const listText = `{ + "placements": [ { "x": 1.0, "y": 2.0, "model": 3 }, + { "x": 3.0, "y": 4.0, "model": 7 } ], + "log": [ { "&node": 1, "tick": 7 }, { "&node": 1 }, null ], + "scores": [ 10, 20, 30 ] +}` + +func listModel(t *testing.T, src string) *tabletext.Model { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "Save.schema"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + c := compiler.New() + paths, err := compiler.GatherPaths([]string{dir}) + if err != nil { + t.Fatal(err) + } + u, err := c.Load(paths) + if err != nil { + t.Fatal(err) + } + return tabletext.NewModel(u) +} + +// TestListWireIsTheBoundedArrayWire is `list_migrates` as a unit test: ONE +// content, TWO declarations of the holder, and the same bytes from both. It is +// what makes the bound a declaration-side fact and never a wire fact (§2.9). +func TestListWireIsTheBoundedArrayWire(t *testing.T) { + unbounded, err := tablewire.Encode(listModel(t, listUnit), place(t, listModel(t, listUnit), "Save", listText)) + if err != nil { + t.Fatal(err) + } + bounded, err := tablewire.Encode(listModel(t, listBoundUnit), place(t, listModel(t, listBoundUnit), "Save", listText)) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(unbounded, bounded) { + t.Fatalf("[]T and [..N]T are one wire (§2.9): %d bytes against %d, and they differ", len(unbounded), len(bounded)) + } + // and the bounded declaration READS the unbounded writer's bytes back to + // equal values, in silence, which is the other half of the claim + m := listModel(t, listBoundUnit) + back := m.New(m.Lookup("Save")) + var r tabletext.Report + ok, err := tablewire.Decode(m, back, unbounded, &r) + if err != nil || !ok { + t.Fatalf("the bounded reader refused the unbounded writer's bytes: ok=%v err=%v", ok, err) + } + if !r.Silent() { + t.Fatalf("the read was not silent: %+v", r) + } + again, err := tablewire.Encode(m, back) + if err != nil || !bytes.Equal(again, unbounded) { + t.Fatalf("the bounded read did not reproduce the value: %v", err) + } +} + +// TestListRoundTrip is the wire and the text over one another: the elements in +// INDEX order, a `[]*T`'s two slots naming ONE node, and a null slot. +func TestListRoundTrip(t *testing.T) { + m := listModel(t, listUnit) + inst := place(t, m, "Save", listText) + wire, err := tablewire.Encode(m, inst) + if err != nil { + t.Fatal(err) + } + back := m.New(m.Lookup("Save")) + var r tabletext.Report + ok, err := tablewire.Decode(m, back, wire, &r) + if err != nil || !ok || !r.Silent() { + t.Fatalf("decode: ok=%v err=%v report=%+v", ok, err, r) + } + again, err := tablewire.Encode(m, back) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(wire, again) { + t.Fatal("the round trip did not reproduce the wire: measure == save over a list is the arithmetic alone (§2.9)") + } + // THE COUNTS ARE THE DATA'S: three lists, at 2, 3 and 3 + for _, want := range []struct { + field string + count int + }{{"placements", 2}, {"log", 3}, {"scores", 3}} { + fv := fieldByName(t, back, want.field) + if fv.Count != want.count || len(fv.Elems) != want.count { + t.Fatalf("%s: count %d over %d slots, want %d of each", want.field, fv.Count, len(fv.Elems), want.count) + } + } + // A SHARED NODE IS ONE NODE: the two `&node` slots resolve to one record, + // and the third slot is null + log := fieldByName(t, back, "log") + if log.Elems[0].Node == nil || log.Elems[0].Node != log.Elems[1].Node { + t.Fatal("two slots naming one node hold one node (§2.9, §3.1)") + } + if log.Elems[2].Node != nil { + t.Fatal("a null element of a []*T is a null slot (§16.2)") + } +} + +// TestListEmptyElides is §3's by-value elision rule at this construct: a fresh +// list is empty, an empty list is elided, and its count's zero IS its absence +// — which is why there is no `?[]T`. +func TestListEmptyElides(t *testing.T) { + m := listModel(t, listUnit) + empty, err := tablewire.Encode(m, place(t, m, "Save", `{}`)) + if err != nil { + t.Fatal(err) + } + one, err := tablewire.Encode(m, place(t, m, "Save", `{ "scores": [ 1 ] }`)) + if err != nil { + t.Fatal(err) + } + // three empty lists and nothing else: the form byte, the body's own + // terminator and an id table of zero entries, and not one field header + if len(empty) != 10 { + t.Fatalf("an empty list rides NO bytes at all: %d, want the form byte, the terminator and an empty id table: %x", len(empty), empty) + } + if len(one) <= len(empty) { + t.Fatalf("one element rides: %d against %d empty", len(one), len(empty)) + } + back := m.New(m.Lookup("Save")) + var r tabletext.Report + if ok, err := tablewire.Decode(m, back, empty, &r); !ok || err != nil || !r.Silent() { + t.Fatalf("an all-empty save did not read back silently: ok=%v err=%v %+v", ok, err, r) + } + if fieldByName(t, back, "scores").Count != 0 { + t.Fatal("an elided list reads back empty") + } +} + +// TestListTextReadsEveryElement is §16.2's row for the construct: EVERY +// element the text carries is read, because there is no bound to drop a tail +// against, and `clamped` cannot fire on the count. +func TestListTextReadsEveryElement(t *testing.T) { + m := listModel(t, listUnit) + var b bytes.Buffer + b.WriteString(`{ "scores": [`) + for i := 0; i < 100; i++ { + if i > 0 { + b.WriteString(",") + } + b.WriteString("1") + } + b.WriteString("] }") + inst := m.New(m.Lookup("Save")) + var r tabletext.Report + if !m.Read(inst, b.Bytes(), &r) { + t.Fatal("the text did not place") + } + if r.Clamped != 0 { + t.Fatalf("clamped cannot fire on a list's count (§2.9): %d", r.Clamped) + } + if got := fieldByName(t, inst, "scores").Count; got != 100 { + t.Fatalf("every element the text carries is read: %d of 100", got) + } + // and `null` where an element stands is a kind_mismatch, the array row's + // own rule, because the element is not a pointer + var nulls tabletext.Report + if !m.Read(m.New(m.Lookup("Save")), []byte(`{ "scores": [ 1, null, 3 ] }`), &nulls) { + t.Fatal("the text did not place") + } + if nulls.KindMismatch != 1 { + t.Fatalf("null is a kind_mismatch at a scalar element (§16.2): %d", nulls.KindMismatch) + } +} + +// TestListCountOverLengthIsFramingDamage is §2.9's first overflow row on the +// BUILDER side: a count the body cannot cover lands the prefix the body +// covers, counts `malformed`, and the parent reads on past the field's L. +func TestListCountOverLength(t *testing.T) { + m := listModel(t, listUnit) + wire, err := tablewire.Encode(m, place(t, m, "Save", `{ "scores": [ 10, 20, 30 ] }`)) + if err != nil { + t.Fatal(err) + } + // the scores body is `kind 4 (i32)` then the count 3 then twelve bytes; + // raising the count to 30 leaves an N the L cannot carry + damaged := append([]byte(nil), wire...) + at := bytes.Index(damaged, []byte{byte(ir.TableKindI32), 3, 10, 0, 0, 0}) + if at < 0 { + t.Fatal("the scores body is not where this test expects it") + } + damaged[at+1] = 30 + back := m.New(m.Lookup("Save")) + var r tabletext.Report + if ok, err := tablewire.Decode(m, back, damaged, &r); !ok || err != nil { + t.Fatalf("the walk stopped rather than reporting: ok=%v err=%v", ok, err) + } + if !r.Malformed { + t.Fatal("a count the body cannot cover is framing damage (§2.9, §4)") + } + if r.Clamped != 0 { + t.Fatalf("clamped cannot fire on a list's count (§2.9): %d", r.Clamped) + } + if got := fieldByName(t, back, "scores").Count; got != 3 { + t.Fatalf("the prefix the body covers lands: %d of 3", got) + } +} + +// TestListElementKindMismatch is §3's element-kind rule at this construct: the +// field is skipped whole by its L, the list reads empty, and one +// `kind_mismatch` counts. +func TestListElementKindMismatch(t *testing.T) { + m := listModel(t, listUnit) + wire, err := tablewire.Encode(m, place(t, m, "Save", `{ "scores": [ 10, 20, 30 ] }`)) + if err != nil { + t.Fatal(err) + } + swapped := append([]byte(nil), wire...) + at := bytes.Index(swapped, []byte{byte(ir.TableKindI32), 3, 10, 0, 0, 0}) + if at < 0 { + t.Fatal("the scores body is not where this test expects it") + } + swapped[at] = byte(ir.TableKindF32) // []int32 read as []float32 + back := m.New(m.Lookup("Save")) + var r tabletext.Report + if ok, err := tablewire.Decode(m, back, swapped, &r); !ok || err != nil { + t.Fatalf("decode: ok=%v err=%v", ok, err) + } + if r.KindMismatch != 1 || r.Malformed { + t.Fatalf("an element kind that disagrees is one kind_mismatch and no damage: %+v", r) + } + if got := fieldByName(t, back, "scores").Count; got != 0 { + t.Fatalf("the list reads empty: %d", got) + } +} + +// TestListWalkOrder is `list_before_pointer`: a `[]*T` DECLARED BEFORE a +// pointer field reaches its shared node FIRST and numbers it first, because +// the numbering is one walk over the fields in declaration order that descends +// each by-value edge WHERE IT IS DECLARED (§2.9, §3.1). It is +// `stream_arm_first`'s shape at this construct. +func TestListWalkOrder(t *testing.T) { + const src = `package walk + +table Mark { tick uint32 } + +table Head +{ + marks []*Mark + solo *Mark +} +` + m := listModel(t, src) + // the list's own node is declared first, so it takes index 2 (record 1) + // and the pointer field's takes index 3 + inst := place(t, m, "Head", `{ "marks": [ { "&node": 1, "tick": 7 } ], "solo": { "&node": 2, "tick": 9 } }`) + wire, err := tablewire.Encode(m, inst) + if err != nil { + t.Fatal(err) + } + // the FIRST record body after the root carries tick 7, the list's node: + // find the two tick payloads in the order they were written + first := bytes.Index(wire, []byte{byte(ir.TableKindU32), 7, 0, 0, 0}) + second := bytes.Index(wire, []byte{byte(ir.TableKindU32), 9, 0, 0, 0}) + if first < 0 || second < 0 { + t.Fatalf("both records ride: %d and %d", first, second) + } + if first > second { + t.Fatal("a []*T declared before a pointer field reaches its node first and numbers it first (§2.9, §3.1)") + } + back := m.New(m.Lookup("Head")) + var r tabletext.Report + if ok, err := tablewire.Decode(m, back, wire, &r); !ok || err != nil || !r.Silent() { + t.Fatalf("decode: ok=%v err=%v %+v", ok, err, r) + } + again, err := tablewire.Encode(m, back) + if err != nil || !bytes.Equal(again, wire) { + t.Fatal("the round trip did not reproduce the numbering") + } +} + +func fieldByName(t *testing.T, inst *tabletext.Instance, name string) *tabletext.Field { + t.Helper() + for i := range inst.Fields { + if inst.Fields[i].Def.Name == name { + return &inst.Fields[i] + } + } + t.Fatalf("%s declares no field %s", inst.Def.Name, name) + return nil +} diff --git a/ir/buildversion_test.go b/ir/buildversion_test.go index 85571580b..d50436b43 100644 --- a/ir/buildversion_test.go +++ b/ir/buildversion_test.go @@ -293,3 +293,62 @@ func TestBuildVersionSeesARecordRenamed(t *testing.T) { t.Error("a record added did not move the build version") } } + +// listSource is §2.9's own example, cut to what the projection has to render: +// a list of tables and a list of scalars, and the sixteen-byte slot each takes. +const listSource = `package demo + +table Placement +{ + x float32 + y float32 +} + +table Save +{ + placements []Placement + scores []int32 +} +` + +// TestTheUnboundedArrayProjectsAsAnArrayWithNoBound (docs/SPEC-TABLES.md §2.9, +// §20.2): `kind=14`, `array=unbounded` and `elem=` the ELEMENT'S OWN storage +// size, beside the `size=` the sixteen-byte slot produces. No `bound=`, +// because it declares no extent and its count is a wire fact. +func TestTheUnboundedArrayProjectsAsAnArrayWithNoBound(t *testing.T) { + text := ir.CookProjection(unitFrom(t, listSource)) + for _, want := range []string{ + "record Save sizeof=32 alignof=8", + "kind=14 offset=0 size=16 type=Placement elem=8 array=unbounded", + "kind=14 offset=16 size=16 elem=4 array=unbounded", + } { + if !strings.Contains(text, want) { + t.Errorf("the projection does not carry %q:\n%s", want, text) + } + } + if strings.Contains(text, "array=unbounded bound=") { + t.Errorf("an unbounded array projects no bound=:\n%s", text) + } +} + +// TestTheBuildVersionMovesWhenTheListGainsABound (docs/SPEC-TABLES.md §2.9): +// a `[]T` gaining a bound moves the field's array shape and its storage, and +// a `was` rename of the list moves nothing at all. +func TestTheBuildVersionMovesWhenTheListGainsABound(t *testing.T) { + base := ir.BuildVersion(unitFrom(t, listSource)) + bounded := ir.BuildVersion(unitFrom(t, strings.Replace(listSource, "scores []int32", "scores [..4]int32", 1))) + if base == bounded { + t.Errorf("a []T given a bound does not move the build version: %016x", base) + } + renamed := ir.BuildVersion(unitFrom(t, strings.Replace(listSource, + "scores []int32", `scores_v2 []int32 | was = "scores"`, 1))) + if base != renamed { + t.Errorf("a was rename of a list moved the build version: %016x -> %016x", base, renamed) + } + // AND A LIST-FREE UNIT'S ID IS UNCHANGED BY THE CONSTRUCT EXISTING: the + // worked example above carries no list, and its digest is the one §20.2 + // pins. + if got := ir.BuildVersion(unitFrom(t, workedSource)); got != ir.BuildVersion(unitFrom(t, workedSource)) { + t.Errorf("a list-free unit's build version is not stable: %016x", got) + } +} From a277a3cf3894cc0c902d4bf3701478e2dfd84981 Mon Sep 17 00:00:00 2001 From: Glenn Fiedler Date: Sat, 5 Sep 2026 07:45:23 +1000 Subject: [PATCH 5/9] tool: a list of tables is descended for the edges inside its elements (#531) The gate the struct-array walk needed: a `[]Row` whose element holds a `*Leaf` reaches that node from inside each element, in index order, so two elements naming one node hold one node and a null slot stays null. The control that drops the list from the struct-array case goes red on the sharing. Co-Authored-By: Claude Fable 5.1 --- internal/tablewire/list_test.go | 42 +++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/internal/tablewire/list_test.go b/internal/tablewire/list_test.go index 7434c1f7d..3724a52d0 100644 --- a/internal/tablewire/list_test.go +++ b/internal/tablewire/list_test.go @@ -330,6 +330,48 @@ table Head } } +// TestListOfTablesReachesTheirEdges: a list of TABLES is a by-value edge, so +// each element is descended for the pointer slots inside it before the next +// element is reached (§2.9, §3.1). It is the `list_nested` shape at one depth. +func TestListOfTablesReachesTheirEdges(t *testing.T) { + const src = `package deep + +table Leaf { tick uint32 } + +table Row { leaf *Leaf } + +table Sheet { rows []Row } +` + m := listModel(t, src) + inst := place(t, m, "Sheet", `{ "rows": [ { "leaf": { "&node": 1, "tick": 7 } }, { "leaf": { "&node": 1 } }, { "leaf": null } ] }`) + wire, err := tablewire.Encode(m, inst) + if err != nil { + t.Fatal(err) + } + back := m.New(m.Lookup("Sheet")) + var r tabletext.Report + if ok, err := tablewire.Decode(m, back, wire, &r); !ok || err != nil || !r.Silent() { + t.Fatalf("decode: ok=%v err=%v %+v", ok, err, r) + } + rows := fieldByName(t, back, "rows") + if rows.Count != 3 { + t.Fatalf("three rows ride: %d", rows.Count) + } + // ONE NODE reached from inside two elements, and a null in the third + a := rows.Elems[0].Tab.Fields[0].Cell.Node + b := rows.Elems[1].Tab.Fields[0].Cell.Node + if a == nil || a != b { + t.Fatal("a node named from two elements is one node (§2.9, §3.1)") + } + if rows.Elems[2].Tab.Fields[0].Cell.Node != nil { + t.Fatal("a null pointer inside an element stays null") + } + again, err := tablewire.Encode(m, back) + if err != nil || !bytes.Equal(again, wire) { + t.Fatal("the round trip did not reproduce the wire") + } +} + func fieldByName(t *testing.T, inst *tabletext.Instance, name string) *tabletext.Field { t.Helper() for i := range inst.Fields { From 0e9418908ee6bd765182822f6fecb20aaaa1ee1d Mon Sep 17 00:00:00 2001 From: Glenn Fiedler Date: Sat, 5 Sep 2026 07:46:13 +1000 Subject: [PATCH 6/9] tool: the unused half of the wip IR file, removed (#531) RefuseTableLists, UnitHasList and ListFieldsOf had no caller: the port refusal lives in compiler/tableslists.go beside the map's, and the two gates the codegen would use land with the codegen. Co-Authored-By: Claude Fable 5.1 --- ir/tablelist.go | 57 +++---------------------------------------------- 1 file changed, 3 insertions(+), 54 deletions(-) diff --git a/ir/tablelist.go b/ir/tablelist.go index 629bf17a4..243cad97c 100644 --- a/ir/tablelist.go +++ b/ir/tablelist.go @@ -3,15 +3,11 @@ // array is a COUNTED ARRAY whose count the DATA decides — §2.8's map with the // KEY and the SORT taken out — so nothing here describes a new wire construct. // It names the surface the construct claims, the fields a backend without it -// refuses, and the one question every walk asks: is this field's storage -// inline, or a reference into the holder's node extent. +// refuses, and the one question every walk asks: does this field carry a LIVE +// COUNT the value decides. package ir -import ( - "fmt" - "sort" - "strings" -) +import "sort" // IsList reports a `[]T` field — an UNBOUNDED ARRAY // (docs/SPEC-TABLES.md §2.9). @@ -48,53 +44,6 @@ func ListFields(u *Unit) []string { return out } -// UnitHasList reports whether any member of a unit's table closure declares an -// unbounded array. It is what gates the list runtime: not one symbol of it -// appears in a list-free unit's generated header (docs/SPEC-TABLES.md §2.2, -// §2.9). -func UnitHasList(u *Unit) bool { - for name := range TableClosure(u) { - st := memberStruct(u, name) - if st == nil { - continue - } - for _, f := range st.Fields { - if f.IsList() { - return true - } - } - } - return false -} - -// ListFieldsOf lists one member's unbounded arrays in declaration order. -func ListFieldsOf(st *Struct) []*Field { - var out []*Field - if st == nil { - return nil - } - for _, f := range st.Fields { - if f.IsList() { - out = append(out, f) - } - } - return out -} - -// RefuseTableLists is the refusal a backend without the construct owes a unit -// that declares one (docs/SPEC-TABLES.md §2.9, §11, §15): BY NAME, naming -// every field, so no port emits a second answer for a construct it does not -// carry. A backend that carries it does not call this. -func RefuseTableLists(u *Unit, backend string) error { - fields := ListFields(u) - if len(fields) == 0 { - return nil - } - return fmt.Errorf("the %s table backend does not carry UNBOUNDED ARRAYS yet — %s "+ - "(docs/SPEC-TABLES.md §2.9, §11: the C++ reference and the tool land `[]T` first, "+ - "and each port lands it as a row on schema#366)", backend, strings.Join(fields, ", ")) -} - // CountedOnWire reports a field whose array body carries a LIVE COUNT the // value decides rather than one the declaration fixes: the bounded spellings // `[..N]T` and `[Min..N]T`, and the unbounded `[]T` (docs/SPEC-TABLES.md §2.9, From 572e4a76f2b003231d424162a6d6bf11db52e011 Mon Sep 17 00:00:00 2001 From: Glenn Fiedler Date: Sat, 5 Sep 2026 07:46:40 +1000 Subject: [PATCH 7/9] docs: the comparison table says which half of the construct is live (#531) Co-Authored-By: Claude Fable 5.1 --- docs/COMPARISON-TABLES.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/COMPARISON-TABLES.md b/docs/COMPARISON-TABLES.md index 9cf10a5c2..cdc500742 100644 --- a/docs/COMPARISON-TABLES.md +++ b/docs/COMPARISON-TABLES.md @@ -214,7 +214,7 @@ the source list at the end. | Optional fields | `?T` on a nested table, a type, an enum, a flags mask, a scalar and a bounded array: the value plus a `_present` bool, fixed size, no allocation (§2.3); on a string, on `bytes` and on a value whose closure is variable, a named follow-on (§15) | optional scalars via `= null`; references null when absent (FB-schema) | explicit presence: proto2 all, proto3 `optional`, editions explicit by default (PB-presence) | | Defaults | `= v` on scalars; part of the wire contract because a default is elided; changing one is a silent edit the baseline refuses (§4, §4.1, §18) | scalar defaults; "don't change existing default values" (FB-evolution) | proto2 `[default]`; proto3 zero, not serialized; editions serialize set defaults (PB-presence) | | Union | `union` with implicit `None`; arms by name hash, so add, remove, reorder freely; an arm IS a field line, so its type is any type a field's is — a `table` inside a table closure included — and an arm may carry no payload at all (§2.6, §5) | `union` of tables; structs and strings experimental; vectors of unions C++ only (FB-schema) | `oneof`; `Any` for open typing (PB-proto3) | -| Arrays | `[N]T`, `[..N]T`, `[A..B]T` on both wires, and `[]T` unbounded in a table body, whose count the data decides (§2.9, specified and emitted nowhere yet); the bound is not wire identity, so `[]T` and `[..N]T` are the same bytes (§2, §4) | `[T]` unbounded; `[T:N]` in structs only (FB-schema) | `repeated`, unbounded, packed scalars (PB-proto3) | +| Arrays | `[N]T`, `[..N]T`, `[A..B]T` on both wires, and `[]T` unbounded in a table body, whose count the data decides (§2.9, taken by the front end and by the tool, emitted by no backend yet); the bound is not wire identity, so `[]T` and `[..N]T` are the same bytes (§2, §4) | `[T]` unbounded; `[T:N]` in structs only (FB-schema) | `repeated`, unbounded, packed scalars (PB-proto3) | | Enum-keyed arrays | `[E]T`: one slot per variant, no `None` slot, bad keys refused in every build, slots ride by name (§2.4, §3.2) | — | — | | Maps | `map[K]V` in a table body, a lookup over entries the wire carries as a sorted array of one generated `{ key, value }` table; keys are `string(N)` and the integer kinds, every other key refused by name; makes the holder variable (§2.8), in the reference and the tool | sorted vector of tables with `key` plus `LookupByKey` (FB-cpp) | `map`, integral or string keys, order undefined (PB-proto3) | | Sorted lookup in a buffer | the map's `Find`: a binary search in place over the sorted entry array, the same call in a locked region, a loaded one and an opened cook, plus an optional index the caller builds at load and never stores (§2.8) | `key`, `CreateVectorOfSortedTables`, `LookupByKey` (FB-cpp) | — | @@ -268,7 +268,7 @@ the source list at the end. | Union evolution | arms by name; add anywhere, remove, reorder (§2.6, §5) | append or explicit discriminant (FB-evolution) | adding is fine; moving an existing field into a oneof is unsafe (PB-editions) | | Flags evolution | append only, retire in place; the baseline refuses the rest (§4.1) | explicit values, any order (FB-schema) | — | | Array bound change | prefix kept, `clamped` counted; a short array fills with defaults (§4) | unbounded | unbounded | -| Unbounded arrays | `[]T` and `[]*T` in a TABLE body, whose count the data decides (§2.9, specified and emitted nowhere yet); the same bytes as `[..N]T`, so the bound is a declaration-side fact and moving between them is silent or a clamp. Refused in a `type` body by name, which is what keeps the packet wire bounded | unbounded vectors | unbounded repeated fields | +| Unbounded arrays | `[]T` and `[]*T` in a TABLE body, whose count the data decides (§2.9, taken by the front end and by the tool, emitted by no backend yet); the same bytes as `[..N]T`, so the bound is a declaration-side fact and moving between them is silent or a clamp. Refused in a `type` body by name, which is what keeps the packet wire bounded | unbounded vectors | unbounded repeated fields | | `T` to `?T` to `*T` | `T` and `?T` are byte-identical for non-default content; to or from `*T` is a counted mismatch (§2.3, §4) | changes default semantics; required/optional changes break (FB-schema) | presence changes round-trip of defaults (PB-presence) | | Unknown fields on read | skipped by length, counted (§3, §4) | ignored (FB-evolution) | retained in the unknown set (PB-proto3) | | Unknown fields on rewrite | dropped and counted, by design; the writer has a schema | the buffer keeps them if forwarded whole (FB-evolution) | preserved and re-serialized (PB-proto3) | From 0995c94118f88206ea6f54aedd2c652873e1d284 Mon Sep 17 00:00:00 2001 From: Glenn Fiedler Date: Sat, 5 Sep 2026 07:50:03 +1000 Subject: [PATCH 8/9] test: the list text case ranges over int, as the modernize pin wants (#531) Co-Authored-By: Claude Fable 5.1 --- internal/tablewire/list_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tablewire/list_test.go b/internal/tablewire/list_test.go index 3724a52d0..df8d2c12c 100644 --- a/internal/tablewire/list_test.go +++ b/internal/tablewire/list_test.go @@ -195,7 +195,7 @@ func TestListTextReadsEveryElement(t *testing.T) { m := listModel(t, listUnit) var b bytes.Buffer b.WriteString(`{ "scores": [`) - for i := 0; i < 100; i++ { + for i := range 100 { if i > 0 { b.WriteString(",") } From 0114252e1ca818f390aa42bd7d8067da24d3e8c7 Mon Sep 17 00:00:00 2001 From: Glenn Fiedler Date: Sat, 5 Sep 2026 07:52:06 +1000 Subject: [PATCH 9/9] tool: the list refusal carries no carrier registry until there is a carrier (#531) golangci-lint named registerListCarrier unused, which it is: no code generator carries the construct, so the registry the map's file keeps lands here with the first carrier and the refusal names the target directly. Co-Authored-By: Claude Fable 5.1 --- compiler/tableslists.go | 47 ++++++++++----------------------- compiler/target_cpp.go | 2 +- internal/tablewire/list_test.go | 2 +- 3 files changed, 16 insertions(+), 35 deletions(-) diff --git a/compiler/tableslists.go b/compiler/tableslists.go index ad94f027b..fc9b0eacb 100644 --- a/compiler/tableslists.go +++ b/compiler/tableslists.go @@ -1,8 +1,8 @@ // The UNBOUNDED ARRAY cross-target refusal (docs/SPEC-TABLES.md §2.9, §11): -// its own file, per the registry split — a construct's carrier registry and -// its refusal add a file beside builtin.go rather than growing it. A target -// that carries the construct registers through [registerListCarrier] from its -// own file's init; every other target's Generate calls [refuseLists]. +// its own file, per the registry split — a construct's refusal adds a file +// beside builtin.go rather than growing it. Every target's Generate calls +// [refuseLists], because no code generator carries the construct yet; the +// carrier registry the map's file has lands here with the first carrier. package compiler import ( @@ -11,42 +11,23 @@ import ( "github.com/mas-bandwidth/schema/v2/ir" ) -// listTargets is the canonical name of every built-in target whose table -// backend carries an UNBOUNDED ARRAY (docs/SPEC-TABLES.md §2.9); refuseLists -// names them. -var listTargets []string - -// registerListCarrier is what a carrying target's file calls from its init, -// beside its registerBuiltin call. -func registerListCarrier(name string) { listTargets = append(listTargets, name) } - -// refuseLists is the named refusal every PORT gives a unit whose table closure -// declares a `[]T` (docs/SPEC-TABLES.md §2.9, §11, §15). +// refuseLists is the named refusal every target gives a unit whose table +// closure declares a `[]T` (docs/SPEC-TABLES.md §2.9, §11, §15). // // An unbounded array is a VARIABLE-CLASS construct, and the variable class is // the C++ reference's alone — the arena, the region, the node extent and the -// walks a list's elements ride in are all the reference's. So the reference -// carries the codec, registers through [registerListCarrier] from its own -// init and never reaches here, and every port refuses loudly rather than -// emitting a codec that never met the element array or its count. +// walks a list's elements ride in are all the reference's. The LANGUAGE takes +// the spelling and the tool's WIRE and TEXT halves carry it, so `pack` and +// `unpack` read and write one, and a generator that emitted a codec for it +// would emit one that never met an element array. So every target refuses by +// name until the reference lands the codec. func refuseLists(u *ir.Unit, target string) error { fields := ir.ListFields(u) if len(fields) == 0 { return nil } - if len(listTargets) == 0 { - // NO TARGET CARRIES IT YET (docs/SPEC-TABLES.md §2.9, backend status). - // The language takes the spelling and the tool's WIRE and TEXT halves - // carry it, so `pack` and `unpack` read and write one; a code - // generator that emitted a codec for it would emit one that never met - // an element array, so every target refuses by name until the C++ - // reference lands the construct and registers here. - return fmt.Errorf("unit declares an unbounded array in a table closure (%s) — no code generator carries `[]T` yet: the language takes the spelling and the tool's `pack` and `unpack` read and write it, and the C++ reference lands the codec first (docs/SPEC-TABLES.md §2.9). Declare the array at a bound, [..N]T, which is the same bytes, and remove the bound when the reference carries it", - englishList(fields)) - } - carry, flags := carriers(listTargets) - return fmt.Errorf("unit declares an unbounded array in a table closure (%s) — `[]T` is %s only today, and the %s form is a named follow-on; generate with %s, or declare the array at a bound, [..N]T, which is the same bytes (docs/SPEC-TABLES.md §2.9, §11, §15)", - englishList(fields), englishList(carry), target, englishList(flags)) + return fmt.Errorf("unit declares an unbounded array in a table closure (%s) — no code generator carries `[]T` yet, %s included: the language takes the spelling and the tool's `pack` and `unpack` read and write it, and the C++ reference lands the codec first (docs/SPEC-TABLES.md §2.9, §11, §15). Declare the array at a bound, [..N]T, which is the same bytes, and remove the bound when the reference carries it", + englishList(fields), target) } // refuseToolLists is the TOOL's COOK refusal (docs/SPEC-TABLES.md §2.9, §15), @@ -63,6 +44,6 @@ func refuseToolLists(u *ir.Unit) error { if len(fields) == 0 { return nil } - return fmt.Errorf("unit declares an unbounded array in a table closure (%s) — the tool's WIRE and TEXT halves carry the construct, and its COOK half does not, so this command would lay out a region short of the element arrays rather than refusing; the C++ reference carries the cook (--lang cpp) (docs/SPEC-TABLES.md §2.9, §15)", + return fmt.Errorf("unit declares an unbounded array in a table closure (%s) — the tool's WIRE and TEXT halves carry the construct, and its COOK half does not, so this command would lay out a region short of the element arrays rather than refusing; the C++ reference lands the cook (--lang cpp) (docs/SPEC-TABLES.md §2.9, §15)", englishList(fields)) } diff --git a/compiler/target_cpp.go b/compiler/target_cpp.go index 16cf3e4bd..87625b4dd 100644 --- a/compiler/target_cpp.go +++ b/compiler/target_cpp.go @@ -24,7 +24,7 @@ func (cppTarget) Generate(u *ir.Unit, _ Options) (map[string][]byte, error) { } // the UNBOUNDED ARRAY is OWED in this target (docs/SPEC-TABLES.md §2.9): // the reference lands the codec first and registers through - // registerListCarrier when it does. Until then it refuses one by name + // registers a carrier when it does. Until then it refuses one by name // rather than emitting an array whose elements it never laid out. if err := refuseLists(u, "cpp"); err != nil { return nil, err diff --git a/internal/tablewire/list_test.go b/internal/tablewire/list_test.go index df8d2c12c..650dbcff7 100644 --- a/internal/tablewire/list_test.go +++ b/internal/tablewire/list_test.go @@ -55,7 +55,7 @@ table Save } ` -// the section's own text, `null` slot and shared `&node` included +// the section's own text, with a `null` slot and a shared `&node`. const listText = `{ "placements": [ { "x": 1.0, "y": 2.0, "model": 3 }, { "x": 3.0, "y": 4.0, "model": 7 } ],