gelang 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/LICENSE +21 -0
  3. package/README.md +535 -0
  4. package/bin/ge.js +112 -0
  5. package/package.json +62 -0
  6. package/python/pyeffic/__init__.py +9 -0
  7. package/python/pyeffic/__main__.py +6 -0
  8. package/python/pyeffic/analyzer.py +464 -0
  9. package/python/pyeffic/apisurface.py +238 -0
  10. package/python/pyeffic/autoselect.py +327 -0
  11. package/python/pyeffic/backends.py +87 -0
  12. package/python/pyeffic/bench.py +233 -0
  13. package/python/pyeffic/cli.py +184 -0
  14. package/python/pyeffic/compiler.py +421 -0
  15. package/python/pyeffic/config.py +383 -0
  16. package/python/pyeffic/dartgen.py +441 -0
  17. package/python/pyeffic/deploy.py +586 -0
  18. package/python/pyeffic/diagnostics.py +194 -0
  19. package/python/pyeffic/difftest.py +424 -0
  20. package/python/pyeffic/downloader.py +307 -0
  21. package/python/pyeffic/emitters/__init__.py +11 -0
  22. package/python/pyeffic/emitters/base.py +2359 -0
  23. package/python/pyeffic/emitters/cpp.py +266 -0
  24. package/python/pyeffic/emitters/csharp.py +342 -0
  25. package/python/pyeffic/emitters/dart.py +349 -0
  26. package/python/pyeffic/emitters/go.py +388 -0
  27. package/python/pyeffic/emitters/kotlin.py +314 -0
  28. package/python/pyeffic/emitters/rust.py +314 -0
  29. package/python/pyeffic/emitters/zig.py +411 -0
  30. package/python/pyeffic/ffi.py +49 -0
  31. package/python/pyeffic/frontends/__init__.py +94 -0
  32. package/python/pyeffic/frontends/hybrid.py +709 -0
  33. package/python/pyeffic/frontends/typescript.py +965 -0
  34. package/python/pyeffic/ge_cli.py +1148 -0
  35. package/python/pyeffic/golden.py +348 -0
  36. package/python/pyeffic/idents.py +206 -0
  37. package/python/pyeffic/modules.py +220 -0
  38. package/python/pyeffic/packer.py +222 -0
  39. package/python/pyeffic/pipeline.py +797 -0
  40. package/python/pyeffic/reactgen.py +966 -0
  41. package/python/pyeffic/researcher.py +177 -0
  42. package/python/pyeffic/scaffold.py +397 -0
  43. package/python/pyeffic/stdlib.py +246 -0
  44. package/python/pyeffic/styling.py +220 -0
  45. package/python/pyeffic/templates/desktop_gui/README.md +106 -0
  46. package/python/pyeffic/templates/desktop_gui/app/__init__.py +0 -0
  47. package/python/pyeffic/templates/desktop_gui/app/core/__init__.py +0 -0
  48. package/python/pyeffic/templates/desktop_gui/app/core/add.ge.py +13 -0
  49. package/python/pyeffic/templates/desktop_gui/app/core/factorial.ge.py +20 -0
  50. package/python/pyeffic/templates/desktop_gui/app/core/fibonacci.ge.py +25 -0
  51. package/python/pyeffic/templates/desktop_gui/app/core/gcd.ge.py +19 -0
  52. package/python/pyeffic/templates/desktop_gui/app/core/is_prime.ge.py +24 -0
  53. package/python/pyeffic/templates/desktop_gui/app/core/multiply.ge.py +13 -0
  54. package/python/pyeffic/templates/desktop_gui/app/core/power.ge.py +25 -0
  55. package/python/pyeffic/templates/desktop_gui/app/main.ge.py +49 -0
  56. package/python/pyeffic/templates/desktop_gui/app/memory/__init__.py +0 -0
  57. package/python/pyeffic/templates/desktop_gui/app/memory/buffer.ge.py +26 -0
  58. package/python/pyeffic/templates/desktop_gui/app/memory/limits.ge.py +47 -0
  59. package/python/pyeffic/templates/desktop_gui/app/memory/state.ge.py +44 -0
  60. package/python/pyeffic/templates/desktop_gui/app/ui/__init__.py +0 -0
  61. package/python/pyeffic/templates/desktop_gui/app/ui/layout.ge.py +64 -0
  62. package/python/pyeffic/templates/desktop_gui/app/ui/render.ge.py +87 -0
  63. package/python/pyeffic/templates/desktop_gui/app/ui/theme.ge.py +147 -0
  64. package/python/pyeffic/templates/desktop_gui/app/ui/widgets.ge.py +105 -0
  65. package/python/pyeffic/templates/desktop_gui/desktop/__init__.py +1 -0
  66. package/python/pyeffic/templates/desktop_gui/desktop/main.ge.py +258 -0
  67. package/python/pyeffic/templates/desktop_gui/ge.toml +16 -0
  68. package/python/pyeffic/templates/desktop_gui/tests/__init__.py +0 -0
  69. package/python/pyeffic/templates/desktop_gui/tests/ge_loader.py +76 -0
  70. package/python/pyeffic/templates/desktop_gui/tests/test_app.py +173 -0
  71. package/python/pyeffic/templates/web_react/README.md +115 -0
  72. package/python/pyeffic/templates/web_react/app/__init__.py +0 -0
  73. package/python/pyeffic/templates/web_react/app/core/__init__.py +0 -0
  74. package/python/pyeffic/templates/web_react/app/core/add.ge.py +9 -0
  75. package/python/pyeffic/templates/web_react/app/core/factorial.ge.py +16 -0
  76. package/python/pyeffic/templates/web_react/app/core/fibonacci.ge.py +21 -0
  77. package/python/pyeffic/templates/web_react/app/core/is_prime.ge.py +20 -0
  78. package/python/pyeffic/templates/web_react/app/core/multiply.ge.py +9 -0
  79. package/python/pyeffic/templates/web_react/app/main.ge.py +25 -0
  80. package/python/pyeffic/templates/web_react/app/memory/__init__.py +0 -0
  81. package/python/pyeffic/templates/web_react/app/memory/buffer.ge.py +25 -0
  82. package/python/pyeffic/templates/web_react/app/memory/limits.ge.py +51 -0
  83. package/python/pyeffic/templates/web_react/ge.toml +23 -0
  84. package/python/pyeffic/templates/web_react/tests/__init__.py +0 -0
  85. package/python/pyeffic/templates/web_react/tests/ge_loader.py +68 -0
  86. package/python/pyeffic/templates/web_react/tests/test_app.py +105 -0
  87. package/python/pyeffic/templates/web_react/ui/main.ge.ui +33 -0
  88. package/python/pyeffic/templates/web_react/web/__init__.py +0 -0
  89. package/python/pyeffic/templates/web_react/web/server.ge.py +78 -0
  90. package/python/pyeffic/ts2py.py +657 -0
  91. package/python/pyeffic/typecheck.py +232 -0
  92. package/python/pyeffic/ui.py +154 -0
  93. package/python/pyeffic/ui_dsl.py +618 -0
  94. package/python/pyeffic/widgets.py +87 -0
  95. package/scripts/README.md +42 -0
  96. package/scripts/check-toolchains.py +85 -0
@@ -0,0 +1,194 @@
1
+ """Structured compiler diagnostics for GE.
2
+
3
+ Instead of raw Python tracebacks, the compiler produces user-friendly errors:
4
+
5
+ error[GE001]: unsupported feature 'try/except' at line 12
6
+ --> myrent.ge.py:12:5
7
+ |
8
+ 12 | try:
9
+ | ^^^ GE does not support exception handling yet.
10
+
11
+ Error codes:
12
+ GE001 unsupported feature
13
+ GE002 type mismatch
14
+ GE003 untyped parameter
15
+ GE004 parse error
16
+ GE005 compilation error (rustc/clang++)
17
+ GE006 FFI type error
18
+ GE007 missing function
19
+ GE008 missing entry point
20
+ GE009 TypeScript transpilation error
21
+ GE010 package error
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Optional
28
+
29
+
30
+ @dataclass
31
+ class Diagnostic:
32
+ code: str
33
+ message: str
34
+ file: str = ""
35
+ line: int = 0
36
+ column: int = 0
37
+ source_line: str = ""
38
+ severity: str = "error" # error, warning, info
39
+
40
+ def format(self) -> str:
41
+ """Format the diagnostic as a user-friendly error message."""
42
+ parts = []
43
+
44
+ # header: error[GE001]: message
45
+ header = f"{self.severity}[{self.code}]: {self.message}"
46
+ parts.append(header)
47
+
48
+ # location: --> file:line:column
49
+ if self.file and self.line > 0:
50
+ loc = f" --> {self.file}:{self.line}"
51
+ if self.column > 0:
52
+ loc += f":{self.column}"
53
+ parts.append(loc)
54
+
55
+ # source context with caret
56
+ if self.source_line:
57
+ line_num = str(self.line)
58
+ pad = " " * len(line_num)
59
+ parts.append(f" {pad} |")
60
+ parts.append(f" {line_num} | {self.source_line}")
61
+ if self.column > 0:
62
+ caret_pad = " " * (self.column - 1)
63
+ caret_len = max(1, len(self.message.split()[0]) if self.message else 1)
64
+ parts.append(f" {pad} | {caret_pad}{'^' * caret_len}")
65
+ parts.append(f" {pad} |")
66
+
67
+ return "\n".join(parts)
68
+
69
+
70
+ class ErrorReporter:
71
+ """Collects and reports compiler diagnostics."""
72
+
73
+ def __init__(self):
74
+ self.diagnostics: list[Diagnostic] = []
75
+
76
+ def error(self, code: str, message: str, file: str = "", line: int = 0,
77
+ column: int = 0, source_line: str = "") -> None:
78
+ self.diagnostics.append(Diagnostic(code, message, file, line, column,
79
+ source_line, "error"))
80
+
81
+ def warning(self, code: str, message: str, file: str = "", line: int = 0,
82
+ column: int = 0, source_line: str = "") -> None:
83
+ self.diagnostics.append(Diagnostic(code, message, file, line, column,
84
+ source_line, "warning"))
85
+
86
+ def has_errors(self) -> bool:
87
+ return any(d.severity == "error" for d in self.diagnostics)
88
+
89
+ def format_all(self) -> str:
90
+ if not self.diagnostics:
91
+ return ""
92
+ parts = [d.format() for d in self.diagnostics]
93
+ error_count = sum(1 for d in self.diagnostics if d.severity == "error")
94
+ warning_count = sum(1 for d in self.diagnostics if d.severity == "warning")
95
+ summary = f"\n{error_count} error(s), {warning_count} warning(s)"
96
+ return "\n\n".join(parts) + summary
97
+
98
+ def print(self) -> None:
99
+ formatted = self.format_all()
100
+ if formatted:
101
+ print(formatted)
102
+
103
+
104
+ def report_unsupported(file: str, line: int, feature: str, source_line: str = "") -> Diagnostic:
105
+ """Create a GE001 unsupported feature diagnostic."""
106
+ return Diagnostic(
107
+ code="GE001",
108
+ message=f"unsupported feature '{feature}' — GE does not support this yet",
109
+ file=file,
110
+ line=line,
111
+ source_line=source_line,
112
+ )
113
+
114
+
115
+ def report_type_mismatch(file: str, line: int, expected: str, got: str,
116
+ source_line: str = "") -> Diagnostic:
117
+ """Create a GE002 type mismatch diagnostic."""
118
+ return Diagnostic(
119
+ code="GE002",
120
+ message=f"type mismatch: expected '{expected}', got '{got}'",
121
+ file=file,
122
+ line=line,
123
+ source_line=source_line,
124
+ )
125
+
126
+
127
+ def report_untyped_param(file: str, line: int, param_name: str,
128
+ source_line: str = "") -> Diagnostic:
129
+ """Create a GE003 untyped parameter diagnostic."""
130
+ return Diagnostic(
131
+ code="GE003",
132
+ message=f"parameter '{param_name}' has no type annotation — GE requires typed parameters",
133
+ file=file,
134
+ line=line,
135
+ source_line=source_line,
136
+ )
137
+
138
+
139
+ def report_parse_error(file: str, line: int, message: str,
140
+ source_line: str = "") -> Diagnostic:
141
+ """Create a GE004 parse error diagnostic."""
142
+ return Diagnostic(
143
+ code="GE004",
144
+ message=message,
145
+ file=file,
146
+ line=line,
147
+ source_line=source_line,
148
+ )
149
+
150
+
151
+ def report_compile_error(backend: str, log: str) -> Diagnostic:
152
+ """Create a GE005 compilation error diagnostic from compiler output."""
153
+ # try to extract the first error line from the log
154
+ first_error = ""
155
+ error_line = 0
156
+ for line in log.splitlines():
157
+ if "error" in line.lower():
158
+ first_error = line.strip()
159
+ # try to extract line number from compiler output
160
+ # patterns: file:line:col, file(line,col), file:line, etc.
161
+ import re
162
+ m = re.search(r':(\d+)(?::\d+)?(?:\s*:|\s*\))', line)
163
+ if m:
164
+ try:
165
+ error_line = int(m.group(1))
166
+ except ValueError:
167
+ pass
168
+ break
169
+ msg = f"{backend} compilation failed"
170
+ if first_error:
171
+ msg += f": {first_error[:200]}"
172
+ return Diagnostic(
173
+ code="GE005",
174
+ message=msg,
175
+ line=error_line,
176
+ )
177
+
178
+
179
+ def report_missing_function(file: str, name: str) -> Diagnostic:
180
+ """Create a GE007 missing function diagnostic."""
181
+ return Diagnostic(
182
+ code="GE007",
183
+ message=f"function '{name}' is called but not defined or not exported",
184
+ file=file,
185
+ )
186
+
187
+
188
+ def report_missing_entry(file: str, entry: str) -> Diagnostic:
189
+ """Create a GE008 missing entry point diagnostic."""
190
+ return Diagnostic(
191
+ code="GE008",
192
+ message=f"entry point '{entry}' not found in source",
193
+ file=file,
194
+ )
@@ -0,0 +1,424 @@
1
+ """Differential testing: prove every backend agrees with Python.
2
+
3
+ GE emits many backends from one IR, so the same program can be run through
4
+ CPython and through every native binary and the outputs compared. That makes
5
+ cross-backend agreement a *checkable property* rather than a hope.
6
+
7
+ python -m pyeffic.difftest <file-or-dir> [--backends rust,cpp,...]
8
+
9
+ Lanes
10
+ -----
11
+ reference CPython executes the lowered program — the oracle
12
+ parity each backend's binary must match the oracle byte-for-byte
13
+ (stdout, and optionally exit code)
14
+
15
+ This is the technique `scriptc` uses with Node.js as its oracle, and the one
16
+ Rustlantis used to find 22 previously-unknown Rust compiler bugs.
17
+
18
+ Corpus conventions
19
+ ------------------
20
+ A case is a `.ge`, `.ge.py`, or `.ge.ts` file with a `main()` that prints.
21
+ Optional directives in the first lines:
22
+
23
+ // @expect: 42 expected stdout (skips the CPython run)
24
+ // @skip: cpp exclude a backend
25
+ // @only: rust run only these backends
26
+ // @exit: 1 expected exit code
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import io
31
+ import os
32
+ import subprocess
33
+ import sys
34
+ import tempfile
35
+ import traceback
36
+ from contextlib import redirect_stdout
37
+ from dataclasses import dataclass, field
38
+ from pathlib import Path
39
+
40
+ from .config import Config, detect_compilers
41
+
42
+ #: backends we can build and run, in a stable order
43
+ RUNNABLE_BACKENDS = ("rust", "cpp", "csharp", "zig", "go", "kotlin")
44
+
45
+ _EXE = ".exe" if sys.platform == "win32" else ""
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Results
50
+ # ---------------------------------------------------------------------------
51
+
52
+ @dataclass
53
+ class RunResult:
54
+ backend: str
55
+ ok: bool
56
+ stdout: str = ""
57
+ stderr: str = ""
58
+ exit_code: int = 0
59
+ note: str = ""
60
+
61
+ @property
62
+ def normalised(self) -> str:
63
+ """Stdout with trailing whitespace normalised for comparison."""
64
+ return "\n".join(line.rstrip() for line in self.stdout.splitlines()).strip()
65
+
66
+
67
+ @dataclass
68
+ class DiffReport:
69
+ source: Path
70
+ reference: RunResult | None = None
71
+ results: dict[str, RunResult] = field(default_factory=dict)
72
+ skipped: dict[str, str] = field(default_factory=dict)
73
+ error: str = ""
74
+
75
+ @property
76
+ def expected(self) -> str:
77
+ if self.reference and self.reference.ok:
78
+ return self.reference.normalised
79
+ return ""
80
+
81
+ @property
82
+ def mismatches(self) -> dict[str, str]:
83
+ """backend -> why it disagrees with the oracle."""
84
+ want = self.expected
85
+ out: dict[str, str] = {}
86
+ for backend, r in self.results.items():
87
+ if not r.ok:
88
+ out[backend] = r.note or "build or run failed"
89
+ elif r.normalised != want:
90
+ out[backend] = (f"stdout differs\n"
91
+ f" expected: {want!r}\n"
92
+ f" actual : {r.normalised!r}")
93
+ return out
94
+
95
+ @property
96
+ def passed(self) -> bool:
97
+ return not self.error and not self.mismatches
98
+
99
+ @property
100
+ def compared(self) -> int:
101
+ return len(self.results) - len(self.mismatches)
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Directives
106
+ # ---------------------------------------------------------------------------
107
+
108
+ def _directives(source: str) -> dict[str, str]:
109
+ """Parse leading `// @key: value` / `# @key: value` directives.
110
+
111
+ A repeated key accumulates, one value per line, so a program with
112
+ several output lines can list several `@expect` directives.
113
+ """
114
+ out: dict[str, str] = {}
115
+ for line in source.splitlines()[:24]:
116
+ s = line.strip()
117
+ for prefix in ("//", "#"):
118
+ if s.startswith(prefix):
119
+ body = s[len(prefix):].strip()
120
+ if body.startswith("@") and ":" in body:
121
+ key, _, val = body[1:].partition(":")
122
+ key = key.strip().lower()
123
+ if key in out:
124
+ out[key] = out[key] + "\n" + val.strip()
125
+ else:
126
+ out[key] = val.strip()
127
+ break
128
+ return out
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Reference lane: CPython
133
+ # ---------------------------------------------------------------------------
134
+
135
+ def _stub_preamble(backend, code): # noqa: ARG001
136
+ return None
137
+
138
+
139
+ def _stub_value(*args, **kwargs): # noqa: ARG001
140
+ return 0
141
+
142
+
143
+ def _stub_decorator(*args, **kwargs):
144
+ """`@rust` / `@cpp` used bare, or called with a function."""
145
+ if len(args) == 1 and callable(args[0]):
146
+ return args[0]
147
+
148
+ def _wrap(fn):
149
+ return fn
150
+ return _wrap
151
+
152
+
153
+ def run_reference(source_path: Path) -> RunResult:
154
+ """Execute the lowered program under CPython and capture stdout."""
155
+ from .frontends import frontend_for
156
+ from .modules import _lower_source
157
+
158
+ result = RunResult(backend="python", ok=False)
159
+ try:
160
+ raw = source_path.read_text(encoding="utf-8")
161
+ if frontend_for(source_path) != "python":
162
+ code = _lower_source(raw, source_path)
163
+ else:
164
+ code = raw
165
+ except Exception as exc:
166
+ result.note = f"lowering failed: {exc}"
167
+ return result
168
+
169
+ # Strip compiler-only imports and give the intrinsics no-op stubs so the
170
+ # program can run as ordinary Python.
171
+ lines = []
172
+ for line in code.splitlines():
173
+ s = line.strip()
174
+ if s.startswith("from pyeffic.") or s.startswith("import pyeffic"):
175
+ continue
176
+ if s.startswith("from __future__"):
177
+ continue
178
+ lines.append(line)
179
+ code = "\n".join(lines)
180
+
181
+ ns: dict = {
182
+ "__name__": "__main__",
183
+ "ge_preamble": _stub_preamble,
184
+ "ge_inline": _stub_value,
185
+ "ge_raw": _stub_value,
186
+ "gePreamble": _stub_preamble,
187
+ "geInline": _stub_value,
188
+ "geRaw": _stub_value,
189
+ "print": print,
190
+ }
191
+ for backend in RUNNABLE_BACKENDS:
192
+ ns[backend] = _stub_decorator
193
+
194
+ buf = io.StringIO()
195
+ try:
196
+ exec(compile(code, str(source_path), "exec"), ns) # noqa: S102
197
+ with redirect_stdout(buf):
198
+ main = ns.get("main")
199
+ if callable(main):
200
+ main()
201
+ else:
202
+ # module-level program: re-exec with stdout captured
203
+ exec(compile(code, str(source_path), "exec"), ns) # noqa: S102
204
+ result.stdout = buf.getvalue()
205
+ result.ok = True
206
+ except SystemExit as exc:
207
+ result.stdout = buf.getvalue()
208
+ result.exit_code = int(exc.code or 0)
209
+ result.ok = True
210
+ except Exception:
211
+ result.stdout = buf.getvalue()
212
+ result.note = "runtime error: " + traceback.format_exc(limit=1).strip().splitlines()[-1]
213
+ return result
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Parity lane: native backends
218
+ # ---------------------------------------------------------------------------
219
+
220
+ def available_backends(requested: tuple[str, ...] | None = None) -> list[str]:
221
+ """Backends whose toolchain is actually installed."""
222
+ info = detect_compilers()
223
+ have = {
224
+ "rust": bool(info.rustc),
225
+ "cpp": bool(info.cpp),
226
+ "csharp": bool(info.dotnet),
227
+ "zig": bool(info.zig),
228
+ "go": bool(info.go),
229
+ "kotlin": bool(info.kotlinc),
230
+ }
231
+ wanted = requested or RUNNABLE_BACKENDS
232
+ return [b for b in wanted if have.get(b)]
233
+
234
+
235
+ def _exe_for(report, backend: str) -> Path | None:
236
+ cr = {
237
+ "rust": report.rust_compile,
238
+ "cpp": report.cpp_compile,
239
+ "csharp": report.csharp_compile,
240
+ "zig": report.zig_compile,
241
+ "go": report.go_compile,
242
+ "kotlin": report.kotlin_compile,
243
+ }.get(backend)
244
+ if cr is None:
245
+ return None
246
+ return cr.exe if cr.ok else None
247
+
248
+
249
+ def run_backend(source_path: Path, backend: str, timeout: int = 60,
250
+ workdir: Path | None = None) -> RunResult:
251
+ """Build the program for one backend, run it, capture stdout."""
252
+ from .pipeline import build
253
+
254
+ result = RunResult(backend=backend, ok=False)
255
+ with tempfile.TemporaryDirectory() as td:
256
+ out = Path(workdir) if workdir else Path(td) / "build"
257
+ cfg = Config(out_dir=out, target="desktop", do_research=False,
258
+ force_backend=backend)
259
+ try:
260
+ report = build(source_path, cfg, entry="main")
261
+ except Exception as exc:
262
+ result.note = f"compiler error: {exc}"
263
+ return result
264
+
265
+ if report.errors.has_errors():
266
+ first = report.errors.diagnostics[0]
267
+ result.note = f"build failed: {getattr(first, 'message', first)}"
268
+ return result
269
+
270
+ exe = _exe_for(report, backend)
271
+ if exe is None or not Path(exe).exists():
272
+ result.note = "no executable produced"
273
+ return result
274
+
275
+ try:
276
+ proc = subprocess.run([str(exe)], capture_output=True, text=True,
277
+ timeout=timeout)
278
+ except subprocess.TimeoutExpired:
279
+ result.note = f"timed out after {timeout}s"
280
+ return result
281
+ result.stdout = proc.stdout
282
+ result.stderr = proc.stderr
283
+ result.exit_code = proc.returncode
284
+ result.ok = True
285
+ return result
286
+
287
+
288
+ # ---------------------------------------------------------------------------
289
+ # Driver
290
+ # ---------------------------------------------------------------------------
291
+
292
+ def diff_one(source_path: Path, backends: list[str] | None = None,
293
+ timeout: int = 60) -> DiffReport:
294
+ """Compare one program's output across CPython and every backend."""
295
+ report = DiffReport(source=source_path)
296
+ raw = source_path.read_text(encoding="utf-8")
297
+ directives = _directives(raw)
298
+
299
+ expected = directives.get("expect")
300
+ skip = {b.strip() for b in directives.get("skip", "").split(",") if b.strip()}
301
+ only = {b.strip() for b in directives.get("only", "").split(",") if b.strip()}
302
+
303
+ candidates = backends if backends is not None else available_backends()
304
+ if only:
305
+ candidates = [b for b in candidates if b in only]
306
+ candidates = [b for b in candidates if b not in skip]
307
+
308
+ if expected is not None:
309
+ report.reference = RunResult(backend="python", ok=True, stdout=expected)
310
+ else:
311
+ report.reference = run_reference(source_path)
312
+ if not report.reference.ok:
313
+ report.error = f"reference failed: {report.reference.note}"
314
+ return report
315
+
316
+ for backend in candidates:
317
+ report.results[backend] = run_backend(source_path, backend, timeout=timeout)
318
+
319
+ for backend in RUNNABLE_BACKENDS:
320
+ if backend not in report.results:
321
+ report.skipped[backend] = "not requested or toolchain missing"
322
+ return report
323
+
324
+
325
+ def find_cases(root: Path) -> list[Path]:
326
+ """Every runnable case under `root`."""
327
+ from .frontends import is_ge_source
328
+
329
+ if root.is_file():
330
+ return [root]
331
+ out: list[Path] = []
332
+ for p in sorted(root.rglob("*")):
333
+ if p.is_file() and is_ge_source(p):
334
+ out.append(p)
335
+ return out
336
+
337
+
338
+ def diff_corpus(root: Path, backends: list[str] | None = None,
339
+ timeout: int = 60, verbose: bool = False) -> list[DiffReport]:
340
+ """Run the differential lane over a file or directory."""
341
+ reports: list[DiffReport] = []
342
+ for case in find_cases(root):
343
+ rep = diff_one(case, backends=backends, timeout=timeout)
344
+ reports.append(rep)
345
+ if verbose:
346
+ mark = "ok " if rep.passed else "DIFF"
347
+ print(f" [{mark}] {case.name} "
348
+ f"({rep.compared}/{len(rep.results)} backends agree)")
349
+ for backend, why in rep.mismatches.items():
350
+ print(f" {backend}: {why}")
351
+ if rep.error:
352
+ print(f" {rep.error}")
353
+ return reports
354
+
355
+
356
+ # ---------------------------------------------------------------------------
357
+ # CLI
358
+ # ---------------------------------------------------------------------------
359
+
360
+ def main(argv: list[str] | None = None) -> int:
361
+ import argparse
362
+
363
+ p = argparse.ArgumentParser(
364
+ prog="python -m pyeffic.difftest",
365
+ description="Compare CPython and every native backend on the same program")
366
+ p.add_argument("path", help="a .ge file or a directory of them")
367
+ p.add_argument("--backends", default=None,
368
+ help="comma-separated subset (default: all installed)")
369
+ p.add_argument("--timeout", type=int, default=60,
370
+ help="per-program run timeout in seconds")
371
+ p.add_argument("-v", "--verbose", action="store_true")
372
+ args = p.parse_args(argv)
373
+
374
+ requested = None
375
+ if args.backends:
376
+ requested = tuple(b.strip() for b in args.backends.split(",") if b.strip())
377
+
378
+ root = Path(args.path)
379
+ if not root.exists():
380
+ print(f"error: {root} not found", file=sys.stderr)
381
+ return 2
382
+
383
+ backends = available_backends(requested)
384
+ print("GE differential test")
385
+ print(f" corpus : {root}")
386
+ print(f" oracle : CPython")
387
+ print(f" backends: {', '.join(backends) if backends else '(none installed)'}")
388
+ print()
389
+
390
+ if not backends:
391
+ print("no backend toolchains available — nothing to compare")
392
+ return 2
393
+
394
+ reports = diff_corpus(root, backends=backends, timeout=args.timeout,
395
+ verbose=args.verbose)
396
+ total = len(reports)
397
+ failed = [r for r in reports if not r.passed]
398
+ comparisons = sum(len(r.results) for r in reports)
399
+ agreements = sum(r.compared for r in reports)
400
+
401
+ print()
402
+ print(f" programs : {total}")
403
+ print(f" comparisons: {comparisons}")
404
+ print(f" agreements : {agreements}")
405
+ print(f" mismatches : {comparisons - agreements}")
406
+
407
+ if failed:
408
+ print()
409
+ print("FAILED:")
410
+ for r in failed:
411
+ print(f" {r.source}")
412
+ if r.error:
413
+ print(f" {r.error}")
414
+ for backend, why in r.mismatches.items():
415
+ print(f" {backend}: {why}")
416
+ return 1
417
+
418
+ print()
419
+ print("all backends agree with CPython")
420
+ return 0
421
+
422
+
423
+ if __name__ == "__main__":
424
+ sys.exit(main())