diff --git a/src/ferrolang_vm/test_cli.py b/src/ferrolang_vm/test_cli.py index 075a525..d82b2ee 100644 --- a/src/ferrolang_vm/test_cli.py +++ b/src/ferrolang_vm/test_cli.py @@ -62,7 +62,18 @@ def main() -> int: # shell is sitting in -- an editable install plus a git worktree is enough # to silently build and test the wrong tree. Say which tree this is. print(f"ferro-test: building {ROOT}", file=sys.stderr) - test_file = os.fspath(ROOT / "tools" / "tests" / "test_milestones_dosboxx.py") + tests = ROOT / "tools" / "tests" + # The host gates run first and take under a second. A missing declaration + # or an 8.3-illegal name would otherwise be found only after a DOSBox-X + # boot and a full compiler build, and the DOS-side message for either is + # unhelpful. They do not replace the DOS run; they precede it. + gates = [os.fspath(tests / name) for name in + ("test_host_syntax.py", "test_dos_names.py")] + if pytest.main([*gates, "-q", "--no-header"]) != 0: + print("ferro-test: host gates failed; not starting DOSBox-X", + file=sys.stderr) + return 1 + test_file = os.fspath(tests / "test_milestones_dosboxx.py") pytest_args = [test_file, "--tb=short", "-v" if args.verbose else "-q"] if args.select: pytest_args.extend(["-k", args.select]) diff --git a/tools/tests/test_host_syntax.py b/tools/tests/test_host_syntax.py new file mode 100644 index 0000000..56408e5 --- /dev/null +++ b/tools/tests/test_host_syntax.py @@ -0,0 +1,105 @@ +"""Compile the compiler's own sources on the host, as a syntax gate. + +This is not verification. AGENTS.md is explicit that a host compiler's result +does not count, and it still does not: the DOS build and the milestone suite +decide whether anything works. What this buys is the turnaround. A missing +declaration or a signature that disagrees with its definition used to surface +only after a DOSBox-X boot and a full compiler build; here it surfaces in about +a second, with the line number. + +It uses the same Open Watcom the project targets, just the Windows-hosted build, +and the same strictness as ``fec/build-dos.bat`` (``-za -wx -wcd=202``). The +target differs -- wcc386 is 32-bit where the DOS build is 16-bit large model -- +so this catches syntax, types and declarations, not code generation or memory +model problems. + +Skipped when Watcom is not installed, so the suite still runs anywhere. +""" +from __future__ import annotations + +import os +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +from ferrolang_vm.paths import ROOT + +SRC = ROOT / "fec" / "src" +# Mirrors the compile order in fec/build-dos.bat. +SOURCES = ("arena", "diag", "lexer", "ast", "parser", "types", "m7", "own", + "check", "lower", "emit_c", "driver") + + +def _watcom() -> Path | None: + root = os.environ.get("WATCOM") + candidates = [Path(root)] if root else [] + candidates.append(Path("C:/WATCOM19")) + for base in candidates: + if (base / "binnt" / "wcc386.exe").is_file(): + return base + return None + + +@pytest.fixture(scope="session") +def watcom() -> Path: + base = _watcom() + if base is None: + pytest.skip("Open Watcom is not installed on the host; set WATCOM to enable") + return base + + +@pytest.fixture(scope="session") +def objdir(tmp_path_factory: pytest.TempPathFactory) -> Path: + return tmp_path_factory.mktemp("wcc") + + +@pytest.mark.parametrize("name", SOURCES) +def test_source_compiles_clean(name: str, watcom: Path, objdir: Path) -> None: + source = SRC / f"{name}.c" + if not source.is_file(): + pytest.fail(f"{source} is missing but build-dos.bat compiles it") + env = dict(os.environ) + env["WATCOM"] = os.fspath(watcom) + env["INCLUDE"] = os.fspath(watcom / "h") + completed = subprocess.run( + [os.fspath(watcom / "binnt" / "wcc386.exe"), "-q", "-za", "-wx", + "-wcd=202", "-zq", f"-i={SRC}", os.fspath(source)], + cwd=objdir, capture_output=True, text=True, env=env, timeout=120, + ) + output = (completed.stdout + completed.stderr).strip() + # -wx keeps warnings meaningful, so treat any diagnostic as a failure: the + # DOS build runs the same flags and stops on them. + assert completed.returncode == 0 and not output, ( + f"{name}.c does not compile clean\n{output}" + ) + + +def test_build_scripts_agree_on_sources() -> None: + """The two build files and this test must name the same translation units. + + They drifted apart while the M7 wrapper existed, which is how a source could + stop being compiled without anyone noticing. + """ + batch = (ROOT / "fec" / "build-dos.bat").read_text(encoding="utf-8", errors="replace") + makefile = (ROOT / "fec" / "Makefile").read_text(encoding="utf-8", errors="replace") + in_batch = set(re.findall(r"src\\(\w+)\.c", batch)) + srcline = next(line for line in makefile.splitlines() if line.startswith("SRC =")) + in_make = set(re.findall(r"src/(\w+)\.c", srcline)) + assert in_batch == set(SOURCES), f"build-dos.bat compiles {sorted(in_batch)}" + assert in_make == set(SOURCES), f"Makefile compiles {sorted(in_make)}" + + +def test_no_source_is_orphaned() -> None: + """Every .c under fec/src must be compiled by something. + + check_m7.c and emitcm7.c hid check.c and emit_c.c from the build by + including them textually; nothing flagged that they had stopped being + translation units of their own. + """ + on_disk = {p.stem for p in SRC.glob("*.c")} + assert on_disk == set(SOURCES), ( + f"fec/src has {sorted(on_disk - set(SOURCES))} that no build step compiles" + )