diff --git a/content/en/conformance.md b/content/en/conformance.md index 9849b87..02427e4 100644 --- a/content/en/conformance.md +++ b/content/en/conformance.md @@ -145,9 +145,17 @@ margin could not distinguish *one file failing to load* from *hundreds of specs regressing*. A run that gains examples overall still fails here if any single file drops, which a total could not see. -The shim stubs a few MSpec matchers (`complain`, `output`, `ruby_exe`, …), so -their examples count as *skipped* rather than passing — the record is a -deliberately **conservative lower bound**. The +The shim stubs `ruby_exe`, `ruby_cmd` and `output_to_fd` — three helpers that +would need a subprocess or real file-descriptor redirection — so their examples +count as *skipped* rather than passing, and the record is a deliberately +**conservative lower bound**. + +`complain`, `output` and `be_computed_by` are **implemented**, not stubbed; this +page said otherwise until 2026-11-09, and the error ran in the direction that +matters. An example using a stubbed helper *skips*; one using `complain` **runs** +and, when the behaviour is missing, **fails** — so a reader trusting the old +sentence would have looked for it among the skips, where it is not. That list is +now derived from the shim by `tools/conformancefigure` rather than retyped. The [`rubyspec-ratchet`](https://github.com/go-embedded-ruby/ruby/blob/main/.github/workflows/rubyspec-ratchet.yml) workflow enforces it on every push and pull request. @@ -220,12 +228,13 @@ a checked fact carry an unchecked one. It is **not** "rbgo implements 93 % of Ruby", and it cannot be turned into a percentage of the language: -- **Only `language/` and `core/` are swept.** ruby/spec's `library/`, - `optional/` and `security/` trees are **not run at all** — most of what people - mean by "the standard library" is outside this measurement entirely. -- **The shim is not mspec.** Some matchers are stubbed, so the examples needing - them are counted as *skipped*, never as passing. The total is a conservative - lower bound. +- **Only `language/` and `core/` are swept.** `run.sh` walks those two trees and + no others, so ruby/spec's `library/`, `optional/`, `command_line/` and + `security/` trees are **not run at all** — most of what people mean by "the + standard library" is outside this measurement entirely. +- **The shim is not mspec.** Three helpers are stubbed — `ruby_exe`, `ruby_cmd` and `output_to_fd` — + so the examples needing them are counted as *skipped*, never as passing. The + total is a conservative lower bound. - **Skips are outside the ratio.** The 93.3 % divides passes by the examples that ran; the 477 skips and the 10 unreadable files are in neither column. - **Examples are not weighted.** A heavily specified method contributes far more diff --git a/tools/conformancefigure/main.go b/tools/conformancefigure/main.go index abdadcf..93678c3 100644 --- a/tools/conformancefigure/main.go +++ b/tools/conformancefigure/main.go @@ -33,6 +33,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strconv" "strings" "time" @@ -90,6 +91,24 @@ var ( // recognise when there is only one copy left and say so, not treat the // absence of the second copy as an unreadable answer. shimDerivedRe = regexp.MustCompile(`(?m)^CUR_VERSION\s*=\s*RUBY_VERSION\s*(?:#.*)?$`) + + // ⛔ THE THIRD FACT THESE PAGES RESTATE, and the one that was wrong on both + // of them. The shim stubs some mspec helpers by raising SpecSkip; those + // examples are counted as SKIPPED, which is why every page says the total is + // a conservative lower bound. Which helpers those are was retyped, and on + // 2026-11-09 conformance.md said "`complain`, `output`, `ruby_exe`, …" when + // `complain` and `output` are implemented and `ruby_cmd` -- stubbed one line + // below the `ruby_exe` that was named -- appeared nowhere. + // + // The error runs in the direction that hides work: an example using a + // genuinely stubbed helper SKIPS, but one using `complain` RUNS and, when the + // behaviour is missing, FAILS. A reader sent to look for it among the skips + // does not find it there (ruby#909 was nearly missed exactly so). + // + // `skip` is deliberately outside this set: it raises SkippedSpecError, which + // this pattern does not match, because the spec ASKED to be skipped -- mspec + // behaviour rbgo implements, not a hole in the shim. + shimStubRe = regexp.MustCompile(`(?m)^\s*def\s+([a-z_][a-z0-9_]*[?!]?)\b[^\n]*\braise[\s(]+SpecSkip\b`) ) // Two shapes of CURRENT claim, because two sites phrase it two ways round. @@ -230,6 +249,108 @@ var vocabularyCases = []struct { {`
for the landing page's layouts.
+ // `language/` and `core/` carry a slash and so are never identifiers.
+ codeSpanName = regexp.MustCompile("(?:`|)([a-z_][a-z0-9_]*[?!]?)(?:`|)")
+)
+
+// stubCases is this vocabulary's own control, in the same spirit as
+// vocabularyCases and versionCases, and run on every invocation for the same
+// reason: a pattern that matches nothing is indistinguishable from a site that
+// states the list correctly.
+var stubCases = []struct {
+ line string
+ want []string
+}{
+ // The docs' prose and its bullet, as this change writes them: the phrase and
+ // the whole enumeration on one line.
+ {"The shim stubs `ruby_exe`, `ruby_cmd` and `output_to_fd` — three helpers that",
+ []string{"ruby_exe", "ruby_cmd", "output_to_fd"}},
+ {"- **The shim is not mspec.** Three helpers are stubbed — `ruby_exe`, `ruby_cmd` and `output_to_fd` —",
+ []string{"ruby_exe", "ruby_cmd", "output_to_fd"}},
+ // The landing page's HTML, where a list item is one long line anyway.
+ {"477 more are skipped because the shim stubs ruby_exe, ruby_cmd and output_to_fd. ",
+ []string{"ruby_exe", "ruby_cmd", "output_to_fd"}},
+ // ⛔ The sentence that was WRONG, as it stood on 2026-11-09. It must be a
+ // claim, or the gate could not have caught it.
+ {"The shim stubs a few MSpec matchers (`complain`, `output`, `ruby_exe`, …), so",
+ []string{"complain", "output", "ruby_exe"}},
+ // The correction itself: a denial, not a claim.
+ {"`complain`, `output` and `be_computed_by` are **implemented**, not stubbed; this", nil},
+ // ⛔ THE SENTENCE THAT EXPLAINS THE CORRECTION, which names `complain` and is
+ // not a claim about what is stubbed. Matched on the word "stub" alone it was
+ // read as one, and this check failed conformance.md on its own explanation.
+ {"matters. An example using a stubbed helper *skips*; one using `complain` **runs**", nil},
+ // Vague but true, and naming nobody: nothing to check.
+ {"- **The shim is not mspec.** Some matchers are stubbed, so the examples needing", nil},
+ {"477 more are skipped because our shim stubs the matcher they need ", nil},
+ // ⛔ Real lines from this site that use "stub" about something else. The
+ // first names four gems and would fail a correct page.
+ {"| **graceful stub** | **succeeds** | the module loads, but constructing a connection / handle raises | `sqlite3`, `sequel`, `activerecord` |", nil},
+ {" - *RPC, serialization & columnar data* — **`grpc`** (server + client stub,", nil},
+ // A line about the suites, which names code spans and no stub.
+ {"- **Only `language/` and `core/` are swept.** ruby/spec's `library/`,", nil},
+}
+
+// matchStubs returns the helpers a line claims the shim stubs.
+func matchStubs(line string) []string {
+ if !stubClaim.MatchString(line) {
+ return nil
+ }
+ if stubNegation.MatchString(line) {
+ return nil
+ }
+ var out []string
+ for _, m := range codeSpanName.FindAllStringSubmatch(line, -1) {
+ out = append(out, m[1])
+ }
+ return out
+}
+
+// checkStubVocabulary runs stubCases, like checkVocabulary, before any verdict.
+func checkStubVocabulary() error {
+ for _, c := range stubCases {
+ got := matchStubs(c.line)
+ if len(got) != len(c.want) {
+ return &exitError{2, fmt.Sprintf("the stubbed-helper vocabulary is wrong: %q yielded %v, expected %v",
+ c.line, got, c.want)}
+ }
+ for i := range got {
+ if got[i] != c.want[i] {
+ return &exitError{2, fmt.Sprintf("the stubbed-helper vocabulary is wrong: %q yielded %v, expected %v",
+ c.line, got, c.want)}
+ }
+ }
+ }
+ return nil
+}
+
// match returns the figure a line claims as the CURRENT total, if any.
func match(line string) (string, bool) {
if datedLine.MatchString(line) {
@@ -403,6 +524,9 @@ func check(stdout, stderr io.Writer, root, baseline, runtimePath, shimPath strin
if err := checkVocabulary(); err != nil {
return err
}
+ if err := checkStubVocabulary(); err != nil {
+ return err
+ }
want, err := authoritative(baseline, timeout)
if err != nil {
@@ -416,6 +540,17 @@ func check(stdout, stderr io.Writer, root, baseline, runtimePath, shimPath strin
return &exitError{2, fmt.Sprintf("cannot read the version the gate measures at: %v", err)}
}
+ // ⛔ NOT a refusal here, and the ⛔ further down says why: a structural
+ // complaint must never arrive before a concrete disagreement. An unreadable
+ // stub list does not stop this tool reading a stale TOTAL, so the refusal is
+ // held until the findings have been printed.
+ wantStubs, stubErr := authoritativeStubs(shimPath, timeout)
+ isStub := map[string]bool{}
+ for _, s := range wantStubs {
+ isStub[s] = true
+ }
+ namedSomewhere := map[string]bool{}
+
type finding struct {
where string
got int
@@ -425,9 +560,14 @@ func check(stdout, stderr io.Writer, root, baseline, runtimePath, shimPath strin
where string
got string
}
+ type stubFinding struct {
+ where string
+ got string
+ }
var wrong []finding
var wrongVersion []versionFinding
- checked, versionsChecked := 0, 0
+ var wrongStub []stubFinding
+ checked, versionsChecked, stubsChecked := 0, 0, 0
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
@@ -476,6 +616,19 @@ func check(stdout, stderr io.Writer, root, baseline, runtimePath, shimPath strin
})
}
}
+ for _, name := range matchStubs(text) {
+ if stubErr != nil {
+ break // no oracle for this fact; the refusal waits its turn below
+ }
+ stubsChecked++
+ namedSomewhere[name] = true
+ if !isStub[name] {
+ wrongStub = append(wrongStub, stubFinding{
+ where: fmt.Sprintf("%s:%d", path, line),
+ got: name,
+ })
+ }
+ }
own := allVersions(text)
ownHad := len(own) > 0
versions := append(own, versionsAcross(prev, prevHadVersion, text, ownHad)...)
@@ -498,8 +651,9 @@ func check(stdout, stderr io.Writer, root, baseline, runtimePath, shimPath strin
// "N of M, never N alone": a walk that matched nothing reports no
// disagreement, which is the same output as a correct site.
- fmt.Fprintf(stdout, "%d total claim(s) and %d version claim(s) checked against the gate's %d at Ruby %s (%s)\n",
- checked, versionsChecked, want, wantVersion, howSpelled)
+ fmt.Fprintf(stdout, "%d total claim(s), %d version claim(s) and %d stubbed-helper mention(s) checked "+
+ "against the gate's %d at Ruby %s (%s); the shim stubs %s\n",
+ checked, versionsChecked, stubsChecked, want, wantVersion, howSpelled, strings.Join(wantStubs, ", "))
// ⛔ THE DISAGREEMENTS ARE REPORTED BEFORE THE ABSENCES, and that order is
// the whole of it. Written the other way round -- the version-absence
// refusal returning first -- this tool answered "found no statement of the
@@ -513,9 +667,18 @@ func check(stdout, stderr io.Writer, root, baseline, runtimePath, shimPath strin
for _, w := range wrongVersion {
fmt.Fprintf(stderr, "%s: says the gate measures at %s, it measures at %s\n", w.where, w.got, wantVersion)
}
- if n := len(wrong) + len(wrongVersion); n > 0 {
+ for _, w := range wrongStub {
+ fmt.Fprintf(stderr, "%s: calls `%s` a stubbed shim helper; the shim stubs %s and not it, "+
+ "so its examples RUN -- and when the behaviour is missing they FAIL rather than skip\n",
+ w.where, w.got, strings.Join(wantStubs, ", "))
+ }
+ if n := len(wrong) + len(wrongVersion) + len(wrongStub); n > 0 {
return &exitError{1, fmt.Sprintf("%d claim(s) disagree with the ruby repository", n)}
}
+ // Now the held refusal, after every concrete finding has been printed.
+ if stubErr != nil {
+ return &exitError{2, fmt.Sprintf("cannot read the helpers the shim stubs: %v", stubErr)}
+ }
if checked == 0 {
return &exitError{1, fmt.Sprintf("found no statement of the total in %s; "+
"either the vocabulary no longer matches how the docs phrase it, or the figure was dropped", root)}
@@ -528,6 +691,27 @@ func check(stdout, stderr io.Writer, root, baseline, runtimePath, shimPath strin
return &exitError{1, fmt.Sprintf("found no statement of the version the gate measures at in %s; "+
"a total without it is a count over an unnamed population (Ruby %s today)", root, wantVersion)}
}
+ // And the absence that matters most here: a page may name the stubbed
+ // helpers correctly and still be missing one, which is how `ruby_cmd` stayed
+ // unmentioned on both sites while `ruby_exe`, one line above it in the shim,
+ // was named everywhere. Naming a subset is the failure this cannot let pass.
+ //
+ // It applies only where the list is made at all: a tree that never names a
+ // stubbed helper has nothing to be incomplete about, and this tool is run
+ // over two trees of very different depth. That leaves one hole -- deleting
+ // the enumeration outright -- and it is closed by a test that runs this over
+ // the real content/en and requires the mentions to be there.
+ var unnamed []string
+ for _, s := range wantStubs {
+ if !namedSomewhere[s] {
+ unnamed = append(unnamed, s)
+ }
+ }
+ if stubsChecked > 0 && len(unnamed) > 0 {
+ return &exitError{1, fmt.Sprintf("the shim stubs %s, and %s names %s nowhere; "+
+ "every page that explains why the total is a lower bound owes the reader the whole list",
+ strings.Join(wantStubs, ", "), root, strings.Join(unnamed, ", "))}
+ }
return nil
}
@@ -675,6 +859,32 @@ func authoritativeVersion(runtimePath, shimPath string, timeout time.Duration) (
return runtime, "two copies, agreeing", nil
}
+// authoritativeStubs reports the mspec helpers the shim stubs, read out of
+// spec_helper.rb itself.
+//
+// It fetches the shim a second time rather than sharing authoritativeVersion's
+// copy on purpose: each of the two facts then refuses on its own terms, and a
+// version check that cannot read the file does not also decide the stub list.
+func authoritativeStubs(shimPath string, timeout time.Duration) ([]string, error) {
+ sh, err := fetch(shimPath, shimURL, timeout)
+ if err != nil {
+ return nil, fmt.Errorf("the shim's stubbed helpers: %w", err)
+ }
+ var out []string
+ for _, m := range shimStubRe.FindAllSubmatch(sh, -1) {
+ out = append(out, string(m[1]))
+ }
+ if len(out) == 0 {
+ // Not "nothing is stubbed": every page says the total is a lower bound
+ // BECAUSE something is, so an empty answer here means the pattern stopped
+ // matching the shim, and judging the pages against an empty set would
+ // turn every correct enumeration into a finding.
+ return nil, fmt.Errorf("no `def … raise SpecSkip` in %s; the pattern no longer matches the shim", shimURL)
+ }
+ sort.Strings(out)
+ return out, nil
+}
+
// hasAnySuffix is spelled out so the file types this walks are a list in one
// place rather than a chain of && that grows a bug each time it is extended.
func hasAnySuffix(path string, suffixes ...string) bool {
diff --git a/tools/conformancefigure/main_test.go b/tools/conformancefigure/main_test.go
index 7ae7a94..90ff46d 100644
--- a/tools/conformancefigure/main_test.go
+++ b/tools/conformancefigure/main_test.go
@@ -3,8 +3,10 @@ package main
import (
"bytes"
"errors"
+ "io/fs"
"os"
"path/filepath"
+ "strings"
"testing"
"time"
)
@@ -71,12 +73,30 @@ func versionFixtures(t *testing.T, runtimeV, shimV string) (string, string) {
t.Fatal(err)
}
sp := filepath.Join(dir, "spec_helper.rb")
- if err := os.WriteFile(sp, []byte("CUR_VERSION = \""+shimV+"\"\nPLATFORMS = [:darwin]\n"), 0o644); err != nil {
+ if err := os.WriteFile(sp, []byte("CUR_VERSION = \""+shimV+"\"\nPLATFORMS = [:darwin]\n"+shimStubs), 0o644); err != nil {
t.Fatal(err)
}
return rp, sp
}
+// shimStubs is the part of spec_helper.rb the stubbed-helper check reads, in the
+// exact shapes the real file uses: a bare `raise`, a `::Kernel.raise`, and --
+// the one that must NOT be read as a stub -- `skip`, which raises a SpecSkip
+// SUBCLASS because the spec asked to be skipped.
+//
+// The implemented matchers are here too, with bodies, so a pattern that started
+// matching any `def` in the file would be caught by a fixture rather than on
+// the live site.
+const shimStubs = `
+def output_to_fd(*a); raise SpecSkip, "output_to_fd matcher unsupported"; end
+def be_computed_by(sym, *extra); BeComputedByMatcher.new(sym, *extra); end
+def complain(pat = nil, options = {}); ComplainMatcher.new(pat, options[:verbose]); end
+class SkippedSpecError < SpecSkip; end
+def skip(reason = 'no reason'); ::Kernel.raise(SkippedSpecError, reason); end
+def ruby_exe(*a, **k); ::Kernel.raise(SpecSkip, "ruby_exe subprocess unsupported"); end
+def ruby_cmd(*a, **k); ::Kernel.raise(SpecSkip, "ruby_cmd unsupported"); end
+`
+
// ⛔ An unreadable baseline is a refusal (2), never a pass: it finds no
// disagreement either, and that silence reads exactly like agreement.
func TestAnUnreadableBaselineRefuses(t *testing.T) {
@@ -336,7 +356,7 @@ func TestADerivedShimVersionIsNotADisagreement(t *testing.T) {
// With a trailing comment, because that is how the line will actually be
// written: the change wants explaining at the site.
if err := os.WriteFile(sp,
- []byte("CUR_VERSION = RUBY_VERSION # one fact, one place (#799)\n"), 0o644); err != nil {
+ []byte("CUR_VERSION = RUBY_VERSION # one fact, one place (#799)\n"+shimStubs), 0o644); err != nil {
t.Fatal(err)
}
@@ -461,3 +481,122 @@ func TestATomlFileIsWalked(t *testing.T) {
t.Errorf("it said %q, want it to name the stale figure", said)
}
}
+
+// ⛔ The three cases the stubbed-helper check exists for, each one a state this
+// site was actually in on 2026-11-09.
+func TestTheStubbedHelperListIsJudged(t *testing.T) {
+ dir := t.TempDir()
+ base := filepath.Join(dir, "BASELINE")
+ if err := os.WriteFile(base, []byte("OK\ta_spec.rb\t23511\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ rp, sp := versionFixtures(t, "4.0.9", "4.0.9")
+
+ // Every page carries a correct total and version, so nothing but the stub
+ // list can decide these cases.
+ const preamble = "It passes **23,511**, and those guards resolve at Ruby 4.0.9.\n"
+
+ for _, tc := range []struct {
+ name string
+ page string
+ status int
+ says string
+ }{{
+ name: "the list as this site carried it, naming two implemented matchers",
+ page: preamble + "The shim stubs a few MSpec matchers (`complain`, `output`, `ruby_exe`, …), so\n" +
+ "their examples count as skipped.\n",
+ status: 1,
+ says: "calls `complain` a stubbed shim helper",
+ }, {
+ name: "a list missing one, which is how `ruby_cmd` went unmentioned everywhere",
+ page: preamble + "The shim stubs two MSpec helpers — `ruby_exe` and `output_to_fd` — so their\n" +
+ "examples count as skipped.\n",
+ status: 1,
+ says: "names ruby_cmd nowhere",
+ }, {
+ name: "the whole list, and a sentence DENYING that the others are stubbed",
+ page: preamble + "The shim stubs `ruby_exe`, `ruby_cmd` and `output_to_fd`, so their examples\n" +
+ "skip. `complain`, `output` and `be_computed_by` are **implemented**, not\n" +
+ "stubbed; an example using a stubbed helper skips, one using `complain` runs.\n",
+ status: 0,
+ }} {
+ t.Run(tc.name, func(t *testing.T) {
+ root := t.TempDir()
+ if err := os.WriteFile(filepath.Join(root, "page.md"), []byte(tc.page), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ var out, errb bytes.Buffer
+ cmd := RootCmd()
+ cmd.SetArgs([]string{"--root", root, "--baseline", base,
+ "--runtime", rp, "--shim", sp, "--timeout", "5s"})
+ cmd.SetOut(&out)
+ cmd.SetErr(&errb)
+ err := cmd.Execute()
+ if tc.status == 0 {
+ if err != nil {
+ t.Fatalf("Execute: %v\nstdout: %s\nstderr: %s", err, out.String(), errb.String())
+ }
+ return
+ }
+ var ee *exitError
+ if !errors.As(err, &ee) {
+ t.Fatalf("got %v, want an exitError", err)
+ }
+ if ee.code != tc.status {
+ t.Errorf("status %d, want %d", ee.code, tc.status)
+ }
+ said := ee.Error() + errb.String()
+ if !strings.Contains(said, tc.says) {
+ t.Errorf("it said %q, want it to name %q", said, tc.says)
+ }
+ })
+ }
+}
+
+// ⛔ THE HOLE THE WALK ITSELF CANNOT CLOSE. The completeness check only applies
+// where the list is made at all, so deleting the enumeration outright would
+// leave the tool with nothing to complete and a clean exit. This reads the real
+// content/en and requires it to name every helper the shim stubs -- and only
+// those -- independently of any figure on the page, so a figure refresh never
+// makes this test noisy.
+func TestTheSiteNamesEveryStubbedHelper(t *testing.T) {
+ want := map[string]bool{}
+ for _, m := range shimStubRe.FindAllStringSubmatch(shimStubs, -1) {
+ want[m[1]] = false
+ }
+ if len(want) != 3 {
+ t.Fatalf("the fixture shim yielded %d stubbed helper(s), want 3: the pattern and the fixture have drifted apart", len(want))
+ }
+
+ mentions := 0
+ err := filepath.WalkDir("../../content/en", func(path string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() || !hasAnySuffix(path, ".md", ".html", ".toml") {
+ return err
+ }
+ body, rerr := os.ReadFile(path)
+ if rerr != nil {
+ return rerr
+ }
+ for i, line := range strings.Split(string(body), "\n") {
+ for _, name := range matchStubs(line) {
+ mentions++
+ if _, ok := want[name]; !ok {
+ t.Errorf("%s:%d calls `%s` a stubbed shim helper; the shim stubs it nowhere", path, i+1, name)
+ continue
+ }
+ want[name] = true
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("walking content/en: %v", err)
+ }
+ for name, seen := range want {
+ if !seen {
+ t.Errorf("content/en names `%s` nowhere, and the shim stubs it: the page explains that the\n"+
+ "total is a lower bound without saying which examples it is low by", name)
+ }
+ }
+ t.Logf("%d stubbed-helper mention(s) across content/en", mentions)
+}