test(asm): pin the riscv64 error parity against the toolchain catalogues
Assisted-by: GLM 5.3 Flash
This commit is contained in:
1 parent
f0942ff7f3
commit
d18195f581
1 file changed
+225
@@ -0,0 +1,225 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package asm
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/gasm-sdk/parser"
|
||||
)
|
||||
|
||||
// riscv64ErrorCatalogues are the toolchain's own negative-case files for
|
||||
// riscv64, relative to cmd/asm's testdata: riscv64error.s carries the
|
||||
// preprocess-stage rejections and riscv64validation.s the validate-stage
|
||||
// ones (register banks, compressed constraints, vector shapes).
|
||||
var riscv64ErrorCatalogues = []string{"riscv64error.s", "riscv64validation.s"}
|
||||
|
||||
// riscv64AcceptedErrorShapes lists the toolchain's catalogue spellings gasm
|
||||
// still accepts, each an acceptance superset with a documented reason. The
|
||||
// list only shrinks: every tightening of the encoder moves spellings out of
|
||||
// it, and a spelling reappearing here means a regression.
|
||||
var riscv64AcceptedErrorShapes = []string{}
|
||||
|
||||
// TestRISCVToolchainErrorParity walks the toolchain's riscv64error.s and
|
||||
// riscv64validation.s (Go 1.27) and requires gasm to reject every case the
|
||||
// toolchain rejects, with an equivalent diagnostic, the documented acceptance
|
||||
// supersets above excepted. A live Go toolchain is needed for the source
|
||||
// files; the test skips without one or in -short.
|
||||
func TestRISCVToolchainErrorParity(t *testing.T) {
|
||||
goroot := riscv64Goroot(t)
|
||||
dir := filepath.Join(goroot, "src", "cmd", "asm", "internal", "asm", "testdata")
|
||||
allowed := map[string]bool{}
|
||||
for _, s := range riscv64AcceptedErrorShapes {
|
||||
allowed[s] = true
|
||||
}
|
||||
for _, name := range riscv64ErrorCatalogues {
|
||||
data, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
for raw := range strings.SplitSeq(string(data), "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "TEXT") || !strings.Contains(line, "ERROR") {
|
||||
continue
|
||||
}
|
||||
body := line
|
||||
if i := strings.Index(body, "//"); i >= 0 {
|
||||
body = strings.TrimSpace(body[:i])
|
||||
}
|
||||
want := ""
|
||||
if m := regexp.MustCompile(`ERROR "([^"]*)"`).FindStringSubmatch(line); m != nil {
|
||||
want = m[1]
|
||||
}
|
||||
body = strings.ReplaceAll(body, "\t", " ")
|
||||
body = strings.Join(strings.Fields(body), " ")
|
||||
// A symbol reference needs the file's own declarations, which a
|
||||
// one-line probe cannot carry.
|
||||
if strings.Contains(body, "(SB)") {
|
||||
continue
|
||||
}
|
||||
src := "#include \"textflag.h\"\n\nTEXT ·f(SB), NOSPLIT, $0-0\n\t" + body + "\n\tRET\n"
|
||||
f, perr := parser.Parse("errorparity.s", src)
|
||||
if len(perr) > 0 {
|
||||
continue // the parser already rejects the spelling
|
||||
}
|
||||
_, aerr := AssembleFileRISCV(f)
|
||||
if aerr == nil {
|
||||
if !allowed[body] {
|
||||
t.Errorf("gasm accepts what the toolchain rejects: %s", body)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if want != "" && !riscv64DiagEquivalent(want, aerr.Error()) {
|
||||
t.Errorf("gasm rejects %s with an inequivalent diagnostic:\n toolchain: %s\n gasm: %s", body, want, aerr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// riscv64DiagEquivalent answers whether gasm's rejection carries the same
|
||||
// information as the toolchain's expected message. The two word the same
|
||||
// facts differently: the toolchain writes "immediate out of range 0 to 31"
|
||||
// where gasm may spell the field's own name, and gasm prefixes the mnemonic.
|
||||
// Canonicalisation drops the subjects and connectives and compares the
|
||||
// remaining token sequence.
|
||||
func riscv64DiagEquivalent(want, got string) bool {
|
||||
return riscv64DiagSubseq(riscv64DiagTokens(want), riscv64DiagTokens(got))
|
||||
}
|
||||
|
||||
// riscv64DiagTokens lower-cases a diagnostic, strips punctuation and the
|
||||
// connective tokens, and returns its words. "to" survives only between
|
||||
// digits, where the range wording uses it.
|
||||
func riscv64DiagTokens(msg string) []string {
|
||||
msg = strings.ToLower(msg)
|
||||
for _, r := range []string{"[", "]", ",", ".", ":", "\n", "(", ")"} {
|
||||
msg = strings.ReplaceAll(msg, r, " ")
|
||||
}
|
||||
fields := strings.Fields(msg)
|
||||
out := make([]string, 0, len(fields))
|
||||
for i, w := range fields {
|
||||
switch w {
|
||||
case "the", "a", "operand", "immediate", "an", "for":
|
||||
// subjects and articles carry no constraint
|
||||
case "to":
|
||||
if i > 0 && i+1 < len(fields) && riscv64IsNumeric(fields[i-1]) && riscv64IsNumeric(fields[i+1]) {
|
||||
continue // the range connective
|
||||
}
|
||||
out = append(out, w)
|
||||
default:
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// riscv64DiagSubseq answers whether want is a subsequence of got.
|
||||
func riscv64DiagSubseq(want, got []string) bool {
|
||||
i := 0
|
||||
for _, w := range got {
|
||||
if i < len(want) && w == want[i] {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return i == len(want)
|
||||
}
|
||||
|
||||
// riscv64IsNumeric reports whether s parses as a signed decimal number, the
|
||||
// token shape the range connective sits between.
|
||||
func riscv64IsNumeric(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
if s[0] == '-' || s[0] == '+' {
|
||||
s = s[1:]
|
||||
}
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// riscv64Goroot resolves the live toolchain root, the environment's own value
|
||||
// first, `go env GOROOT` as the fallback.
|
||||
func riscv64Goroot(t *testing.T) string {
|
||||
t.Helper()
|
||||
if goroot := os.Getenv("GOROOT"); goroot != "" {
|
||||
return goroot
|
||||
}
|
||||
out, err := exec.Command("go", "env", "GOROOT").Output()
|
||||
if err != nil {
|
||||
t.Skipf("no GOROOT: %v", err)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// riscv64RejectedNames pins the names the toolchain's riscv64 table carries
|
||||
// but refuses to encode: each is a "no encoding for instruction" under every
|
||||
// operand shape GOARCH=riscv64 go tool asm accepts. gasm rejects them too,
|
||||
// and the pair of rejections is the parity this catalogue asserts.
|
||||
var riscv64RejectedNames = []string{"DUFFCOPY", "DUFFZERO", "PCALIGNMAX"}
|
||||
|
||||
// TestRISCVBacklogNameParity walks the audit's known-but-unencodable
|
||||
// backlog: the names go tool asm recognises on riscv64 yet refuses to encode
|
||||
// must be refused by gasm as well.
|
||||
func TestRISCVBacklogNameParity(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("live toolchain backlog: skipped in -short mode")
|
||||
}
|
||||
for _, name := range riscv64RejectedNames {
|
||||
if riscv64ProbeAssembles(name + " X5") {
|
||||
t.Errorf("gasm encodes %s, which the toolchain refuses under every shape", name)
|
||||
}
|
||||
if riscv64ProbeAssembles(name) {
|
||||
t.Errorf("gasm encodes %s, which the toolchain refuses under every shape", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// riscv64ProbeAssembles answers whether a single statement wrapped in its
|
||||
// own function parses and assembles on riscv64.
|
||||
func riscv64ProbeAssembles(body string) bool {
|
||||
src := "#include \"textflag.h\"\n\nTEXT ·probe(SB), NOSPLIT, $0-0\n\t" + body + "\n\tRET\n"
|
||||
f, perr := parser.Parse("backlogparity.s", src)
|
||||
if len(perr) > 0 {
|
||||
return false // the parser already rejects the line
|
||||
}
|
||||
_, aerr := AssembleFileRISCV(f)
|
||||
return aerr == nil
|
||||
}
|
||||
|
||||
// riscv64CatalogueSelfCheck guards the catalogue walk itself: every ERROR
|
||||
// line in the two files must yield a probe body, so a formatting change in
|
||||
// the toolchain's files cannot silently empty the parity set.
|
||||
func TestRISCVErrorCatalogueSelfCheck(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("live toolchain catalogue: skipped in -short mode")
|
||||
}
|
||||
goroot := riscv64Goroot(t)
|
||||
dir := filepath.Join(goroot, "src", "cmd", "asm", "internal", "asm", "testdata")
|
||||
total := 0
|
||||
for _, name := range riscv64ErrorCatalogues {
|
||||
data, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
n := strings.Count(string(data), "ERROR")
|
||||
total += n
|
||||
if n == 0 {
|
||||
t.Errorf("%s carries no ERROR lines; the catalogue walk is empty", name)
|
||||
}
|
||||
}
|
||||
if total < 1000 {
|
||||
t.Errorf("catalogue carries %d ERROR lines, want at least 1000", total)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user