From 6f3e054babd63406ad4b8619370af7f8c37b7a4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Wed, 7 Oct 2026 21:37:24 +0200 Subject: [PATCH] test(asm): pin the flag-list TEXT shapes against the toolchain Assisted-by: GLM 5.3 Flash --- asm/flaglist_integration_test.go | 127 +++++++++++++++++++++++++++++++ parser/flags.go | 19 +++-- parser/flags_test.go | 5 +- parser/parser.go | 4 +- 4 files changed, 145 insertions(+), 10 deletions(-) create mode 100644 asm/flaglist_integration_test.go diff --git a/asm/flaglist_integration_test.go b/asm/flaglist_integration_test.go new file mode 100644 index 0000000..6b32451 --- /dev/null +++ b/asm/flaglist_integration_test.go @@ -0,0 +1,127 @@ +// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +package asm + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "sourcedock.dev/petrbalvin/gasm-sdk/parser" +) + +// flagListShapes are the TEXT shapes the flags operand decides: joined +// names, the legacy numeric spellings, the arithmetic and immediate forms, +// each with a frame size beside it. A flag the operand carries suppresses +// the stack-split guard; the toolchain reads one integer from the operand, +// and after the front end folds it the bytes must not tell the difference. +// The bodies are leaf-shaped on purpose: the frame engine's open divergences +// (the NOFRAME prologue for a non-leaf, the big-frame guard's shape) sit +// outside the flags operand and are somebody else's gap list. +var flagListShapes = []struct { + name string // the subtest's name + flags string // the flags operand as written + frame string // the frame operand as written + body string // the function body +}{ + {"name", "NOSPLIT", "$4096-0", "\tMOVD R0, R1\n\tRET\n"}, + {"numeric", "4", "$4096-0", "\tMOVD R0, R1\n\tRET\n"}, + {"numericOR", "2|4", "$4096-0", "\tMOVD R0, R1\n\tRET\n"}, + {"joinedFrame", "DUPOK|NOSPLIT", "$4096-0", "\tMOVD R0, R1\n\tRET\n"}, + {"joinedFrameless", "NOSPLIT|TOPFRAME", "$0-0", "\tRET\n"}, + {"parenthesised", "(NOSPLIT|NOFRAME)", "$0-0", "\tMOVD R0, R1\n\tRET\n"}, +} + +// TestFlagListAssemblyPathRejectsUnknown holds the front end's rejections +// that mirror the toolchain's: an identifier outside the flag table +// ("unexpected TYPO evaluating expression") and the immediate spelling of +// the operand ("TEXT: expected integer constant"), each refused on the +// assembly path before the encoder ever sees a tree. +func TestFlagListAssemblyPathRejectsUnknown(t *testing.T) { + for _, c := range []struct{ src, want string }{ + {"#include \"textflag.h\"\n\nTEXT f(SB), NOSPLIT|TYPO, $0-0\n\tRET\n", + "unexpected TYPO evaluating expression"}, + {"#include \"textflag.h\"\n\nTEXT f(SB), $NOSPLIT, $0-0\n\tRET\n", + "TEXT: expected integer constant; found $NOSPLIT"}, + {"#include \"textflag.h\"\n\nGLOBL g<>(SB), $8, $8\n", + "GLOBL: expected integer constant; found $8"}, + } { + _, errs := parser.ParseWithOptions("f.s", c.src, parser.Options{Expand: true}) + if len(errs) == 0 || !strings.Contains(errs[0].Error(), c.want) { + t.Errorf("errors = %v, want %q", errs, c.want) + } + } +} + +// TestFlagListByteParity assembles every flag-list shape through the fixed +// front end and holds the function's bytes equal to the installed +// toolchain's, no guard words where the flag suppresses them and none +// missing where it demands one. +func TestFlagListByteParity(t *testing.T) { + if testing.Short() { + t.Skip("live go tool asm oracle: skipped in -short mode") + } + goBin, err := exec.LookPath("go") + if err != nil { + t.Skip("no Go toolchain available") + } + out, err := exec.Command(goBin, "env", "GOROOT").Output() + if err != nil { + t.Fatalf("go env GOROOT: %v", err) + } + include := filepath.Join(strings.TrimSpace(string(out)), "pkg", "include") + + for _, c := range flagListShapes { + t.Run(c.name, func(t *testing.T) { + src := "#include \"textflag.h\"\n\nTEXT f(SB), " + c.flags + ", " + c.frame + "\n" + c.body + dir := t.TempDir() + file := filepath.Join(dir, "f.s") + if err := os.WriteFile(file, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + + af, errs := parser.ParseWithOptions(file, src, parser.Options{Expand: true}) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs[0]) + } + img, err := AssembleFileARM64(af) + if err != nil { + t.Fatalf("AssembleFileARM64: %v", err) + } + if len(img.Funcs) != 1 { + t.Fatalf("%d functions, want 1", len(img.Funcs)) + } + + objPath := filepath.Join(t.TempDir(), "oracle.o") + cmd := exec.Command(goBin, "tool", "asm", "-std", "-I", include, + "-p", "flaglisttest", "-o", objPath, file) + cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=arm64") + if oout, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("go tool asm: %v\n%s", err, oout) + } + goCode, ok := oracleFuncText(t, mustRead(t, objPath))["f"] + if !ok { + t.Fatal("the oracle output carries no f") + } + fn := img.Funcs[0] + gasmCode := maskCode(append([]byte(nil), img.Code[fn.Offset:fn.Offset+fn.Size]...), fn.Relocs) + goCode = maskCode(append([]byte(nil), goCode...), fn.Relocs) + cmpLen := min(len(goCode), len(gasmCode)) + if !bytes.Equal(gasmCode[:cmpLen], goCode[:cmpLen]) { + t.Errorf("bytes differ:\ngasm: % x\ngo: % x", gasmCode[:cmpLen], goCode[:cmpLen]) + } + if len(goCode) > len(gasmCode) { + for _, b := range goCode[len(gasmCode):] { + if b != 0 { + t.Errorf("non-zero trailing bytes in the oracle output") + break + } + } + } + }) + } +} diff --git a/parser/flags.go b/parser/flags.go index ee118e5..2ad04a4 100644 --- a/parser/flags.go +++ b/parser/flags.go @@ -77,7 +77,8 @@ func flagsRun(g []token.Token) []token.Token { // evalFlags evaluates one flags operand. Every identifier must name a // flag in textFlags; the toolchain rejects any other with "unexpected NAME // evaluating expression", and so does this. The whole operand folds to -// one integer, which is returned as the value. +// one integer, which is returned as the value. pseudo names the directive +// in the toolchain's own diagnostics ("TEXT", "GLOBL"). // // The names returned follow the operand's own spelling: an operand written // purely as names keeps them in written order, duplicates included, the @@ -89,17 +90,21 @@ func flagsRun(g []token.Token) []token.Token { // After a failure the names collected so far are returned with value 0: // the tree stays usable beside the diagnostic, and value 0 is exactly the // flags the assembler can trust the operand for. -func (p *state) evalFlags(g []token.Token, keepWritten bool) ([]string, int64) { +func (p *state) evalFlags(g []token.Token, keepWritten bool, pseudo string) ([]string, int64) { if len(g) == 0 { return nil, 0 } - // An explicit '$' makes the operand an immediate ('$NOSPLIT' over the - // preprocessor's '$4'); the toolchain reads it as the same constant. + // The flags operand is a bare constant expression; the immediate + // spelling is the toolchain's "TEXT: expected integer constant". if g[0].Kind == token.Dollar { - g = g[1:] - if len(g) == 0 { - return nil, 0 + if p.expand { + found := "$" + if len(g) > 1 { + found += g[1].Text + } + p.errorf(g[0].Pos, "%s: expected integer constant; found %s", pseudo, found) } + return nil, 0 } sub := make([]token.Token, 0, len(g)) diff --git a/parser/flags_test.go b/parser/flags_test.go index 85757b6..dc6d7fc 100644 --- a/parser/flags_test.go +++ b/parser/flags_test.go @@ -53,7 +53,10 @@ func TestTextFlagsExpression(t *testing.T) { {"(NOSPLIT|NOFRAME)", []string{"NOSPLIT", "NOFRAME"}, 516}, {"NOSPLIT|4", []string{"NOSPLIT"}, 4}, {"NOSPLIT&DUPOK", nil, 0}, - {"$NOSPLIT", []string{"NOSPLIT"}, 4}, + // The immediate spelling is the toolchain's "expected integer + // constant" on the assembly path; the raw view reads nothing from + // it and stays quiet. + {"$NOSPLIT", nil, 0}, } { txt := parseTextHeader(t, "TEXT \u00b7f(SB), "+c.operand+", $0") if !slices.Equal(txt.Flags, c.wantFlag) { diff --git a/parser/parser.go b/parser/parser.go index 603080a..a6c6e8e 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -261,7 +261,7 @@ func (p *state) parseText(line []token.Token) { var frame []token.Token switch { case len(ops) >= 2: - text.Flags, text.FlagVal = p.evalFlags(flagsRun(ops[0]), false) + text.Flags, text.FlagVal = p.evalFlags(flagsRun(ops[0]), false, "TEXT") frame = ops[1] if len(ops) > 2 { p.errorf(line[0].Pos, "expect two or three operands for TEXT") @@ -355,7 +355,7 @@ func (p *state) parseGlobl(line []token.Token) *ast.Globl { ops := splitOperands(rest) switch { case len(ops) >= 2: - g.Flags, g.FlagVal = p.evalFlags(flagsRun(ops[0]), true) + g.Flags, g.FlagVal = p.evalFlags(flagsRun(ops[0]), true, "GLOBL") g.Size = parseOperand(ops[1], false) case len(ops) == 1: g.Size = parseOperand(ops[0], false)