Skip to content

Commit 35fbe6e

Browse files
authored
test(QA-03): verify shared compiler targets against CPython (#36)
* fix(ERR-01): preserve raise and await source origins * test(QA-03): verify shared compiler targets against CPython
1 parent 4c24485 commit 35fbe6e

13 files changed

Lines changed: 623 additions & 8 deletions

‎.github/workflows/ci.yaml‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
persist-credentials: false
2424
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
2525
with:
26-
python-version: '3.14'
26+
python-version: '3.14.7'
2727
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
2828
with:
2929
node-version: '24.19.0'
@@ -60,10 +60,13 @@ jobs:
6060
- run: uv python install
6161
- run: uv sync --locked --group browser-tests
6262
- run: uv run --no-sync python -m playwright install --with-deps chromium
63+
- run: uv run --no-sync python scripts/compiler_conformance.py --browser --report build/compiler-parity-report.json
6364
- run: uv run --no-sync python scripts/compiler_browser_smoke.py --report build/compiler-browser-report.json
6465
- uses: actions/upload-artifact@v5
6566
if: always()
6667
with:
6768
name: compiler-browser-report
68-
path: build/compiler-browser-report.json
69+
path: |
70+
build/compiler-browser-report.json
71+
build/compiler-parity-report.json
6972
if-no-files-found: error

‎IMPLEMENTATION_PLAN.md‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ meet their first milestones.
357357
| [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 |
358358
| [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 |
359359
| [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 |
360-
| [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 |
360+
| [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 |
361361
| [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 |
362362

363363
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
469469
awaits replace constructor spans, while propagated runtime/callback errors retain
470470
their origin. CPython/native/browser cases cover the correction and mapped
471471
tracebacks. QA-03 now depends on this diagnostic fix as well as BROWSER-01.
472+
473+
474+
QA-03 implements compiler-parity/1: one approved corpus executes under isolated
475+
CPython and as identical emitted artifacts in Node and a real browser worker.
476+
Reports retain typed values, output order, exception origins, source-map frames,
477+
native JavaScript observations, explicit rejected/deferred diagnostics and exact
478+
corpus/artifact identities. The browser host suite separately verifies callback
479+
release, cancellation and worker stop/restart. Borrowed text-metrics cases retain
480+
Shimify's exact MIT notice, including the runnable browser example. The versioned
481+
[compatibility matrix](docs/compiler-conformance.md) records the adopted scope
482+
and remaining differences; full Python tracebacks and arbitrary dynamic Python
483+
remain outside this compiler profile. No release was published.

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,5 @@ Build the local [text_metrics browser example](docs/compiler-browser.md#run-the-
117117
with `uv run python examples/browser_text_metrics.py --output build/browser-text-metrics`.
118118
It uses our emitted modules and helpers in a worker; source parsing remains the
119119
separate independent-interpreter milestone.
120+
121+
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.

‎docs/compiler-conformance.md‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Compiler compatibility matrix: compiler-parity/1
2+
3+
The development compiler runs the same approved source corpus under CPython
4+
3.14.7 and as identical emitted ES modules in Node 24.19.0 and a Chromium module
5+
worker. This is a bounded ahead-of-time Python profile. The independent source
6+
interpreter is a separate pocket-python implementation. Published 0.1.0 predates
7+
this compiler.
8+
9+
| Area | Adopted behavior and evidence | Boundary |
10+
| --- | --- | --- |
11+
| Values | Typed None, bool, arbitrary-size int, code-point str and immutable tuple returns | Mutable containers, floats and arbitrary host values are deferred |
12+
| 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 |
13+
| 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 |
14+
| 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 |
15+
| 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 |
16+
| 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 |
17+
| 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 |
18+
| Imports | Explicit source allowlist and shared module/runtime identity; module callback origins preserved | Dynamic imports, undeclared sources and arbitrary installed-package execution are rejected |
19+
| 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 |
20+
21+
The schema-1 corpus consists of compiler_contract.json plus compiler_parity.json.
22+
The initial matrix has 24 supported cases and 10 explicit boundary cases. Its
23+
report also records three native observations in both JavaScript targets:
24+
Array.of length, Promise callback ordering and UTF-16 string length. These are
25+
native API observations, not CPython-language assertions. Compiler Promise cases
26+
compare the adopted Python wrapper with emitted modules separately.
27+
28+
The reference runs only trusted test fixtures in fresh isolated Python processes.
29+
The browser fixture server serves static assets; the browser worker performs the
30+
computation. Both targets import the same artifact bytes. Tagged comparison
31+
preserves integer precision, Unicode surrogate distinctions and tuple structure.
32+
Output and original error spans are compared independently. Source-map checks
33+
feed actual target stack frames to Node's SourceMap consumer. An unraised
34+
exception rejected through a Promise reports its first source await; its native
35+
JS stack can retain the construction site. Only explicitly raised errors in the
36+
traceback subset require the first mapped frame to identify the error origin.
37+
38+
Run the complete suite from a development checkout with the declared browser
39+
test group and its Chromium installation:
40+
41+
```sh
42+
uv sync --locked --group browser-tests
43+
uv run --no-sync python -m playwright install chromium
44+
uv run --no-sync python scripts/compiler_conformance.py --browser --report build/compiler-parity-report.json
45+
uv run --no-sync python scripts/compiler_browser_smoke.py --report build/compiler-browser-report.json
46+
```
47+
48+
Omit --browser for the Node/reference gate; --channel chrome selects an installed
49+
Chrome for local verification. CI pins the reference and Node versions, uses the
50+
locked Playwright package and retains both reports with the actual browser
51+
version. The ordinary installed-wheel/sdist suite runs the Node/reference gate
52+
on Linux, macOS and Windows. Reports contain every compared result, original and
53+
mapped error locations, profile classifications, corpus SHA-256 and artifact
54+
manifest identities. A corpus change updates its digest; incompatible report
55+
schema/comparison semantics require a new suite version. A report describes its
56+
tested versions and selected cases, not universal CPython compatibility.
57+
58+
The shared reduced text-metrics computation originates in Shimify's fixture
59+
package. The attribution entry in compiler_parity.json pins the source revision;
60+
tests/fixtures/licenses/SHIMIFY-LICENSE.txt retains its exact MIT notice. Emitted
61+
shared-computation artifacts, the host smoke fixture and the runnable browser
62+
example include that notice in LICENSES/shimify.txt with manifest hashes. Other
63+
cases are original regression examples; no upstream Python test suite is copied.
64+
This attribution does not choose a license for the compiler itself.

‎docs/compiler-emitter.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,9 @@ Run `python -m unittest discover -s tests -p 'test_compiler*.py' -v` with the
8686
installed development checkout and Node 24.19.0. Tests compare CPython and emitted
8787
Node output/errors/evaluation order, the single-module text_metrics application,
8888
precision/Unicode/tuple/default behavior and an independent Node source-map
89-
consumer. Local modules, async browser capabilities and the final browser parity
90-
gate remain MOD-01, ASYNC-01/BROWSER-01 and QA-03. This compiler executes only
89+
consumer. Local modules and async browser capabilities have separate adopted
90+
profiles. The shared [compiler-parity/1 suite](compiler-conformance.md) compares
91+
emitted Node and browser-worker execution with CPython. This compiler executes only
9192
ahead-of-time approved source; it does not parse new source in the browser.
9293

9394
Exception construction does not start a traceback. An explicit raise records

‎examples/browser_text_metrics.py‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from pathlib import Path
77

88
from JavaScript.browser import compile_browser
9+
from JavaScript.emitter import OutputFile
910
from JavaScript.modules import SourceModule
1011

1112

@@ -23,7 +24,8 @@ def build(output):
2324
corpus = json.loads((ROOT / 'tests/fixtures/compiler_contract.json').read_text(encoding='utf-8'))
2425
reference = next(case['source'] for case in corpus['cases'] if case['id'] == 'text-metrics-single-module')
2526
computation = reference[:reference.index('\nprint(metrics(')]
26-
artifact = compile_browser((SourceModule.from_text('app', APP), SourceModule.from_text('text_metrics', computation)), entrypoint='app')
27+
artifact = compile_browser((SourceModule.from_text('app', APP), SourceModule.from_text('text_metrics', computation)), entrypoint='app',
28+
notices=(OutputFile('LICENSES/shimify.txt', (ROOT / 'tests/fixtures/licenses/SHIMIFY-LICENSE.txt').read_bytes(), 'notice'),))
2729
identity = {'manifest_sha256': hashlib.sha256(artifact.manifest_bytes).hexdigest(), 'profile': 'compiled-javascript',
2830
'profile_version': 1, 'entrypoint': 'app.mjs', 'engine': None}
2931
html = '''<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width">
@@ -59,8 +61,12 @@ def build(output):
5961
if output.exists() or output.is_symlink():
6062
raise ValueError('Choose a new output directory.')
6163
output.mkdir(parents=True)
62-
written = []
64+
written, directories = [], []
6365
try:
66+
parents = {parent for name in files for parent in (output / name).parents if parent != output and output in parent.parents}
67+
for directory in sorted(parents, key=lambda path: len(path.parts)):
68+
directory.mkdir()
69+
directories.append(directory)
6470
for name, data in files.items():
6571
path = output / name
6672
with path.open('xb') as stream:
@@ -69,6 +75,11 @@ def build(output):
6975
except BaseException:
7076
for path in reversed(written):
7177
path.unlink(missing_ok=True)
78+
for directory in reversed(directories):
79+
try:
80+
directory.rmdir()
81+
except OSError:
82+
pass
7283
try:
7384
output.rmdir()
7485
except OSError:

‎pyproject.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,14 @@ source-include = [
3535
"tests/fixtures/conformance.mjs",
3636
"tests/fixtures/browser_protocol.mjs",
3737
"tests/fixtures/compiler_contract.json",
38+
"tests/fixtures/compiler_parity.json",
39+
"tests/fixtures/licenses/SHIMIFY-LICENSE.txt",
3840
"docs/compatibility.md",
3941
"docs/compiler*.md",
4042
"scripts/install_distribution.py",
4143
"scripts/compiler_browser_smoke.py",
44+
"scripts/compiler_conformance.py",
45+
"scripts/compiler_conformance.mjs",
4246
"examples/browser_text_metrics.py",
4347
"CHANGELOG.md",
4448
"RELEASING.md",

‎scripts/compiler_browser_smoke.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from playwright.sync_api import sync_playwright
1616

1717
from JavaScript.browser import compile_browser
18+
from JavaScript.emitter import OutputFile
1819
from JavaScript.modules import SourceModule
1920

2021

@@ -100,7 +101,8 @@ def sources():
100101

101102

102103
def smoke(channel=None):
103-
artifact = compile_browser(sources(), entrypoint='app')
104+
artifact = compile_browser(sources(), entrypoint='app', notices=(OutputFile(
105+
'LICENSES/shimify.txt', (ROOT / 'tests/fixtures/licenses/SHIMIFY-LICENSE.txt').read_bytes(), 'notice'),))
104106
with tempfile.TemporaryDirectory() as temporary:
105107
root = Path(temporary)
106108
for item in artifact.files:

‎scripts/compiler_conformance.mjs‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// The same emitted artifacts and result encoding run in Node and a browser worker.
2+
function typed(value) {
3+
if (value === null) return {type: 'none'};
4+
if (typeof value === 'boolean') return {type: 'bool', value};
5+
if (typeof value === 'bigint') return {type: 'int', value: value.toString()};
6+
if (value?.type === 'str' && Array.isArray(value.codepoints))
7+
return {type: 'str', codepoints: [...value.codepoints]};
8+
if (value?.type === 'tuple' && Array.isArray(value.items))
9+
return {type: 'tuple', items: value.items.map(typed)};
10+
throw new TypeError('Unadopted target result');
11+
}
12+
13+
export async function runCases(cases) {
14+
const results = [];
15+
for (const item of cases) {
16+
const {createModule} = await import(new URL(item.url, import.meta.url));
17+
let stdout = '', value = null, error = null, stack = '';
18+
try {
19+
const app = createModule({write: text => {stdout += text;}});
20+
if (item.export) value = await app[item.export]();
21+
} catch (caught) {
22+
error = {name: caught.name, message: caught.message, location: caught.location};
23+
stack = String(caught.stack);
24+
}
25+
results.push({id: item.id, stdout, value: typed(value), error, stack});
26+
}
27+
const order = [];
28+
const pending = Promise.resolve(1).then(() => {order.push('callback');});
29+
order.push('sync'); await pending; order.push('await');
30+
return {cases: results, native: {'array-length': Array.of(1, 2, 3).length,
31+
'promise-order': order, 'utf16-length': '😀'.length}};
32+
}

0 commit comments

Comments
 (0)