test(asm): pin the riscv64 error parity against the toolchain catalogues

Assisted-by: GLM 5.3 Flash
This commit is contained in:
petrbalvin committed 2026-10-07 19:40:32 +02:00
1 parent f0942ff7f3
commit d18195f581
1 file changed
+225
+225
View File
@@ -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)
}
}