From 8fdc511d0d55422ed6334396da17d8b900fc2d0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Wed, 7 Oct 2026 12:49:46 +0200 Subject: [PATCH] test(asm): add the loong64 error-parity catalogue Assisted-by: GLM 5.3 --- asm/loong64_errorparity_test.go | 358 ++++++++++++++++++++++++++++++++ 1 file changed, 358 insertions(+) create mode 100644 asm/loong64_errorparity_test.go diff --git a/asm/loong64_errorparity_test.go b/asm/loong64_errorparity_test.go new file mode 100644 index 0000000..1494a35 --- /dev/null +++ b/asm/loong64_errorparity_test.go @@ -0,0 +1,358 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +package asm + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "testing" + + "sourcedock.dev/petrbalvin/gasm-sdk/parser" +) + +// loong64AcceptedErrorShapes lists the toolchain's loong64error.s spellings +// gasm still accepts, each an acceptance superset with a known shape. The +// list only shrinks: every tightening of the encoder moves spellings out of +// it, and a spelling reappearing here means a regression. +var loong64AcceptedErrorShapes = []string{} + +// TestLoong64ToolchainErrorParity walks the toolchain's loong64error.s (Go +// 1.27, loong64) 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 file; the test +// skips without one or in -short. +func TestLoong64ToolchainErrorParity(t *testing.T) { + goroot := loong64Goroot(t) + path := filepath.Join(goroot, "src", "cmd", "asm", "internal", "asm", "testdata", "loong64error.s") + data, err := os.ReadFile(path) + if err != nil { + t.Skip(err) + } + allowed := map[string]bool{} + for _, s := range loong64AcceptedErrorShapes { + allowed[s] = true + } + 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), " ") + // Label-relative and non-operand lines need their function context. + if strings.Contains(body, "(PC)") || strings.Contains(body, "(SB)") || strings.Contains(body, "PCALIGN") || + strings.HasPrefix(body, "RET") || strings.HasPrefix(body, "NOP") || strings.HasPrefix(body, "BRK") || + strings.Contains(body, "again") || strings.Contains(body, "next") || strings.Contains(body, "loop") { + 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 := AssembleFileLOONG64(f) + if aerr == nil { + if !allowed[body] { + t.Errorf("gasm accepts what the toolchain rejects: %s", body) + } + continue + } + if want != "" && !loong64DiagEquivalent(want, aerr.Error()) { + t.Errorf("gasm rejects %s with an inequivalent diagnostic:\n toolchain: %s\n gasm: %s", body, want, aerr) + } + } +} + +// loong64DiagEquivalent 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 "operand out of range 0 to 15" +// where gasm writes "immediate out of range [0, 15]", and both terminate +// sentences the toolchain punctuates. Canonicalisation drops the subject +// word and the connectives and compares the remaining token sequence. +func loong64DiagEquivalent(want, got string) bool { + return loong64DiagSubseq(loong64DiagTokens(want), loong64DiagTokens(got)) +} + +// loong64DiagTokens lower-cases a diagnostic, strips punctuation and the +// connective tokens, and returns its words. "to" survives only between +// digits, where the toolchain's range wording uses it; gasm's bracketed +// form never produces it. +func loong64DiagTokens(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": + // subjects and articles carry no constraint + case "to": + if i > 0 && i+1 < len(fields) && isDigits(fields[i-1]) && isDigits(fields[i+1]) { + continue // the range connective + } + out = append(out, w) + default: + out = append(out, w) + } + } + return out +} + +// loong64DiagSubseq answers whether want is a subsequence of got. +func loong64DiagSubseq(want, got []string) bool { + i := 0 + for _, w := range got { + if i < len(want) && w == want[i] { + i++ + } + } + return i == len(want) +} + +func isDigits(s string) bool { + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return len(s) > 0 +} + +// loong64ForeignErrorCorpus lists the corpus directories whose .s files the +// loong64 build attempts: the toolchain's own assembler testdata (the +// foreign-architecture files the corpus audit indicts on loong64) and the +// go/build malformed-source probes. +var loong64ForeignErrorCorpus = []string{ + filepath.Join("cmd", "asm", "internal", "asm", "testdata"), + filepath.Join("go", "build", "testdata", "bads"), +} + +// loong64ForeignAcceptedLines lists toolchain rejections gasm still +// accepts, keyed by corpus-relative path and line, each with its reason. +// The list only shrinks: every tightening moves lines out of it, and a line +// reappearing here means a regression. +var loong64ForeignAcceptedLines = map[string]string{ + // duperror.s rejections are whole-file semantics: the toolchain indicts + // the second declaration against the first in the same file, and a + // single TEXT or GLOBL line is legal on its own. gasm rejects the + // whole file with the same redeclaration diagnostic. + "cmd/asm/internal/asm/testdata/duperror.s:7": "symbol foo redeclared is a whole-file judgement", + "cmd/asm/internal/asm/testdata/duperror.s:11": "symbol bar redeclared is a whole-file judgement", + // The toolchain deletes the R22 spelling from the loong64 register map + // (its comment: avoid unintentionally clobbering g) and keeps g alone; + // gasm resolves R22 to register 22 as part of its deliberate ABI-alias + // superset (A0, SP, T0, ... share the table), so the three mips64.s + // spellings that name R22 assemble here. + "cmd/asm/internal/asm/testdata/mips64.s:135": "R22 spelling: gasm's deliberate ABI-alias superset", + "cmd/asm/internal/asm/testdata/mips64.s:138": "R22 spelling: gasm's deliberate ABI-alias superset", + "cmd/asm/internal/asm/testdata/mips64.s:218": "R22 spelling: gasm's deliberate ABI-alias superset", +} + +// TestLoong64ForeignErrorParity takes go tool asm's own per-line rejections +// on GOARCH=loong64 as the ground truth over the foreign corpus files and +// requires gasm to reject every one of those lines too, the documented +// acceptance supersets above excepted. The toolchain is run once per file; +// the test skips without a live toolchain or in -short. +func TestLoong64ForeignErrorParity(t *testing.T) { + goroot := loong64Goroot(t) + files := loong64CorpusFiles(t, goroot) + if len(files) == 0 { + t.Skip("no corpus files") + } + for _, path := range files { + if filepath.Base(path) == "loong64error.s" { + continue // the native catalogue above walks it deeply + } + rel := loong64CorpusRel(goroot, path) + rejected := loong64ToolchainRejects(t, goroot, path) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + lines := strings.Split(string(data), "\n") + for _, ln := range rejected { + if ln < 1 || ln > len(lines) { + t.Fatalf("%s: diagnostic names line %d beyond the file", rel, ln) + } + body := strings.TrimSpace(lines[ln-1]) + if body == "" || strings.HasPrefix(body, "//") { + continue + } + if i := strings.Index(body, "//"); i >= 0 { + body = strings.TrimSpace(body[:i]) + } + body = strings.Join(strings.Fields(strings.ReplaceAll(body, "\t", " ")), " ") + key := rel + ":" + strconv.Itoa(ln) + if _, ok := loong64ForeignAcceptedLines[key]; ok { + continue + } + if loong64ProbeAssembles(body) { + t.Errorf("gasm accepts what the toolchain rejects at %s: %s", key, body) + } + } + } +} + +// loong64RejectedNames pins the names the toolchain's loong64 table carries +// but refuses to encode: each is an "illegal combination" under every +// operand shape GOARCH=loong64 go tool asm accepts. gasm rejects them too, +// and the pair of rejections is the parity this catalogue asserts. +var loong64RejectedNames = []string{"RFE", "DUFFCOPY", "DUFFZERO", "PCALIGNMAX"} + +// TestLoong64BacklogNameParity walks the audit's known-but-unencodable +// backlog: the names go tool asm recognises on loong64 yet refuses to +// encode must be refused by gasm as well, and the one name the audit +// misses because its probe battery lacks the shape (SCQ, whose only form +// is the three-operand store-conditional) must encode to the toolchain's +// own bytes. +func TestLoong64BacklogNameParity(t *testing.T) { + if testing.Short() { + t.Skip("live toolchain backlog: skipped in -short mode") + } + for _, name := range loong64RejectedNames { + if loong64ProbeAssembles(name) { + t.Errorf("gasm encodes %s, which the toolchain refuses under every shape", name) + } + } + // sc.q: loong64enc1.s pins the encoding as c4145738 + // (SCQ R4, R5, (R6): sc.q rd, rk, (rj)). + src := "#include \"textflag.h\"\n\nTEXT ·f(SB), NOSPLIT, $0-0\n\tSCQ R4, R5, (R6)\n\tRET\n" + f, perr := parser.Parse("backlog.s", src) + if len(perr) > 0 { + t.Fatalf("parse SCQ: %v", perr[0]) + } + img, aerr := AssembleFileLOONG64(f) + if aerr != nil { + t.Fatalf("assemble SCQ: %v", aerr) + } + want := []byte{0xc4, 0x14, 0x57, 0x38, 0x20, 0x00, 0x00, 0x4c} + if !slices.Equal(img.Code, want) { + t.Errorf("SCQ encodes to % X, want % X (RET included)", img.Code, want) + } +} + +// loong64ProbeAssembles answers whether the single corpus line, wrapped in +// its own function, parses and assembles on loong64. +func loong64ProbeAssembles(body string) bool { + src := "#include \"textflag.h\"\n\n" + if strings.HasPrefix(body, "TEXT ") { + src += body + "\n\tRET\n" + } else if strings.HasPrefix(body, "DATA ") || strings.HasPrefix(body, "GLOBL ") { + src += body + "\n\nTEXT ·probe(SB), NOSPLIT, $0-0\n\tRET\n" + } else { + src += "TEXT ·probe(SB), NOSPLIT, $0-0\n\t" + body + "\n\tRET\n" + } + f, perr := parser.Parse("foreignparity.s", src) + if len(perr) > 0 { + return false // the parser already rejects the line + } + _, aerr := AssembleFileLOONG64(f) + return aerr == nil +} + +// loong64Goroot resolves the live toolchain root, the environment's own +// value first, `go env GOROOT` as the fallback. +func loong64Goroot(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)) +} + +// loong64CorpusFiles lists every .s file under the corpus roots, the +// testdata tree walked recursively. +func loong64CorpusFiles(t *testing.T, goroot string) []string { + t.Helper() + var files []string + for _, rel := range loong64ForeignErrorCorpus { + root := filepath.Join(goroot, "src", rel) + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && strings.HasSuffix(path, ".s") { + files = append(files, path) + } + return nil + }) + if err != nil { + t.Skipf("walk %s: %v", root, err) + } + } + slices.Sort(files) + return files +} + +// loong64CorpusRel renders a corpus file's path relative to GOROOT/src, the +// key the accepted-lines catalogue uses. +func loong64CorpusRel(goroot, path string) string { + rel, err := filepath.Rel(filepath.Join(goroot, "src"), path) + if err != nil { + return path + } + return rel +} + +var ( + // loong64ParseDiag matches the parse-stage "file:line: message". + loong64ParseDiag = regexp.MustCompile(`(?m)^(?:\S*asm: )?([^:\s]+\.s):(\d+):`) + // loong64EncodeDiag matches the encode-stage + // "asm: message: 003c (file.s:61)". + loong64EncodeDiag = regexp.MustCompile(`\(([^:\s()]+\.s):(\d+)\)`) +) + +// loong64ToolchainRejects runs go tool asm on the file for GOARCH=loong64 +// and returns the line numbers it rejects, both diagnostic shapes +// collected. An infrastructure failure fails the test: without the ground +// truth there is no parity to assert. +func loong64ToolchainRejects(t *testing.T, goroot, path string) []int { + t.Helper() + obj := filepath.Join(t.TempDir(), "probe.o") + cmd := exec.Command("go", "tool", "asm", "-I", filepath.Join(goroot, "pkg", "include"), "-o", obj, path) + cmd.Env = append(os.Environ(), "GOARCH=loong64") + out, err := cmd.CombinedOutput() + var lines []int + seen := map[int]bool{} + for _, m := range loong64ParseDiag.FindAllStringSubmatch(string(out), -1) { + if strings.HasSuffix(m[1], ".s") { + ln, perr := strconv.Atoi(m[2]) + if perr == nil && !seen[ln] { + seen[ln] = true + lines = append(lines, ln) + } + } + } + for _, m := range loong64EncodeDiag.FindAllStringSubmatch(string(out), -1) { + ln, perr := strconv.Atoi(m[2]) + if perr == nil && !seen[ln] { + seen[ln] = true + lines = append(lines, ln) + } + } + slices.Sort(lines) + if err != nil && len(lines) == 0 { + t.Fatalf("go tool asm %s: %v\n%s", filepath.Base(path), err, out) + } + return lines +}