From 503cb206fd4f9b2d3dbbd004bc87247379c0265e Mon Sep 17 00:00:00 2001
From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com>
Date: Tue, 7 Jul 2026 00:42:59 +0100
Subject: [PATCH] =?UTF-8?q?test(corpus):=20whole-repo=20.eph=20ratchet=20g?=
 =?UTF-8?q?ate=20=E2=80=94=20only=2013/119=20files=20compile;=20enforce=20?=
 =?UTF-8?q?truth=20both=20ways?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Ground truth 2026-07-07: of 119 .eph files in the repo, only 13 compile
through the real pipeline ('ephapax compile'). conformance/valid/ fails
0/9 (!), stdlib 7/9, examples ~all — the corpus is v1-era or speculative
syntax (braces-form fn bodies, &T borrows, ADT type declarations) that
predates the v2 grammar, and nothing gated it, so it rotted silently.

src/ephapax-cli/tests/eph_corpus_gate.rs (runs in cargo test / rust-ci):
- eph_corpus_ratchet: every file in tests/eph-corpus-compiles.txt must
  compile (regression gate) AND every compiling repo .eph must be listed
  (ratchet: newly-supported files get flipped deliberately, in the
  enabling PR). Known-debt count printed every run — no silent caps.
- invalid_corpus_stays_invalid: conformance/invalid/*.eph must FAIL.

Both tests verified green locally (cargo test -p ephapax-cli --test
eph_corpus_gate, exit 0).

The 106-file debt is the v1->v2 corpus-migration backlog; much of it
awaits grammar phases that do not exist yet, so migration is tracked as
the ratchet direction, not attempted wholesale here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
 src/ephapax-cli/tests/eph_corpus_gate.rs | 160 +++++++++++++++++++++++
 tests/eph-corpus-compiles.txt            |  32 +++++
 2 files changed, 192 insertions(+)
 create mode 100644 src/ephapax-cli/tests/eph_corpus_gate.rs
 create mode 100644 tests/eph-corpus-compiles.txt

diff --git a/src/ephapax-cli/tests/eph_corpus_gate.rs b/src/ephapax-cli/tests/eph_corpus_gate.rs
new file mode 100644
index 00000000..f5d9c428
--- /dev/null
+++ b/src/ephapax-cli/tests/eph_corpus_gate.rs
@@ -0,0 +1,160 @@
+// SPDX-License-Identifier: MPL-2.0
+// Owner: Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
+//
+// eph_corpus_gate.rs — the whole-repo .eph corpus ratchet gate.
+//
+// Ground truth 2026-07-07: of the 119 .eph files in the repo, only 13
+// compile through the real pipeline (`ephapax compile`). The rest are
+// v1-era or speculative syntax (braces-form fn bodies, `&T` borrows,
+// ADT `type X = A | B` declarations) awaiting v2 grammar phases. Nothing
+// gated any of this, so the corpus rotted silently.
+//
+// This gate enforces the current truth in BOTH directions:
+//   - every file in tests/eph-corpus-compiles.txt must compile
+//     (regression gate), and
+//   - every repo .eph that compiles must be in the list
+//     (ratchet: a newly-supported file must be flipped deliberately,
+//     in the same PR as the feature that enabled it).
+//
+// It also enforces the negative corpus: conformance/invalid/*.eph must
+// NOT compile.
+//
+// The known-debt count is printed on every run — no silent caps.
+
+use std::collections::BTreeSet;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+fn repo_root() -> PathBuf {
+    Path::new(env!("CARGO_MANIFEST_DIR"))
+        .join("../..")
+        .canonicalize()
+        .expect("repo root")
+}
+
+fn ephapax_bin() -> String {
+    env!("CARGO_BIN_EXE_ephapax").to_string()
+}
+
+/// All .eph files under the repo, repo-root-relative with '/' separators,
+/// excluding build/VCS/agent-scratch directories.
+fn corpus(root: &Path) -> BTreeSet<String> {
+    fn walk(dir: &Path, root: &Path, out: &mut BTreeSet<String>) {
+        let Ok(entries) = std::fs::read_dir(dir) else {
+            return;
+        };
+        for entry in entries.flatten() {
+            let path = entry.path();
+            let name = entry.file_name();
+            let name = name.to_string_lossy();
+            if path.is_dir() {
+                if matches!(
+                    name.as_ref(),
+                    "target" | ".git" | ".claude" | "node_modules" | ".zig-cache-global"
+                ) {
+                    continue;
+                }
+                walk(&path, root, out);
+            } else if name.ends_with(".eph") {
+                let rel = path
+                    .strip_prefix(root)
+                    .expect("under root")
+                    .to_string_lossy()
+                    .replace('\\', "/");
+                out.insert(rel);
+            }
+        }
+    }
+    let mut out = BTreeSet::new();
+    walk(root, root, &mut out);
+    out
+}
+
+fn compiles(root: &Path, rel: &str, out_dir: &Path) -> bool {
+    let out = out_dir.join("gate.wasm");
+    Command::new(ephapax_bin())
+        .current_dir(root)
+        .args([
+            "compile",
+            rel,
+            "-o",
+            out.to_str().expect("utf8 tmp path"),
+        ])
+        .output()
+        .map(|o| o.status.success())
+        .unwrap_or(false)
+}
+
+fn allowlist(root: &Path) -> BTreeSet<String> {
+    let text = std::fs::read_to_string(root.join("tests/eph-corpus-compiles.txt"))
+        .expect("read tests/eph-corpus-compiles.txt");
+    text.lines()
+        .map(str::trim)
+        .filter(|l| !l.is_empty() && !l.starts_with('#'))
+        .map(str::to_string)
+        .collect()
+}
+
+#[test]
+fn eph_corpus_ratchet() {
+    let root = repo_root();
+    let tmp = tempfile::tempdir().expect("tempdir");
+    let expected = allowlist(&root);
+    let all = corpus(&root);
+
+    for listed in &expected {
+        assert!(
+            all.contains(listed),
+            "allowlist entry does not exist on disk: {listed}"
+        );
+    }
+
+    let mut passing = BTreeSet::new();
+    for rel in &all {
+        if compiles(&root, rel, tmp.path()) {
+            passing.insert(rel.clone());
+        }
+    }
+
+    let regressions: Vec<_> = expected.difference(&passing).collect();
+    let unexpected: Vec<_> = passing.difference(&expected).collect();
+    let debt = all.len() - passing.len();
+    println!(
+        "eph corpus: {} files, {} compile, {} known debt (v1->v2 migration backlog)",
+        all.len(),
+        passing.len(),
+        debt
+    );
+
+    assert!(
+        regressions.is_empty(),
+        "REGRESSION — allowlisted files no longer compile: {regressions:?}"
+    );
+    assert!(
+        unexpected.is_empty(),
+        "RATCHET — newly compiling files must be added to \
+         tests/eph-corpus-compiles.txt in the enabling PR: {unexpected:?}"
+    );
+}
+
+#[test]
+fn invalid_corpus_stays_invalid() {
+    let root = repo_root();
+    let tmp = tempfile::tempdir().expect("tempdir");
+    let invalid: Vec<_> = corpus(&root)
+        .into_iter()
+        .filter(|p| p.starts_with("conformance/invalid/"))
+        .collect();
+    assert!(
+        !invalid.is_empty(),
+        "conformance/invalid corpus missing — gate misconfigured"
+    );
+    let wrongly_accepted: Vec<_> = invalid
+        .iter()
+        .filter(|rel| compiles(&root, rel, tmp.path()))
+        .collect();
+    assert!(
+        wrongly_accepted.is_empty(),
+        "negative-corpus files were ACCEPTED by the compiler: {wrongly_accepted:?}"
+    );
+}
diff --git a/tests/eph-corpus-compiles.txt b/tests/eph-corpus-compiles.txt
new file mode 100644
index 00000000..7abc2717
--- /dev/null
+++ b/tests/eph-corpus-compiles.txt
@@ -0,0 +1,32 @@
+# SPDX-License-Identifier: MPL-2.0
+# Owner: Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
+#
+# eph-corpus-compiles.txt — the RATCHET allowlist for the .eph corpus gate
+# (src/ephapax-cli/tests/eph_corpus_gate.rs).
+#
+# Every path here MUST compile (surface-parse → desugar → typecheck →
+# wasm) — a regression on any of them fails CI. Every .eph file in the
+# repo that compiles MUST be listed here — an unexpected pass also fails
+# CI, so the list can only be updated deliberately. The gate prints the
+# known-debt count (files that do not yet compile) on every run; that
+# debt is the v1→v2 corpus-migration backlog (much of it is speculative
+# syntax for features that do not exist yet: braces-form fn bodies,
+# borrows, ADT type declarations).
+#
+# Ratchet direction: when a grammar/codegen phase lands, flip the newly
+# passing files IN THE SAME PR.
+#
+# Paths are repo-root-relative, one per line, '#' comments allowed.
+examples/pattern_matching.eph
+stdlib/Argv.eph
+tests/v2-grammar/fixtures/extern-abstract-types.eph
+tests/v2-grammar/fixtures/extern-callsite.eph
+tests/v2-grammar/fixtures/hypatia-port/bridge.eph
+tests/v2-grammar/fixtures/hypatia-port/hypatia_gui.eph
+tests/v2-grammar/fixtures/implicit-in.eph
+tests/v2-grammar/fixtures/implicit-in-tuple.eph
+tests/v2-grammar/fixtures/let-pair-explicit-in.eph
+tests/v2-grammar/fixtures/multi-module/app.eph
+tests/v2-grammar/fixtures/multi-module/lib/math.eph
+tests/v2-grammar/fixtures/qualified-import/app.eph
+tests/v2-grammar/fixtures/qualified-import/lib.eph
