From 01c5374ca53f64d4b0e45d8d6e6350439710305a Mon Sep 17 00:00:00 2001 From: ellainng <299958882+ellainng@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:09:53 -0700 Subject: [PATCH 1/2] fix(ERR-01): preserve raise and await source origins --- IMPLEMENTATION_PLAN.md | 6 +++ docs/compiler-emitter.md | 7 +++ scripts/compiler_browser_smoke.py | 18 ++++++- src/JavaScript/_compiler_runtime.py | 23 ++++++--- src/JavaScript/emitter.py | 8 ++- tests/test_compiler_errors.py | 77 +++++++++++++++++++++++++++++ 6 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 tests/test_compiler_errors.py diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 9be7779..ab1da57 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -463,3 +463,9 @@ ownership, native GET text Fetch, source errors and stop/restart. A runnable text_metrics example and real-browser tests cover local/offline-after-load computation and transport/resource failures. WEB-03 owns verified PWA assembly and offline reload; QA-03 extends cross-target parity. No package was released. + +[ERR-01](https://github.com/outside-labs/Python-JavaScript/issues/34) corrects +preconstructed exception origins before QA-03: explicit raises and first source +awaits replace constructor spans, while propagated runtime/callback errors retain +their origin. CPython/native/browser cases cover the correction and mapped +tracebacks. QA-03 now depends on this diagnostic fix as well as BROWSER-01. diff --git a/docs/compiler-emitter.md b/docs/compiler-emitter.md index a2a995d..c0427b2 100644 --- a/docs/compiler-emitter.md +++ b/docs/compiler-emitter.md @@ -89,3 +89,10 @@ precision/Unicode/tuple/default behavior and an independent Node source-map consumer. Local modules, async browser capabilities and the final browser parity gate remain MOD-01, ASYNC-01/BROWSER-01 and QA-03. This compiler executes only ahead-of-time approved source; it does not parse new source in the browser. + +Exception construction does not start a traceback. An explicit raise records +its statement span; rejecting a Promise with an unraised exception records the +first source await. Errors already raised in a function/callback keep that +origin when propagated through awaits. Exception values retain identity. Native +stack capture, where available, maps explicit raises to their statement; full +Python call-frame construction remains separately staged. diff --git a/scripts/compiler_browser_smoke.py b/scripts/compiler_browser_smoke.py index 2c244fa..a0acb7f 100644 --- a/scripts/compiler_browser_smoke.py +++ b/scripts/compiler_browser_smoke.py @@ -19,7 +19,8 @@ ROOT = Path(__file__).resolve().parents[1] -APP = '''from JavaScript.browser import read_text, write_text, on_click, unsubscribe, fetch_text +APP = '''from JavaScript import Promise +from JavaScript.browser import read_text, write_text, on_click, unsubscribe, fetch_text from text_metrics import metrics async def run(text): current = await read_text("input") @@ -32,6 +33,13 @@ async def load(text): async def clear(text): await write_text("result", "") async def fail(text): + if text == "raise": + error = TypeError("explicit value") + raise error + if text == "reject": + error = TypeError("rejected value") + pending = Promise.reject(error) + await pending return 1 // 0 async def busy(text): await write_text("status", "busy") @@ -204,8 +212,14 @@ def smoke(channel=None): page.evaluate('window.start()');page.wait_for_function('window.controller.stats().subscriptions===5') page.click('#fail');page.wait_for_function('window.failures.length===1') error = page.evaluate('window.failures[0]') - assert error['type'] == 'ZeroDivisionError' and error['location']['line'] == 14, error + assert error['type'] == 'ZeroDivisionError' and error['location']['line'] == next(i for i,line in enumerate(APP.splitlines(),1) if line.strip()=='return 1 // 0'), error checks.append('python-error-source-location') + for value, statement in [('raise','raise error'),('reject','await pending')]: + page.evaluate('window.start()');page.wait_for_function('window.controller.stats().subscriptions===5') + page.fill('#input',value);page.click('#fail');page.wait_for_function('window.failures.length===1') + error=page.evaluate('window.failures[0]') + assert error['type']=='TypeError' and error['location']['line']==next(i for i,line in enumerate(APP.splitlines(),1) if line.strip()==statement),error + checks.append('preconstructed-raise-and-rejection-origins') sessions = [] for _ in range(3): page.evaluate('window.start()');page.wait_for_function('window.controller.stats().subscriptions===5') diff --git a/src/JavaScript/_compiler_runtime.py b/src/JavaScript/_compiler_runtime.py index 8aff583..9ec12cf 100644 --- a/src/JavaScript/_compiler_runtime.py +++ b/src/JavaScript/_compiler_runtime.py @@ -9,7 +9,18 @@ class $PyError extends Error { constructor(name, message, location) { super(message); this.name = name; this.location = location; } } -function $fail(name, message, location) { throw new $PyError(name, message, location); } +const $raisedErrors = new WeakSet(); +function $markRaised(error, location) { + if (!$raisedErrors.has(error)) { error.location = {...location}; $raisedErrors.add(error); } + return error; +} +function $raise(error, location) { + if (!(error instanceof $PyError)) $fail('TypeError', 'raise requires an adopted exception.', location); + error.location = {...location}; $raisedErrors.add(error); + if (typeof Error.captureStackTrace === 'function') Error.captureStackTrace(error, $raise); + throw error; +} +function $fail(name, message, location) { $raise(new $PyError(name, message, location), location); } '''), 'binding': (('error',), r''' const $UNBOUND = Symbol('unbound'); @@ -226,7 +237,7 @@ class $JSArray { constructor(values) { this.values = values; } } const error = new $PyError('AbortError', 'Compiled operation cancelled.', location); error.code = 'CANCELLED'; return error; } -function $checkpoint(location) { if (signal?.aborted) throw $cancelled(location); } +function $checkpoint(location) { if (signal?.aborted) $raise($cancelled(location), location); } class $Coroutine { constructor(start) { this.start = start; this.started = false; } } @@ -244,7 +255,7 @@ class $PromiseValue { return new Promise((resolve, reject) => { let finished = false; const cleanup = () => signal?.removeEventListener('abort', abort); - const abort = () => { if (!finished) { finished = true; cleanup(); reject($cancelled(location)); } }; + const abort = () => { if (!finished) { finished = true; cleanup(); reject($markRaised($cancelled(location), location)); } }; signal?.addEventListener('abort', abort, {once: true}); if (signal?.aborted) { abort(); return; } promise.then(value => { @@ -252,7 +263,7 @@ class $PromiseValue { try { $checkpoint(location); resolve(value); } catch (error) { reject(error); } }, error => { if (finished) return; finished = true; cleanup(); - reject(error instanceof $PyError ? error : new $PyError('RuntimeError', 'Unhandled Promise rejection.', location)); + reject($markRaised(error instanceof $PyError ? error : new $PyError('RuntimeError', 'Unhandled Promise rejection.', location), location)); }); }); } @@ -349,7 +360,7 @@ class $HostHandle { constructor(wire) { this.wire=Object.freeze({...wire}); Obje unsubscribe: $fn('unsubscribe',1,[],(args,where)=>{ const handle=args[0]; if (!(handle instanceof $HostHandle) || !$subscriptions.has(handle)) { - const error=new $PyError('HostError','Subscription handle is stale.',where);error.code='STALE_HANDLE';throw error; + const error=new $PyError('HostError','Subscription handle is stale.',where);error.code='STALE_HANDLE';$raise(error,where); } return $request('events.unsubscribe',{subscription:handle.wire},where,value=>{ host.releaseCallback($subscriptions.get(handle));$subscriptions.delete(handle);return $hostDecode(value,where); @@ -404,7 +415,7 @@ class $HostHandle { constructor(wire) { this.wire=Object.freeze({...wire}); Obje HELPER_BINDINGS = { - 'error': ('$PyError', '$fail'), 'binding': ('$UNBOUND', '$get'), + 'error': ('$PyError', '$fail', '$raise'), 'binding': ('$UNBOUND', '$get'), 'str': ('$str',), 'tuple': ('$tuple',), 'numeric': ('$number',), 'truth': ('$truth',), 'binary': ('$binary',), 'unary': ('$unary',), 'equal': ('$equal', '$order'), 'compare': ('$compare',), 'index': ('$index',), diff --git a/src/JavaScript/emitter.py b/src/JavaScript/emitter.py index 7820698..241b2fe 100644 --- a/src/JavaScript/emitter.py +++ b/src/JavaScript/emitter.py @@ -194,8 +194,12 @@ def statement(self, node, indent): self.add(f'{self.names[target]}={temporary};', indent, node) elif kind in {'expression', 'return', 'raise'}: value = self.expression(fields['value']) if fields['value'] else 'null' - prefix = {'expression': '', 'return': 'return ', 'raise': 'throw '}[kind] - self.add(prefix + value + ';', indent, node) + if kind == 'raise': + self.use('error') + self.add(f'$raise({value},{self.location(node)});', indent, node) + else: + prefix = 'return ' if kind == 'return' else '' + self.add(prefix + value + ';', indent, node) elif kind == 'if': self.use('truth') self.add(f'if($truth({self.expression(fields["test"])},{self.location(node)})){{', indent, node) diff --git a/tests/test_compiler_errors.py b/tests/test_compiler_errors.py new file mode 100644 index 0000000..425c7bf --- /dev/null +++ b/tests/test_compiler_errors.py @@ -0,0 +1,77 @@ +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +from JavaScript.emitter import compile_source +from JavaScript.modules import SourceModule, compile_modules +from test_compiler_async import run_async +from test_compiler_emitter import run_node + + +class CompilerErrorOriginTests(unittest.TestCase): + def assert_origin(self, source, *, asynchronous=False): + oracle = '''import contextlib,io,json,traceback +source = ''' + repr(source) + ''' +namespace={};output=io.StringIO() +try: + with contextlib.redirect_stdout(output): + exec(compile(source,'origin.py','exec'),namespace) +''' + if asynchronous: + oracle += " import asyncio\n asyncio.run(namespace['main']())\n" + oracle += '''except Exception as error: + frame=[frame for frame in traceback.extract_tb(error.__traceback__) if frame.filename=='origin.py'][-1] + print(json.dumps({'stdout':output.getvalue(),'name':type(error).__name__,'message':str(error),'line':frame.lineno})) +''' + expected = json.loads(subprocess.check_output([sys.executable, '-I', '-X', 'utf8', '-c', oracle], text=True, encoding='utf-8', timeout=10)) + artifact = compile_source(source, filename='origin.py', async_enabled=asynchronous) + actual = run_async(artifact) if asynchronous else run_node(artifact) + self.assertEqual(actual['stdout'], expected['stdout']) + self.assertEqual(actual['error']['name'], expected['name']) + self.assertEqual(actual['error']['message'], expected['message']) + self.assertEqual(actual['error']['location']['filename'], 'origin.py') + self.assertEqual(actual['error']['location']['line'], expected['line']) + return actual + + def test_preconstructed_exception_reports_raise_instead_of_constructor(self): + actual = self.assert_origin('def selected():\n error=TypeError("bad")\n print(error is error)\n raise error\nselected()') + self.assertEqual(actual['error']['location']['line'], 4) + self.assertEqual(actual['error']['location']['column'], 5) + + def test_promise_value_origin_and_prior_callback_runtime_origins(self): + cases = [ + 'from JavaScript import Promise\nasync def main():\n error=TypeError("rejected")\n pending=Promise.reject(error)\n await pending', + 'from JavaScript import Promise\ndef callback(value):\n print("once")\n error=TypeError("callback")\n raise error\nasync def main():\n await Promise.resolve(1).then(callback)', + 'async def inner():\n return 1//0\nasync def main():\n return await inner()', + 'from JavaScript import Promise\ndef identity(value):\n return value\nasync def main():\n error=TypeError("same object")\n observed=await Promise.reject(error).then(None,identity)\n print(error is observed)\n raise error', + ] + for source in cases: + with self.subTest(source=source): + self.assert_origin(source, asynchronous=True) + + def test_shared_runtime_retains_callback_module_origin(self): + sources = (SourceModule.from_text('app', 'from JavaScript import Promise\nfrom helper import callback\nasync def main():\n await Promise.resolve(1).then(callback)'), + SourceModule.from_text('helper', 'def callback(value):\n error=TypeError("module")\n raise error')) + result = run_async(compile_modules(sources, entrypoint='app', async_enabled=True)) + self.assertEqual(result['error']['location']['filename'], 'helper.py') + self.assertEqual(result['error']['location']['line'], 3) + + def test_native_traceback_maps_to_the_raise_statement(self): + source = 'def selected():\n error=TypeError("bad")\n raise error\nselected()' + artifact = compile_source(source, filename='origin.py') + runner = '''import {createModule} from './app.mjs';import {SourceMap} from 'node:module';import {readFile} from 'node:fs/promises'; +const map=new SourceMap(JSON.parse(await readFile(new URL('./app.mjs.map',import.meta.url)))); +try{createModule()}catch(error){const match=error.stack.match(/app\\.mjs:(\\d+):(\\d+)/);console.log(JSON.stringify(map.findEntry(Number(match[1])-1,Number(match[2])-1)));} +''' + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for item in artifact.files: + (root / item.path).write_bytes(item.data) + (root / 'run.mjs').write_text(runner) + result = json.loads(subprocess.check_output(['node', str(root / 'run.mjs')], text=True, timeout=10)) + self.assertEqual(result['originalSource'], 'origin.py') + self.assertEqual(result['originalLine'], 2) + self.assertEqual(result['originalColumn'], 4) From ab153e5da4152afd0bc808fcc44f4812ccc693b9 Mon Sep 17 00:00:00 2001 From: ellainng <299958882+ellainng@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:22:25 -0700 Subject: [PATCH 2/2] test(QA-03): verify shared compiler targets against CPython --- .github/workflows/ci.yaml | 7 +- IMPLEMENTATION_PLAN.md | 14 +- README.md | 2 + docs/compiler-conformance.md | 64 +++++ docs/compiler-emitter.md | 5 +- examples/browser_text_metrics.py | 15 +- pyproject.toml | 4 + scripts/compiler_browser_smoke.py | 4 +- scripts/compiler_conformance.mjs | 32 +++ scripts/compiler_conformance.py | 268 ++++++++++++++++++++ tests/fixtures/compiler_parity.json | 166 ++++++++++++ tests/fixtures/licenses/SHIMIFY-LICENSE.txt | 21 ++ tests/test_compiler_conformance.py | 29 +++ 13 files changed, 623 insertions(+), 8 deletions(-) create mode 100644 docs/compiler-conformance.md create mode 100644 scripts/compiler_conformance.mjs create mode 100644 scripts/compiler_conformance.py create mode 100644 tests/fixtures/compiler_parity.json create mode 100644 tests/fixtures/licenses/SHIMIFY-LICENSE.txt create mode 100644 tests/test_compiler_conformance.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 68dcbee..345a05a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -23,7 +23,7 @@ jobs: persist-credentials: false - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: '3.14' + python-version: '3.14.7' - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: '24.19.0' @@ -60,10 +60,13 @@ jobs: - run: uv python install - run: uv sync --locked --group browser-tests - run: uv run --no-sync python -m playwright install --with-deps chromium + - run: uv run --no-sync python scripts/compiler_conformance.py --browser --report build/compiler-parity-report.json - run: uv run --no-sync python scripts/compiler_browser_smoke.py --report build/compiler-browser-report.json - uses: actions/upload-artifact@v5 if: always() with: name: compiler-browser-report - path: build/compiler-browser-report.json + path: | + build/compiler-browser-report.json + build/compiler-parity-report.json if-no-files-found: error diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index ab1da57..c0e5548 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -357,7 +357,7 @@ meet their first milestones. | [js/MOD-01](https://github.com/outside-labs/Python-JavaScript/issues/23) | Compile bounded local Python module imports | [js/EMIT-01](https://github.com/outside-labs/Python-JavaScript/issues/13) | Blocked | | [js/ASYNC-01](https://github.com/outside-labs/Python-JavaScript/issues/10) | Define Promise scheduling and browser async semantics | [js/PARSE-01](https://github.com/outside-labs/Python-JavaScript/issues/11) | Blocked | | [js/BROWSER-01](https://github.com/outside-labs/Python-JavaScript/issues/24) | Implement browser DOM, events and Fetch adapters | [js/MOD-01](https://github.com/outside-labs/Python-JavaScript/issues/23), [js/ASYNC-01](https://github.com/outside-labs/Python-JavaScript/issues/10), [pocket/HOST-01](https://github.com/outside-labs/pocket-python/issues/17) | Blocked | -| [js/QA-03](https://github.com/outside-labs/Python-JavaScript/issues/25) | Verify CPython, Node and browser execution parity | [js/BROWSER-01](https://github.com/outside-labs/Python-JavaScript/issues/24) | Blocked | +| [js/QA-03](https://github.com/outside-labs/Python-JavaScript/issues/25) | Verify CPython, Node and browser execution parity | [js/BROWSER-01](https://github.com/outside-labs/Python-JavaScript/issues/24), [js/ERR-01](https://github.com/outside-labs/Python-JavaScript/issues/34) | See issue / Project | | [js/PERF-01](https://github.com/outside-labs/Python-JavaScript/issues/26) | Measure compiled application and helper footprints | [js/QA-03](https://github.com/outside-labs/Python-JavaScript/issues/25) | Blocked | PARSE-01 is the first Ready compiler task. PARSE-02/EMIT-01 follow in order; @@ -469,3 +469,15 @@ preconstructed exception origins before QA-03: explicit raises and first source awaits replace constructor spans, while propagated runtime/callback errors retain their origin. CPython/native/browser cases cover the correction and mapped tracebacks. QA-03 now depends on this diagnostic fix as well as BROWSER-01. + + +QA-03 implements compiler-parity/1: one approved corpus executes under isolated +CPython and as identical emitted artifacts in Node and a real browser worker. +Reports retain typed values, output order, exception origins, source-map frames, +native JavaScript observations, explicit rejected/deferred diagnostics and exact +corpus/artifact identities. The browser host suite separately verifies callback +release, cancellation and worker stop/restart. Borrowed text-metrics cases retain +Shimify's exact MIT notice, including the runnable browser example. The versioned +[compatibility matrix](docs/compiler-conformance.md) records the adopted scope +and remaining differences; full Python tracebacks and arbitrary dynamic Python +remain outside this compiler profile. No release was published. diff --git a/README.md b/README.md index a6b74cb..788ed1b 100644 --- a/README.md +++ b/README.md @@ -117,3 +117,5 @@ Build the local [text_metrics browser example](docs/compiler-browser.md#run-the- with `uv run python examples/browser_text_metrics.py --output build/browser-text-metrics`. It uses our emitted modules and helpers in a worker; source parsing remains the separate independent-interpreter milestone. + +The development compiler has a versioned [CPython/Node/browser parity suite](docs/compiler-conformance.md) with typed results, source-map error checks and explicit unsupported boundaries. diff --git a/docs/compiler-conformance.md b/docs/compiler-conformance.md new file mode 100644 index 0000000..10b4fd9 --- /dev/null +++ b/docs/compiler-conformance.md @@ -0,0 +1,64 @@ +# Compiler compatibility matrix: compiler-parity/1 + +The development compiler runs the same approved source corpus under CPython +3.14.7 and as identical emitted ES modules in Node 24.19.0 and a Chromium module +worker. This is a bounded ahead-of-time Python profile. The independent source +interpreter is a separate pocket-python implementation. Published 0.1.0 predates +this compiler. + +| Area | Adopted behavior and evidence | Boundary | +| --- | --- | --- | +| Values | Typed None, bool, arbitrary-size int, code-point str and immutable tuple returns | Mutable containers, floats and arbitrary host values are deferred | +| Evaluation | Output order, short-circuiting, default tuple identity, imported function aliases and source initialization order match CPython | Integer/string interning identity is not adopted; indirect local callable invocation is rejected | +| Unicode/numbers | Integers beyond 2^53, signed floor/modulo, combining marks, supplementary characters and explicit surrogate pairs | Native JS strings count UTF-16 units; Python strings count code points | +| Exceptions | Error class, adopted message, original filename/line/code-point column and output before failure match the reference | Argument-count wording and recursion resource limits are profile-specific | +| Source maps | Actual Node and browser error stacks map back to original explicit raise, division, indexing and module callback source lines | These are mapped JS frames, not complete Python traceback objects; source-map columns identify emitted statements | +| Async | Lazy/single-use coroutines, Promise callback side effects, rejected values and previously raised error origins | Microtask scheduling can differ from CPython; async generators/iteration and Promise.all are not adopted | +| Browser host | Real-worker shared computation, DOM callbacks/release, Fetch grants/limits, cancellation and stop/restart are checked separately by compiler_browser_smoke.py | Host errors carry original spans; workers provide responsiveness isolation, not a general security boundary | +| Imports | Explicit source allowlist and shared module/runtime identity; module callback origins preserved | Dynamic imports, undeclared sources and arbitrary installed-package execution are rejected | +| Diagnostics | Every rejected/deferred corpus entry must fail with its declared compiler diagnostic | A failed supported case fails the suite; no Python execution fallback in either target | + +The schema-1 corpus consists of compiler_contract.json plus compiler_parity.json. +The initial matrix has 24 supported cases and 10 explicit boundary cases. Its +report also records three native observations in both JavaScript targets: +Array.of length, Promise callback ordering and UTF-16 string length. These are +native API observations, not CPython-language assertions. Compiler Promise cases +compare the adopted Python wrapper with emitted modules separately. + +The reference runs only trusted test fixtures in fresh isolated Python processes. +The browser fixture server serves static assets; the browser worker performs the +computation. Both targets import the same artifact bytes. Tagged comparison +preserves integer precision, Unicode surrogate distinctions and tuple structure. +Output and original error spans are compared independently. Source-map checks +feed actual target stack frames to Node's SourceMap consumer. An unraised +exception rejected through a Promise reports its first source await; its native +JS stack can retain the construction site. Only explicitly raised errors in the +traceback subset require the first mapped frame to identify the error origin. + +Run the complete suite from a development checkout with the declared browser +test group and its Chromium installation: + +```sh +uv sync --locked --group browser-tests +uv run --no-sync python -m playwright install chromium +uv run --no-sync python scripts/compiler_conformance.py --browser --report build/compiler-parity-report.json +uv run --no-sync python scripts/compiler_browser_smoke.py --report build/compiler-browser-report.json +``` + +Omit --browser for the Node/reference gate; --channel chrome selects an installed +Chrome for local verification. CI pins the reference and Node versions, uses the +locked Playwright package and retains both reports with the actual browser +version. The ordinary installed-wheel/sdist suite runs the Node/reference gate +on Linux, macOS and Windows. Reports contain every compared result, original and +mapped error locations, profile classifications, corpus SHA-256 and artifact +manifest identities. A corpus change updates its digest; incompatible report +schema/comparison semantics require a new suite version. A report describes its +tested versions and selected cases, not universal CPython compatibility. + +The shared reduced text-metrics computation originates in Shimify's fixture +package. The attribution entry in compiler_parity.json pins the source revision; +tests/fixtures/licenses/SHIMIFY-LICENSE.txt retains its exact MIT notice. Emitted +shared-computation artifacts, the host smoke fixture and the runnable browser +example include that notice in LICENSES/shimify.txt with manifest hashes. Other +cases are original regression examples; no upstream Python test suite is copied. +This attribution does not choose a license for the compiler itself. diff --git a/docs/compiler-emitter.md b/docs/compiler-emitter.md index c0427b2..11b8f9d 100644 --- a/docs/compiler-emitter.md +++ b/docs/compiler-emitter.md @@ -86,8 +86,9 @@ Run `python -m unittest discover -s tests -p 'test_compiler*.py' -v` with the installed development checkout and Node 24.19.0. Tests compare CPython and emitted Node output/errors/evaluation order, the single-module text_metrics application, precision/Unicode/tuple/default behavior and an independent Node source-map -consumer. Local modules, async browser capabilities and the final browser parity -gate remain MOD-01, ASYNC-01/BROWSER-01 and QA-03. This compiler executes only +consumer. Local modules and async browser capabilities have separate adopted +profiles. The shared [compiler-parity/1 suite](compiler-conformance.md) compares +emitted Node and browser-worker execution with CPython. This compiler executes only ahead-of-time approved source; it does not parse new source in the browser. Exception construction does not start a traceback. An explicit raise records diff --git a/examples/browser_text_metrics.py b/examples/browser_text_metrics.py index 4c3c473..6f73da8 100644 --- a/examples/browser_text_metrics.py +++ b/examples/browser_text_metrics.py @@ -6,6 +6,7 @@ from pathlib import Path from JavaScript.browser import compile_browser +from JavaScript.emitter import OutputFile from JavaScript.modules import SourceModule @@ -23,7 +24,8 @@ def build(output): corpus = json.loads((ROOT / 'tests/fixtures/compiler_contract.json').read_text(encoding='utf-8')) reference = next(case['source'] for case in corpus['cases'] if case['id'] == 'text-metrics-single-module') computation = reference[:reference.index('\nprint(metrics(')] - artifact = compile_browser((SourceModule.from_text('app', APP), SourceModule.from_text('text_metrics', computation)), entrypoint='app') + artifact = compile_browser((SourceModule.from_text('app', APP), SourceModule.from_text('text_metrics', computation)), entrypoint='app', + notices=(OutputFile('LICENSES/shimify.txt', (ROOT / 'tests/fixtures/licenses/SHIMIFY-LICENSE.txt').read_bytes(), 'notice'),)) identity = {'manifest_sha256': hashlib.sha256(artifact.manifest_bytes).hexdigest(), 'profile': 'compiled-javascript', 'profile_version': 1, 'entrypoint': 'app.mjs', 'engine': None} html = ''' @@ -59,8 +61,12 @@ def build(output): if output.exists() or output.is_symlink(): raise ValueError('Choose a new output directory.') output.mkdir(parents=True) - written = [] + written, directories = [], [] try: + parents = {parent for name in files for parent in (output / name).parents if parent != output and output in parent.parents} + for directory in sorted(parents, key=lambda path: len(path.parts)): + directory.mkdir() + directories.append(directory) for name, data in files.items(): path = output / name with path.open('xb') as stream: @@ -69,6 +75,11 @@ def build(output): except BaseException: for path in reversed(written): path.unlink(missing_ok=True) + for directory in reversed(directories): + try: + directory.rmdir() + except OSError: + pass try: output.rmdir() except OSError: diff --git a/pyproject.toml b/pyproject.toml index 9e4813c..51333b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,10 +35,14 @@ source-include = [ "tests/fixtures/conformance.mjs", "tests/fixtures/browser_protocol.mjs", "tests/fixtures/compiler_contract.json", + "tests/fixtures/compiler_parity.json", + "tests/fixtures/licenses/SHIMIFY-LICENSE.txt", "docs/compatibility.md", "docs/compiler*.md", "scripts/install_distribution.py", "scripts/compiler_browser_smoke.py", + "scripts/compiler_conformance.py", + "scripts/compiler_conformance.mjs", "examples/browser_text_metrics.py", "CHANGELOG.md", "RELEASING.md", diff --git a/scripts/compiler_browser_smoke.py b/scripts/compiler_browser_smoke.py index a0acb7f..258e172 100644 --- a/scripts/compiler_browser_smoke.py +++ b/scripts/compiler_browser_smoke.py @@ -15,6 +15,7 @@ from playwright.sync_api import sync_playwright from JavaScript.browser import compile_browser +from JavaScript.emitter import OutputFile from JavaScript.modules import SourceModule @@ -100,7 +101,8 @@ def sources(): def smoke(channel=None): - artifact = compile_browser(sources(), entrypoint='app') + artifact = compile_browser(sources(), entrypoint='app', notices=(OutputFile( + 'LICENSES/shimify.txt', (ROOT / 'tests/fixtures/licenses/SHIMIFY-LICENSE.txt').read_bytes(), 'notice'),)) with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) for item in artifact.files: diff --git a/scripts/compiler_conformance.mjs b/scripts/compiler_conformance.mjs new file mode 100644 index 0000000..4909724 --- /dev/null +++ b/scripts/compiler_conformance.mjs @@ -0,0 +1,32 @@ +// The same emitted artifacts and result encoding run in Node and a browser worker. +function typed(value) { + if (value === null) return {type: 'none'}; + if (typeof value === 'boolean') return {type: 'bool', value}; + if (typeof value === 'bigint') return {type: 'int', value: value.toString()}; + if (value?.type === 'str' && Array.isArray(value.codepoints)) + return {type: 'str', codepoints: [...value.codepoints]}; + if (value?.type === 'tuple' && Array.isArray(value.items)) + return {type: 'tuple', items: value.items.map(typed)}; + throw new TypeError('Unadopted target result'); +} + +export async function runCases(cases) { + const results = []; + for (const item of cases) { + const {createModule} = await import(new URL(item.url, import.meta.url)); + let stdout = '', value = null, error = null, stack = ''; + try { + const app = createModule({write: text => {stdout += text;}}); + if (item.export) value = await app[item.export](); + } catch (caught) { + error = {name: caught.name, message: caught.message, location: caught.location}; + stack = String(caught.stack); + } + results.push({id: item.id, stdout, value: typed(value), error, stack}); + } + const order = []; + const pending = Promise.resolve(1).then(() => {order.push('callback');}); + order.push('sync'); await pending; order.push('await'); + return {cases: results, native: {'array-length': Array.of(1, 2, 3).length, + 'promise-order': order, 'utf16-length': '😀'.length}}; +} diff --git a/scripts/compiler_conformance.py b/scripts/compiler_conformance.py new file mode 100644 index 0000000..0745b6a --- /dev/null +++ b/scripts/compiler_conformance.py @@ -0,0 +1,268 @@ +"""Compare approved emitted modules in Node and browser workers with CPython.""" + +import argparse +from contextlib import redirect_stdout +from functools import partial +import hashlib +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +import io +import json +from pathlib import Path +import platform +import subprocess +import sys +import tempfile +from threading import Thread +import traceback + +from JavaScript.compiler import CompileError +from JavaScript.emitter import OutputFile, compile_source +from JavaScript.modules import SourceModule, compile_modules + + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = ROOT / 'tests/fixtures' +RUNNER = ROOT / 'scripts/compiler_conformance.mjs' +LICENSE = FIXTURES / 'licenses/SHIMIFY-LICENSE.txt' + + +def load_cases(): + contract = json.loads((FIXTURES / 'compiler_contract.json').read_text(encoding='utf-8')) + parity = json.loads((FIXTURES / 'compiler_parity.json').read_text(encoding='utf-8')) + cases = [dict(case, family='core') for case in contract['cases']] + parity['cases'] + identities = [case['id'] for case in cases + contract['boundaries'] + parity['boundaries']] + if len(identities) != len(set(identities)): + raise ValueError('Conformance identities must be unique.') + return cases, contract['boundaries'] + parity['boundaries'] + + +def typed(value): + if value is None: + return {'type': 'none'} + if type(value) is bool: + return {'type': 'bool', 'value': value} + if type(value) is int: + return {'type': 'int', 'value': str(value)} + if type(value) is str: + return {'type': 'str', 'codepoints': list(map(ord, value))} + if type(value) is tuple: + return {'type': 'tuple', 'items': list(map(typed, value))} + raise TypeError('Unadopted reference result: ' + type(value).__name__) + + +def oracle(case): + """Test-only trusted fixture execution in a fresh isolated reference process.""" + output, result, failure = io.StringIO(), None, None + sources = case['modules'] if 'modules' in case else [{'name': 'app', 'source': case['source']}] + namespace = {'__name__': '__main__'} + owned = {item['name'].replace('.', '/') + ('/__init__.py' if item.get('package') else '.py'): item['source'] for item in sources} + with tempfile.TemporaryDirectory() as temporary, redirect_stdout(output): + root = Path(temporary) + try: + if 'modules' in case: + import importlib + for name, source in owned.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(source, encoding='utf-8') + sys.path.insert(0, str(root)) + namespace = vars(importlib.import_module('app')) + else: + exec(compile(case['source'], 'app.py', 'exec'), namespace) + if case.get('export'): + result = namespace[case['export']]() + if case.get('async'): + import asyncio + result = asyncio.run(result) + except Exception as error: + result = None + frames = [] + for frame in traceback.extract_tb(error.__traceback__): + filename = frame.filename + try: + filename = Path(filename).relative_to(root).as_posix() + except ValueError: + pass + if filename in owned: + frames.append((filename, frame)) + filename, frame = frames[-1] + line = owned[filename].splitlines()[frame.lineno - 1] + # CPython's code-position columns use UTF-8 bytes; the profile uses code points. + column = len(line.encode('utf-8')[:frame.colno].decode('utf-8')) + 1 + failure = {'name': type(error).__name__, 'message': str(error), + 'location': {'filename': filename, 'line': frame.lineno, 'column': column}} + return {'stdout': output.getvalue(), 'value': typed(result), 'error': failure} + + +def compile_case(case): + notices = (OutputFile('LICENSES/shimify.txt', LICENSE.read_bytes(), 'notice'),) if case.get('borrowed') else () + if 'modules' in case or notices: + modules = case.get('modules') or [{'name': 'app', 'source': case['source']}] + sources = tuple(SourceModule.from_text(item['name'], item['source'], is_package=item.get('package', False)) for item in modules) + return compile_modules(sources, entrypoint='app', async_enabled=case.get('async', False), notices=notices) + return compile_source(case['source'], async_enabled=case.get('async', False)) + + +def reference(case): + process = subprocess.run([sys.executable, '-I', '-B', '-X', 'utf8', str(Path(__file__).resolve()), '--oracle'], + input=json.dumps(case), text=True, encoding='utf-8', capture_output=True, check=True, timeout=15) + return json.loads(process.stdout) + + +def prepare(root): + cases, boundaries = load_cases() + entries, expected, artifacts = [], {}, {} + for case in cases: + # The shared computation is borrowed from Shimify and carries its exact MIT notice. + if case['id'] == 'text-metrics-single-module': + case = dict(case, borrowed=True) + artifact = compile_case(case) + directory = root / case['id'] + for item in artifact.files: + path = directory / item.path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(item.data) + entries.append({'id': case['id'], 'url': './' + case['id'] + '/app.mjs', 'export': case.get('export')}) + expected[case['id']] = reference(case) + artifacts[case['id']] = artifact + if 'stdout' in case and expected[case['id']]['stdout'] != case['stdout']: + raise AssertionError('Reference corpus expectation drifted: ' + case['id']) + rejected = [] + for case in boundaries: + try: + compile_source(case['source'], async_enabled=case.get('async', False)) + except CompileError as error: + diagnostic = error.diagnostic.code + if diagnostic != case['diagnostic']: + raise AssertionError((case['id'], diagnostic, case['diagnostic'])) + rejected.append({'id': case['id'], 'classification': case['classification'], 'diagnostic': diagnostic}) + else: + raise AssertionError('Unsupported fixture compiled: ' + case['id']) + (root / 'runner.mjs').write_bytes(RUNNER.read_bytes()) + (root / 'cases.json').write_text(json.dumps(entries), encoding='utf-8') + return cases, expected, artifacts, rejected + + +def run_node(root): + launcher = "import {readFile} from 'node:fs/promises';import {runCases} from './runner.mjs';const cases=JSON.parse(await readFile(new URL('./cases.json',import.meta.url)));console.log(JSON.stringify(await runCases(cases)));" + (root / 'node.mjs').write_text(launcher, encoding='utf-8') + return json.loads(subprocess.check_output(['node', '--unhandled-rejections=strict', str(root / 'node.mjs')], text=True, encoding='utf-8', timeout=40)) + + +class Handler(SimpleHTTPRequestHandler): + def log_message(self, *args): + pass + + +def run_browser(root, channel): + from playwright.sync_api import sync_playwright + (root / 'index.html').write_text('Compiler conformance', encoding='utf-8') + (root / 'worker.mjs').write_text("import {runCases} from './runner.mjs';self.onmessage=async event=>{try{self.postMessage({result:await runCases(event.data)})}catch(error){self.postMessage({failure:String(error.stack)})}};", encoding='utf-8') + server = ThreadingHTTPServer(('127.0.0.1', 0), partial(Handler, directory=str(root))) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + with sync_playwright() as playwright: + browser = playwright.chromium.launch(channel=channel) + try: + page = browser.new_page() + page.goto(f'http://127.0.0.1:{server.server_port}/') + page.set_default_timeout(30000) + result = page.evaluate("""async()=>{ + const cases=await (await fetch('./cases.json')).json(); + const worker=new Worker('./worker.mjs',{type:'module'}); + try{return await new Promise((resolve,reject)=>{ + const timer=setTimeout(()=>reject(new Error('Conformance worker timed out')),25000); + worker.onmessage=event=>{clearTimeout(timer);event.data.failure?reject(new Error(event.data.failure)):resolve(event.data.result)}; + worker.onerror=event=>{clearTimeout(timer);reject(new Error(event.message))}; + worker.postMessage(cases); + })}finally{worker.terminate()} + }""") + return result, browser.version + finally: + browser.close() + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def mapped_frames(root, artifact, stack): + maps = {item.path.removesuffix('.map'): json.loads(item.data) for item in artifact.files if item.role == 'source-map'} + consumer = """import {SourceMap} from 'node:module';let text='';for await(const chunk of process.stdin)text+=chunk; +const {maps,stack}=JSON.parse(text),frames=[]; +for(const match of stack.matchAll(/(?:[/\\\\])([^/\\\\():\\s]+\\.mjs):(\\d+):(\\d+)/g)){ + if(!maps[match[1]])continue; + const entry=new SourceMap(maps[match[1]]).findEntry(Number(match[2])-1,Number(match[3])-1); + if(entry.originalSource!==undefined)frames.push({filename:entry.originalSource,line:entry.originalLine+1,column:entry.originalColumn+1}); +}console.log(JSON.stringify(frames));""" + process = subprocess.run(['node', '--input-type=module', '-e', consumer], input=json.dumps({'maps': maps, 'stack': stack}), + capture_output=True, text=True, encoding='utf-8', timeout=10, check=True) + return json.loads(process.stdout) + + +def compare(cases, expected, actual, artifacts, root): + if len(actual['cases']) != len(cases) or len({item['id'] for item in actual['cases']}) != len(cases): + raise AssertionError('Missing or duplicate target cases.') + by_id = {item['id']: item for item in actual['cases']} + records = [] + for case in cases: + observed = dict(by_id[case['id']]) + observed.pop('id') + stack = observed.pop('stack', '') + if observed['error']: + observed['error']['location'] = {key: observed['error']['location'][key] for key in ['filename', 'line', 'column']} + if observed != expected[case['id']]: + raise AssertionError((case['id'], 'reference', expected[case['id']], 'target', observed)) + frames = mapped_frames(root, artifacts[case['id']], stack) if stack else [] + if case.get('traceback'): + origin = expected[case['id']]['error']['location'] + if not frames or (frames[0]['filename'], frames[0]['line']) != (origin['filename'], origin['line']): + raise AssertionError((case['id'], 'mapped traceback', frames, 'origin', origin)) + records.append({'id': case['id'], 'family': case['family'], 'status': 'pass', **observed, 'mapped_frames': frames}) + if actual['native'] != {'array-length': 3, 'promise-order': ['sync', 'callback', 'await'], 'utf16-length': 2}: + raise AssertionError(('native observation drift', actual['native'])) + return {'cases': records, 'native': actual['native']} + + +def conformance(*, browser=False, channel=None): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + cases, expected, artifacts, rejected = prepare(root) + node = compare(cases, expected, run_node(root), artifacts, root) + targets = {'node': {'version': subprocess.check_output(['node', '--version'], text=True).strip(), **node}} + if browser: + actual, browser_version = run_browser(root, channel) + targets['browser-worker'] = {'version': browser_version, **compare(cases, expected, actual, artifacts, root)} + digest = hashlib.sha256() + for path in [FIXTURES / 'compiler_contract.json', FIXTURES / 'compiler_parity.json', RUNNER, LICENSE]: + data = path.read_bytes() + digest.update(len(data).to_bytes(8, 'big')) + digest.update(data) + return {'schema_version': 1, 'suite': 'compiler-parity/1', 'corpus_sha256': digest.hexdigest(), + 'reference': {'implementation': platform.python_implementation(), 'version': platform.python_version()}, + 'execution': 'ahead-of-time emitted modules; no target Python fallback', + 'targets': targets, 'boundaries': rejected, + 'artifacts': {name: hashlib.sha256(item.manifest_bytes).hexdigest() for name, item in artifacts.items()}} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--oracle', action='store_true', help=argparse.SUPPRESS) + parser.add_argument('--browser', action='store_true') + parser.add_argument('--channel') + parser.add_argument('--report', type=Path) + args = parser.parse_args() + if args.oracle: + print(json.dumps(oracle(json.load(sys.stdin)))) + return + report = conformance(browser=args.browser, channel=args.channel) + if args.report: + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + print(json.dumps({'suite': report['suite'], 'reference': report['reference'], 'targets': { + name: {'version': target['version'], 'passed': len(target['cases'])} for name, target in report['targets'].items()}, 'boundaries': len(report['boundaries'])})) + + +if __name__ == '__main__': + main() diff --git a/tests/fixtures/compiler_parity.json b/tests/fixtures/compiler_parity.json new file mode 100644 index 0000000..d97e31d --- /dev/null +++ b/tests/fixtures/compiler_parity.json @@ -0,0 +1,166 @@ +{ + "schema_version": 1, + "suite": "compiler-parity/1", + "reference": "CPython 3.14.7", + "attribution": [ + { + "cases": [ + "text-metrics-single-module" + ], + "source": "https://github.com/outside-labs/shimify/tree/b48ed1778270508007d75b861dc117ad4ddc4f2b/tests/fixtures/text_metrics", + "license": "licenses/SHIMIFY-LICENSE.txt", + "notes": "Shared reduced computation adopted in compiler_contract.json; exact MIT notice retained in compiled artifacts. Other cases are original regression examples." + } + ], + "cases": [ + { + "id": "typed-return", + "source": "def main():\n return None, True, False, 9007199254740993, -9007199254740995, \"A\ud83d\ude00e\u0301\", (1, \"\\ud83d\\ude00\")", + "family": "values", + "export": "main" + }, + { + "id": "unicode-columns", + "source": "def main():\n \"\ud83d\ude00\"; return 1 // 0", + "family": "errors", + "export": "main", + "traceback": true + }, + { + "id": "evaluation-order", + "source": "def mark(value):\n print(value)\n return value\ndef main():\n result = mark(1) < mark(2) < mark(3)\n other = mark(3) < mark(2) < mark(1)\n print(mark(\"\") and mark(\"never\"))\n print(mark(()) or mark(\"fallback\"))\n return result, other", + "family": "side-effects", + "export": "main" + }, + { + "id": "default-alias", + "source": "def selected(value=(1, \"\ud83d\ude00\")):\n return value\ndef main():\n first=selected()\n alias=selected\n second=selected()\n return first is second, selected is alias, first", + "family": "values", + "export": "main" + }, + { + "id": "numeric-and-unicode", + "source": "def main():\n return -7//3, 7//-3, -7%3, 7%-3, True+2, len(\"\\ud83d\\ude00\"), len(\"\ud83d\ude00\"), \"\\ud83d\\ude00\"==\"\ud83d\ude00\", \"\ud83d\ude00\"[-1], (1,2)<(1,3)", + "family": "values", + "export": "main" + }, + { + "id": "preconstructed-raise", + "source": "def main():\n error=TypeError(\"bad value\")\n print(error is error)\n raise error", + "family": "errors", + "export": "main", + "traceback": true + }, + { + "id": "output-before-error", + "source": "print(\"before\")\n1//0", + "family": "errors", + "traceback": true + }, + { + "id": "index-error", + "source": "def main():\n return (1,)[4]", + "family": "errors", + "export": "main", + "traceback": true + }, + { + "id": "unbound-local", + "source": "def main():\n if False:\n item=1\n return item", + "family": "errors", + "export": "main" + }, + { + "id": "lazy-coroutine", + "source": "async def selected(value=(1,\"\ud83d\ude00\")):\n print(\"inside\")\n return value\nasync def main():\n pending=selected()\n print(\"before\")\n first=await pending\n print(\"after\")\n second=await selected()\n return first is second, first", + "family": "async", + "export": "main", + "async": true + }, + { + "id": "promise-callback-once", + "source": "from JavaScript import Promise\ndef callback(value):\n print(\"callback\",value)\n return value+1\nasync def main():\n return await Promise.resolve(9007199254740993).then(callback)", + "family": "async", + "export": "main", + "async": true + }, + { + "id": "promise-rejection-value", + "source": "from JavaScript import Promise\nasync def main():\n error=TypeError(\"rejected\")\n pending=Promise.reject(error)\n await pending", + "family": "errors", + "export": "main", + "async": true + }, + { + "id": "promise-callback-raise", + "source": "from JavaScript import Promise\ndef callback(value):\n print(\"once\")\n error=TypeError(\"callback error\")\n raise error\nasync def main():\n return await Promise.resolve(1).then(callback)", + "family": "errors", + "export": "main", + "traceback": true, + "async": true + }, + { + "id": "async-original-failure", + "source": "async def inner():\n return 1//0\nasync def main():\n return await inner()", + "family": "errors", + "export": "main", + "traceback": true, + "async": true + }, + { + "id": "module-alias-and-initialization", + "family": "modules", + "export": "main", + "modules": [ + { + "name": "app", + "source": "from helpers import selected, selected as alias\nprint(\"app\")\ndef main():\n first=selected()\n second=alias()\n return selected is alias, first is second, first" + }, + { + "name": "helpers", + "source": "print(\"helpers\")\ndef selected(value=(1,\"\ud83d\ude00\")):\n return value" + } + ] + }, + { + "id": "module-callback-origin", + "family": "modules", + "export": "main", + "async": true, + "traceback": true, + "modules": [ + { + "name": "app", + "source": "from JavaScript import Promise\nfrom helpers import callback\nasync def main():\n return await Promise.resolve(1).then(callback)" + }, + { + "name": "helpers", + "source": "def callback(value):\n error=TypeError(\"helper error\")\n raise error" + } + ] + } + ], + "boundaries": [ + { + "id": "async-classes", + "source": "class A:\n pass", + "async": true, + "classification": "deferred", + "diagnostic": "PJS100" + }, + { + "id": "async-generator", + "source": "async def main():\n yield 1", + "async": true, + "classification": "deferred", + "diagnostic": "PJS100" + }, + { + "id": "unapproved-promise-method", + "source": "from JavaScript import Promise\nasync def main():\n return await Promise.all(())", + "async": true, + "classification": "rejected", + "diagnostic": "PJS110" + } + ] +} diff --git a/tests/fixtures/licenses/SHIMIFY-LICENSE.txt b/tests/fixtures/licenses/SHIMIFY-LICENSE.txt new file mode 100644 index 0000000..f46deb0 --- /dev/null +++ b/tests/fixtures/licenses/SHIMIFY-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ella Inng + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tests/test_compiler_conformance.py b/tests/test_compiler_conformance.py new file mode 100644 index 0000000..5cd9735 --- /dev/null +++ b/tests/test_compiler_conformance.py @@ -0,0 +1,29 @@ +import importlib.util +import json +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location('compiler_conformance', ROOT / 'scripts/compiler_conformance.py') +SUITE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(SUITE) + + +class CompilerConformanceTests(unittest.TestCase): + def test_shared_reference_and_emitted_node_suite(self): + report = SUITE.conformance() + cases, boundaries = SUITE.load_cases() + self.assertEqual(report['suite'], 'compiler-parity/1') + self.assertEqual(len(report['targets']['node']['cases']), len(cases)) + self.assertEqual(len(report['boundaries']), len(boundaries)) + self.assertEqual({item['status'] for item in report['targets']['node']['cases']}, {'pass'}) + json.dumps(report) + + def test_borrowed_computation_retains_exact_notice_and_record(self): + cases, _ = SUITE.load_cases() + borrowed = next(case for case in cases if case['id'] == 'text-metrics-single-module') + artifact = SUITE.compile_case(dict(borrowed, borrowed=True)) + notice = next(item for item in artifact.files if item.path == 'LICENSES/shimify.txt') + self.assertEqual(notice.data, SUITE.LICENSE.read_bytes()) + self.assertIn(notice.path, artifact.manifest['notices'])