diff --git a/.github/workflows/feature-matrix.yml b/.github/workflows/feature-matrix.yml
new file mode 100644
index 0000000000..8dcff1b224
--- /dev/null
+++ b/.github/workflows/feature-matrix.yml
@@ -0,0 +1,83 @@
+name: TypeScript Feature Matrix
+
+on:
+ pull_request:
+ branches: [main]
+ paths:
+ - ".github/workflows/feature-matrix.yml"
+ - "scripts/gen_feature_matrix.py"
+ - "test-features/**"
+ - "crates/perry/**"
+ - "crates/perry-codegen/**"
+ - "crates/perry-hir/**"
+ - "crates/perry-runtime/**"
+ - "crates/perry-stdlib/**"
+ workflow_dispatch:
+ schedule:
+ # Advisory language-feature drift signal. It is not a required gate.
+ - cron: "43 4 * * *"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: feature-matrix-${{ github.ref }}
+ cancel-in-progress: false
+
+env:
+ CARGO_TERM_COLOR: always
+ MACOSX_DEPLOYMENT_TARGET: "13.0"
+
+jobs:
+ feature-matrix:
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+
+ - uses: Swatinem/rust-cache@v2
+ with:
+ shared-key: "${{ runner.os }}-perry-feature-matrix"
+ save-if: ${{ github.ref == 'refs/heads/main' }}
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: "26"
+
+ - name: Build Perry release binary
+ run: cargo build --release -p perry-runtime -p perry
+
+ - name: Check feature matrix
+ run: |
+ set -euo pipefail
+ mkdir -p .feature-matrix
+ python3 scripts/gen_feature_matrix.py \
+ --check \
+ --node-bin node \
+ --perry-bin "$GITHUB_WORKSPACE/target/release/perry" \
+ --report .feature-matrix/feature_matrix.json \
+ --generated-output .feature-matrix/feature_matrix.md
+
+ - name: Write matrix summary
+ if: always()
+ run: |
+ if [[ -f .feature-matrix/feature_matrix.md ]]; then
+ cat .feature-matrix/feature_matrix.md >> "$GITHUB_STEP_SUMMARY"
+ else
+ cat test-features/feature_matrix.md >> "$GITHUB_STEP_SUMMARY"
+ fi
+
+ - name: Upload feature matrix artifacts
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: feature-matrix-${{ github.sha }}
+ path: |
+ .feature-matrix/feature_matrix.json
+ .feature-matrix/feature_matrix.md
+ if-no-files-found: ignore
+ retention-days: 90
diff --git a/.gitignore b/.gitignore
index db9676d7a9..156a10310f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,6 +32,7 @@ benchmarks/suite/assets/
# -march=native, so sharing across machines with different CPUs can produce
# SIGILL at runtime. Always machine-local, always regenerable.)
.perry-cache/
+.feature-matrix/
# `perry compile --trace llvm` dumps per-module .ll files here.
.perry-trace/
@@ -44,6 +45,7 @@ benchmarks/suite/assets/
# Re-include the test source directories
!test-files/
!tests/
+!test-features/
!test-parity/
!test-coverage/
# But ignore compiled test binaries (no extension) inside them
diff --git a/scripts/gen_feature_matrix.py b/scripts/gen_feature_matrix.py
new file mode 100755
index 0000000000..6d035c7795
--- /dev/null
+++ b/scripts/gen_feature_matrix.py
@@ -0,0 +1,392 @@
+#!/usr/bin/env python3
+"""Generate the TypeScript feature compatibility matrix (#801).
+
+The matrix is a small, committed baseline: every probe runs once under Node's
+TypeScript stripper and once through a Perry-compiled native binary. Current
+gaps are recorded in the markdown output instead of failing the generator; CI
+uses `--check` to fail only when the committed matrix drifts from the probes.
+"""
+
+from __future__ import annotations
+
+import argparse
+import difflib
+import json
+import os
+import re
+import subprocess
+import sys
+import tempfile
+import tomllib
+from dataclasses import dataclass
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+DEFAULT_CONFIG = REPO_ROOT / "test-features" / "feature_matrix.toml"
+DEFAULT_MARKDOWN = REPO_ROOT / "test-features" / "feature_matrix.md"
+DEFAULT_PERRY = REPO_ROOT / "target" / "release" / "perry"
+
+NOISE = re.compile(
+ r"^\(node:\d+\) (ExperimentalWarning|Warning|\[DEP\d+\]|\[MODULE_TYPELESS)"
+ r"|^\(Use `node --trace"
+)
+
+
+@dataclass(frozen=True)
+class Probe:
+ category: str
+ name: str
+ path: Path
+ description: str
+
+
+@dataclass(frozen=True)
+class Result:
+ probe: Probe
+ status: str
+ node_exit: int | None
+ perry_exit: int | None
+ detail: str
+ output: str
+
+
+def normalize(text: str) -> str:
+ lines: list[str] = []
+ for raw in text.replace("\r\n", "\n").split("\n"):
+ line = raw.rstrip()
+ if NOISE.search(line):
+ continue
+ lines.append(line)
+ while lines and lines[-1] == "":
+ lines.pop()
+ return "\n".join(lines)
+
+
+def first_line(text: str) -> str:
+ for line in text.splitlines():
+ stripped = line.strip()
+ if stripped:
+ return stripped
+ return "(no output)"
+
+
+def shell_words(raw: object) -> list[str]:
+ if raw is None:
+ return []
+ if isinstance(raw, list) and all(isinstance(item, str) for item in raw):
+ return raw
+ raise ValueError("command argument lists must be string arrays")
+
+
+def read_config(path: Path) -> tuple[list[str], list[Probe]]:
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
+ settings = data.get("settings", {})
+ if not isinstance(settings, dict):
+ raise ValueError("[settings] must be a TOML table")
+ node_args = shell_words(settings.get("node_args"))
+
+ raw_probes = data.get("probe", [])
+ if not isinstance(raw_probes, list):
+ raise ValueError("[[probe]] entries are required")
+
+ probes: list[Probe] = []
+ seen: set[tuple[str, str]] = set()
+ for item in raw_probes:
+ if not isinstance(item, dict):
+ raise ValueError("each [[probe]] entry must be a table")
+ try:
+ category = item["category"]
+ name = item["name"]
+ rel_path = item["path"]
+ except KeyError as exc:
+ raise ValueError(f"probe is missing required field {exc.args[0]!r}") from exc
+ if not all(isinstance(value, str) for value in (category, name, rel_path)):
+ raise ValueError("probe category, name, and path must be strings")
+ key = (category, name)
+ if key in seen:
+ raise ValueError(f"duplicate probe {category}/{name}")
+ seen.add(key)
+ description = item.get("description", "")
+ if not isinstance(description, str):
+ raise ValueError("probe description must be a string")
+ probe_path = (path.parent / rel_path).resolve()
+ if not probe_path.exists():
+ raise FileNotFoundError(probe_path)
+ probes.append(Probe(category, name, probe_path, description))
+
+ return node_args, probes
+
+
+def run(cmd: list[str], *, cwd: Path, env: dict[str, str], timeout: int) -> tuple[int, str]:
+ try:
+ proc = subprocess.run(
+ cmd,
+ cwd=cwd,
+ env=env,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ timeout=timeout,
+ )
+ return proc.returncode, proc.stdout.decode("utf-8", errors="replace")
+ except subprocess.TimeoutExpired as exc:
+ out = exc.stdout.decode("utf-8", errors="replace") if exc.stdout else ""
+ return 124, out
+ except FileNotFoundError as exc:
+ return 127, str(exc)
+
+
+def compile_and_run_perry(
+ perry_bin: Path,
+ probe: Probe,
+ tmpdir: Path,
+ timeout: int,
+ base_env: dict[str, str],
+) -> tuple[str, int | None, str]:
+ binary = tmpdir / f"{probe.category}-{probe.name}"
+ env = dict(base_env)
+ env.setdefault("PERRY_ALLOW_UNIMPLEMENTED", "1")
+ env.setdefault("PERRY_NO_AUTO_OPTIMIZE", "1")
+ compile_exit, compile_out = run(
+ [str(perry_bin), str(probe.path), "-o", str(binary)],
+ cwd=REPO_ROOT,
+ env=env,
+ timeout=timeout,
+ )
+ if compile_exit != 0:
+ return "COMPILE-FAIL", compile_exit, normalize(compile_out)
+
+ run_exit, run_out = run([str(binary)], cwd=REPO_ROOT, env=env, timeout=timeout)
+ if run_exit != 0:
+ return "RUNTIME-FAIL", run_exit, normalize(run_out)
+ return "PASS", run_exit, normalize(run_out)
+
+
+def run_probe(
+ probe: Probe,
+ *,
+ node_cmd: str,
+ node_args: list[str],
+ perry_bin: Path,
+ tmpdir: Path,
+ timeout: int,
+ base_env: dict[str, str],
+) -> Result:
+ node_exit, node_out_raw = run(
+ [node_cmd, *node_args, str(probe.path)],
+ cwd=REPO_ROOT,
+ env=base_env,
+ timeout=timeout,
+ )
+ node_out = normalize(node_out_raw)
+ if node_exit != 0:
+ return Result(
+ probe=probe,
+ status="NODE-FAIL",
+ node_exit=node_exit,
+ perry_exit=None,
+ detail=f"Node exit {node_exit}: {first_line(node_out)}",
+ output=node_out,
+ )
+
+ perry_status, perry_exit, perry_out = compile_and_run_perry(
+ perry_bin, probe, tmpdir, timeout, base_env
+ )
+ if perry_status != "PASS":
+ return Result(
+ probe=probe,
+ status=perry_status,
+ node_exit=node_exit,
+ perry_exit=perry_exit,
+ detail=f"Perry exit {perry_exit}: {first_line(perry_out)}",
+ output=perry_out,
+ )
+
+ if perry_out != node_out:
+ detail = "stdout differs"
+ if first_line(node_out) != first_line(perry_out):
+ detail = f"Node `{first_line(node_out)}` vs Perry `{first_line(perry_out)}`"
+ return Result(
+ probe=probe,
+ status="DIFF",
+ node_exit=node_exit,
+ perry_exit=perry_exit,
+ detail=detail,
+ output=perry_out,
+ )
+
+ return Result(
+ probe=probe,
+ status="PASS",
+ node_exit=node_exit,
+ perry_exit=perry_exit,
+ detail=first_line(node_out),
+ output=node_out,
+ )
+
+
+def md_escape(value: str) -> str:
+ return value.replace("\\", "\\\\").replace("|", "\\|").replace("\n", "
")
+
+
+def render_markdown(results: list[Result], config: Path) -> str:
+ total = len(results)
+ passing = sum(1 for result in results if result.status == "PASS")
+ categories = sorted({result.probe.category for result in results})
+
+ lines = [
+ "# TypeScript Feature Matrix",
+ "",
+ "Generated by `scripts/gen_feature_matrix.py` from "
+ f"`{config.relative_to(REPO_ROOT)}`.",
+ "",
+ "This is a compatibility radar, not a feature gate. A non-PASS row "
+ "means the current Perry output differs from the Node oracle for that "
+ "probe; CI fails only when this committed matrix is stale.",
+ "",
+ f"Summary: {passing}/{total} probes pass across {len(categories)} categories.",
+ "",
+ "| category | probe | status | detail |",
+ "| --- | --- | --- | --- |",
+ ]
+ for result in sorted(results, key=lambda item: (item.probe.category, item.probe.name)):
+ lines.append(
+ "| "
+ + " | ".join(
+ [
+ md_escape(result.probe.category),
+ md_escape(result.probe.name),
+ result.status,
+ md_escape(result.detail),
+ ]
+ )
+ + " |"
+ )
+
+ lines.extend(["", "## Categories", ""])
+ lines.append("| category | pass | total |")
+ lines.append("| --- | ---: | ---: |")
+ for category in categories:
+ subset = [result for result in results if result.probe.category == category]
+ category_pass = sum(1 for result in subset if result.status == "PASS")
+ lines.append(f"| {md_escape(category)} | {category_pass} | {len(subset)} |")
+
+ lines.append("")
+ return "\n".join(lines)
+
+
+def write_report(results: list[Result], path: Path, config: Path) -> None:
+ by_status: dict[str, int] = {}
+ for result in results:
+ by_status[result.status] = by_status.get(result.status, 0) + 1
+ payload = {
+ "config": str(config.relative_to(REPO_ROOT)),
+ "summary": {
+ "probes": len(results),
+ "passing": by_status.get("PASS", 0),
+ "by_status": by_status,
+ },
+ "probes": [
+ {
+ "category": result.probe.category,
+ "name": result.probe.name,
+ "path": str(result.probe.path.relative_to(REPO_ROOT)),
+ "description": result.probe.description,
+ "status": result.status,
+ "node_exit": result.node_exit,
+ "perry_exit": result.perry_exit,
+ "detail": result.detail,
+ }
+ for result in sorted(results, key=lambda item: (item.probe.category, item.probe.name))
+ ],
+ }
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+
+
+def check_or_write(
+ markdown: str,
+ output: Path,
+ *,
+ check: bool,
+ generated_output: Path | None,
+) -> int:
+ if generated_output is not None:
+ generated_output.parent.mkdir(parents=True, exist_ok=True)
+ generated_output.write_text(markdown, encoding="utf-8")
+
+ if not check:
+ output.parent.mkdir(parents=True, exist_ok=True)
+ output.write_text(markdown, encoding="utf-8")
+ return 0
+
+ existing = output.read_text(encoding="utf-8") if output.exists() else ""
+ if existing == markdown:
+ print(f"{output.relative_to(REPO_ROOT)} is up to date")
+ return 0
+
+ diff = difflib.unified_diff(
+ existing.splitlines(keepends=True),
+ markdown.splitlines(keepends=True),
+ fromfile=f"{output.relative_to(REPO_ROOT)} (committed)",
+ tofile=f"{output.relative_to(REPO_ROOT)} (generated)",
+ )
+ sys.stderr.writelines(diff)
+ return 1
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
+ parser.add_argument("--output", type=Path, default=DEFAULT_MARKDOWN)
+ parser.add_argument("--generated-output", type=Path, default=None)
+ parser.add_argument("--report", type=Path, default=None)
+ parser.add_argument("--perry-bin", type=Path, default=DEFAULT_PERRY)
+ parser.add_argument("--node-cmd", "--node-bin", dest="node_cmd", default="node")
+ parser.add_argument("--timeout", type=int, default=30)
+ parser.add_argument("--check", action="store_true")
+ args = parser.parse_args(argv)
+
+ config = args.config.resolve()
+ output = args.output.resolve()
+ perry_bin = args.perry_bin.resolve()
+ if not perry_bin.exists():
+ print(f"error: Perry binary not found: {perry_bin}", file=sys.stderr)
+ return 2
+
+ node_args, probes = read_config(config)
+ env = os.environ.copy()
+ env.update({
+ "FORCE_COLOR": "0",
+ "NO_COLOR": "1",
+ "NODE_DISABLE_COLORS": "1",
+ })
+
+ with tempfile.TemporaryDirectory(prefix="perry-feature-matrix-") as raw_tmp:
+ tmpdir = Path(raw_tmp)
+ results = [
+ run_probe(
+ probe,
+ node_cmd=args.node_cmd,
+ node_args=node_args,
+ perry_bin=perry_bin,
+ tmpdir=tmpdir,
+ timeout=args.timeout,
+ base_env=env,
+ )
+ for probe in probes
+ ]
+
+ markdown = render_markdown(results, config)
+ if args.report is not None:
+ write_report(results, args.report.resolve(), config)
+ return check_or_write(
+ markdown,
+ output,
+ check=args.check,
+ generated_output=args.generated_output.resolve() if args.generated_output else None,
+ )
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/test-features/README.md b/test-features/README.md
new file mode 100644
index 0000000000..f79121deee
--- /dev/null
+++ b/test-features/README.md
@@ -0,0 +1,22 @@
+# TypeScript Feature Matrix
+
+This directory contains a small compatibility radar for TypeScript and modern
+JavaScript language features. Each probe is a standalone `.ts` file that Node
+runs with `--experimental-strip-types`; Perry compiles the same file to a
+native binary and the generator records whether stdout matches.
+
+Regenerate the committed matrix after adding probes or intentionally changing
+language behavior:
+
+```bash
+python3 scripts/gen_feature_matrix.py --perry-bin target/release/perry
+```
+
+Check that the committed matrix is current:
+
+```bash
+python3 scripts/gen_feature_matrix.py --check --perry-bin target/release/perry
+```
+
+Current failures are allowed in `feature_matrix.md`; the CI check only detects
+drift between the probes and the committed baseline.
diff --git a/test-features/feature_matrix.md b/test-features/feature_matrix.md
new file mode 100644
index 0000000000..6c1b4d91ab
--- /dev/null
+++ b/test-features/feature_matrix.md
@@ -0,0 +1,51 @@
+# TypeScript Feature Matrix
+
+Generated by `scripts/gen_feature_matrix.py` from `test-features/feature_matrix.toml`.
+
+This is a compatibility radar, not a feature gate. A non-PASS row means the current Perry output differs from the Node oracle for that probe; CI fails only when this committed matrix is stale.
+
+Summary: 19/22 probes pass across 14 categories.
+
+| category | probe | status | detail |
+| --- | --- | --- | --- |
+| async | await-microtask | PASS | async:start>after |
+| async | await-order | PASS | async/await-order:a:4\|b:6 |
+| classes | inheritance | PASS | class:hi perry! |
+| classes | mixin-expression | PASS | classes/mixin-expression:tag:base |
+| classes | private-static-fields | DIFF | Node `classes/private-static-fields:2,2` vs Perry `classes/private-static-fields:NaN,NaN` |
+| closures | basic | PASS | closure:15:22 |
+| closures | captured-mutation | PASS | closures/captured-mutation:6,12 |
+| collections | map-set | PASS | map-set:2:2,4 |
+| dynamic-import | relative-module | PASS | dynamic-import:dynamic:3 |
+| errors | try-catch-finally | PASS | error:boom |
+| generators | yield-delegation | PASS | generators/yield-delegation:1,4 |
+| generators | yield-star | PASS | generator:1,2,3 |
+| json | nested-destructure | PASS | json:3 |
+| mixins | generic-helper | PASS | mixin:item:tag |
+| modules | type-only-imports | PASS | modules/type-only-imports:12 |
+| modules | type-only-model | PASS | type-import:user:Ada |
+| primitives | bigint-symbol | PASS | bigint-symbol:Symbol(perry):32 |
+| proxy-reflect | construct-trap | DIFF | Node `proxy:x:proxy` vs Perry `proxy:x` |
+| syntax | destructuring-defaults | PASS | syntax/destructuring-defaults:a:2,n2:0 |
+| syntax | optional-call | PASS | optional:0:7:skip |
+| syntax | optional-nullish | PASS | syntax/optional-nullish:fallback,ok |
+| typed-arrays | uint8array-basic | DIFF | Node `typed-array:3:1,9,3:1` vs Perry `typed-array:3:undefined:undefined` |
+
+## Categories
+
+| category | pass | total |
+| --- | ---: | ---: |
+| async | 2 | 2 |
+| classes | 2 | 3 |
+| closures | 2 | 2 |
+| collections | 1 | 1 |
+| dynamic-import | 1 | 1 |
+| errors | 1 | 1 |
+| generators | 2 | 2 |
+| json | 1 | 1 |
+| mixins | 1 | 1 |
+| modules | 2 | 2 |
+| primitives | 1 | 1 |
+| proxy-reflect | 0 | 1 |
+| syntax | 3 | 3 |
+| typed-arrays | 0 | 1 |
diff --git a/test-features/feature_matrix.toml b/test-features/feature_matrix.toml
new file mode 100644
index 0000000000..b471ddcab6
--- /dev/null
+++ b/test-features/feature_matrix.toml
@@ -0,0 +1,134 @@
+[settings]
+node_args = ["--experimental-strip-types"]
+
+[[probe]]
+category = "closures"
+name = "captured-mutation"
+path = "probes/closures/captured-mutation.ts"
+description = "Closures preserve mutable captured bindings across calls."
+
+[[probe]]
+category = "closures"
+name = "basic"
+path = "probes/closures/basic.ts"
+description = "A returned closure reads and updates its captured lexical state."
+
+[[probe]]
+category = "async"
+name = "await-order"
+path = "probes/async/await-order.ts"
+description = "Async functions resume after awaited promises in source order."
+
+[[probe]]
+category = "async"
+name = "await-microtask"
+path = "probes/async/await_order.ts"
+description = "Awaited microtasks resume and settle the outer promise."
+
+[[probe]]
+category = "generators"
+name = "yield-delegation"
+path = "probes/generators/yield-delegation.ts"
+description = "yield* forwards yielded values and receives the delegate return."
+
+[[probe]]
+category = "generators"
+name = "yield-star"
+path = "probes/generators/yield_star.ts"
+description = "yield* emits delegate values and then observes the delegate return value."
+
+[[probe]]
+category = "classes"
+name = "private-static-fields"
+path = "probes/classes/private-static-fields.ts"
+description = "Private instance fields and private static fields retain brand state."
+
+[[probe]]
+category = "classes"
+name = "inheritance"
+path = "probes/classes/inheritance.ts"
+description = "Subclass overrides and super calls preserve method dispatch."
+
+[[probe]]
+category = "classes"
+name = "mixin-expression"
+path = "probes/classes/mixin-expression.ts"
+description = "Class expressions returned from mixin helpers preserve inheritance."
+
+[[probe]]
+category = "mixins"
+name = "generic-helper"
+path = "probes/mixins/basic.ts"
+description = "Generic TypeScript mixin helpers erase types and keep runtime members."
+
+[[probe]]
+category = "modules"
+name = "type-only-imports"
+path = "probes/modules/type-only-imports.ts"
+description = "Type-only imports erase without shadowing value imports."
+
+[[probe]]
+category = "modules"
+name = "type-only-model"
+path = "probes/type_only_imports/basic.ts"
+description = "A type-only import and value import from the same module remain distinct."
+
+[[probe]]
+category = "syntax"
+name = "optional-nullish"
+path = "probes/syntax/optional-nullish.ts"
+description = "Optional chaining and nullish coalescing preserve short-circuit values."
+
+[[probe]]
+category = "syntax"
+name = "optional-call"
+path = "probes/optional_chaining/nullish.ts"
+description = "Optional property, nullish, and optional call forms short-circuit."
+
+[[probe]]
+category = "syntax"
+name = "destructuring-defaults"
+path = "probes/syntax/destructuring-defaults.ts"
+description = "Destructuring defaults and rest values match Node output."
+
+[[probe]]
+category = "json"
+name = "nested-destructure"
+path = "probes/json/destructure.ts"
+description = "JSON.parse output participates in nested object destructuring."
+
+[[probe]]
+category = "collections"
+name = "map-set"
+path = "probes/map_set/basic.ts"
+description = "Map and Set construction, iteration, and spreading produce stable output."
+
+[[probe]]
+category = "typed-arrays"
+name = "uint8array-basic"
+path = "probes/typed_arrays/basic.ts"
+description = "TypedArray element writes, length, join, and BYTES_PER_ELEMENT match Node."
+
+[[probe]]
+category = "primitives"
+name = "bigint-symbol"
+path = "probes/bigint_symbols/basic.ts"
+description = "BigInt arithmetic and global Symbol registry reads are stable."
+
+[[probe]]
+category = "errors"
+name = "try-catch-finally"
+path = "probes/error_handling/basic.ts"
+description = "try/catch/finally preserves thrown Error values."
+
+[[probe]]
+category = "proxy-reflect"
+name = "construct-trap"
+path = "probes/proxy_reflect/construct.ts"
+description = "Proxy construct traps can delegate through Reflect.construct."
+
+[[probe]]
+category = "dynamic-import"
+name = "relative-module"
+path = "probes/dynamic_import/basic.ts"
+description = "Dynamic import resolves a relative TypeScript module."
diff --git a/test-features/probes/async/await-order.ts b/test-features/probes/async/await-order.ts
new file mode 100644
index 0000000000..691406ebcd
--- /dev/null
+++ b/test-features/probes/async/await-order.ts
@@ -0,0 +1,9 @@
+async function task(label: string, value: number): Promise {
+ const doubled = await Promise.resolve(value * 2);
+ return label + ":" + doubled;
+}
+
+(async () => {
+ const out = await Promise.all([task("a", 2), task("b", 3)]);
+ console.log("async/await-order:" + out.join("|"));
+})();
diff --git a/test-features/probes/async/await_order.ts b/test-features/probes/async/await_order.ts
new file mode 100644
index 0000000000..fb2ede5b23
--- /dev/null
+++ b/test-features/probes/async/await_order.ts
@@ -0,0 +1,10 @@
+async function run() {
+ const order = ["start"];
+ await Promise.resolve();
+ order.push("after");
+ return order.join(">");
+}
+
+run().then((value) => {
+ console.log(`async:${value}`);
+});
diff --git a/test-features/probes/bigint_symbols/basic.ts b/test-features/probes/bigint_symbols/basic.ts
new file mode 100644
index 0000000000..2d5e8b3300
--- /dev/null
+++ b/test-features/probes/bigint_symbols/basic.ts
@@ -0,0 +1,4 @@
+const key = Symbol.for("perry");
+const value = 2n ** 5n;
+
+console.log(`bigint-symbol:${String(key)}:${value}`);
diff --git a/test-features/probes/classes/inheritance.ts b/test-features/probes/classes/inheritance.ts
new file mode 100644
index 0000000000..b254e9373b
--- /dev/null
+++ b/test-features/probes/classes/inheritance.ts
@@ -0,0 +1,19 @@
+class Base {
+ name: string;
+
+ constructor(name: string) {
+ this.name = name;
+ }
+
+ greet() {
+ return `hi ${this.name}`;
+ }
+}
+
+class Child extends Base {
+ greet() {
+ return `${super.greet()}!`;
+ }
+}
+
+console.log(`class:${new Child("perry").greet()}`);
diff --git a/test-features/probes/classes/mixin-expression.ts b/test-features/probes/classes/mixin-expression.ts
new file mode 100644
index 0000000000..80c526b884
--- /dev/null
+++ b/test-features/probes/classes/mixin-expression.ts
@@ -0,0 +1,14 @@
+class Base {
+ value = "base";
+}
+
+function Tagged(BaseClass: any) {
+ return class extends BaseClass {
+ tag() {
+ return "tag:" + this.value;
+ }
+ };
+}
+
+const Mixed = Tagged(Base);
+console.log("classes/mixin-expression:" + new Mixed().tag());
diff --git a/test-features/probes/closures/basic.ts b/test-features/probes/closures/basic.ts
new file mode 100644
index 0000000000..0cb79f6161
--- /dev/null
+++ b/test-features/probes/closures/basic.ts
@@ -0,0 +1,10 @@
+function makeAdder(seed: number) {
+ let total = seed;
+ return (value: number) => {
+ total += value;
+ return total;
+ };
+}
+
+const add = makeAdder(10);
+console.log(`closure:${add(5)}:${add(7)}`);
diff --git a/test-features/probes/closures/captured-mutation.ts b/test-features/probes/closures/captured-mutation.ts
new file mode 100644
index 0000000000..93ec20b98a
--- /dev/null
+++ b/test-features/probes/closures/captured-mutation.ts
@@ -0,0 +1,12 @@
+let seed: number = 1;
+
+function makeAdder(delta: number) {
+ let total = seed;
+ return (next: number) => {
+ total += delta + next;
+ return total;
+ };
+}
+
+const add = makeAdder(2);
+console.log("closures/captured-mutation:" + [add(3), add(4)].join(","));
diff --git a/test-features/probes/dynamic_import/basic.ts b/test-features/probes/dynamic_import/basic.ts
new file mode 100644
index 0000000000..62a8f76d10
--- /dev/null
+++ b/test-features/probes/dynamic_import/basic.ts
@@ -0,0 +1,8 @@
+import("./mod.ts")
+ .then((mod) => {
+ console.log(`dynamic-import:${mod.label(3)}`);
+ })
+ .catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+ });
diff --git a/test-features/probes/dynamic_import/mod.ts b/test-features/probes/dynamic_import/mod.ts
new file mode 100644
index 0000000000..3dad688aaf
--- /dev/null
+++ b/test-features/probes/dynamic_import/mod.ts
@@ -0,0 +1,5 @@
+export const value = "dynamic";
+
+export function label(count: number) {
+ return `${value}:${count}`;
+}
diff --git a/test-features/probes/error_handling/basic.ts b/test-features/probes/error_handling/basic.ts
new file mode 100644
index 0000000000..88a08c9141
--- /dev/null
+++ b/test-features/probes/error_handling/basic.ts
@@ -0,0 +1,11 @@
+function message() {
+ try {
+ throw new Error("boom");
+ } catch (error) {
+ return (error as Error).message;
+ } finally {
+ // Exercise finally without changing the deterministic value.
+ }
+}
+
+console.log(`error:${message()}`);
diff --git a/test-features/probes/generators/yield-delegation.ts b/test-features/probes/generators/yield-delegation.ts
new file mode 100644
index 0000000000..28017605ad
--- /dev/null
+++ b/test-features/probes/generators/yield-delegation.ts
@@ -0,0 +1,11 @@
+function* inner(): Generator {
+ yield 1;
+ return 3;
+}
+
+function* outer(): Generator {
+ const tail = yield* inner();
+ yield tail + 1;
+}
+
+console.log("generators/yield-delegation:" + Array.from(outer()).join(","));
diff --git a/test-features/probes/generators/yield_star.ts b/test-features/probes/generators/yield_star.ts
new file mode 100644
index 0000000000..7d3ba69738
--- /dev/null
+++ b/test-features/probes/generators/yield_star.ts
@@ -0,0 +1,12 @@
+function* inner() {
+ yield 1;
+ yield 2;
+ return 3;
+}
+
+function* outer() {
+ const result = yield* inner();
+ yield result;
+}
+
+console.log(`generator:${Array.from(outer()).join(",")}`);
diff --git a/test-features/probes/json/destructure.ts b/test-features/probes/json/destructure.ts
new file mode 100644
index 0000000000..8fbb6d55ee
--- /dev/null
+++ b/test-features/probes/json/destructure.ts
@@ -0,0 +1,3 @@
+const { a, nested: { b } } = JSON.parse('{"a":1,"nested":{"b":2}}');
+
+console.log(`json:${a + b}`);
diff --git a/test-features/probes/map_set/basic.ts b/test-features/probes/map_set/basic.ts
new file mode 100644
index 0000000000..5c2e0ad238
--- /dev/null
+++ b/test-features/probes/map_set/basic.ts
@@ -0,0 +1,7 @@
+const values = new Map([
+ ["a", 1],
+ ["b", 2],
+]);
+const doubled = new Set([...values.values()].map((value) => value * 2));
+
+console.log(`map-set:${values.get("b")}:${Array.from(doubled).join(",")}`);
diff --git a/test-features/probes/mixins/basic.ts b/test-features/probes/mixins/basic.ts
new file mode 100644
index 0000000000..d8db9ce549
--- /dev/null
+++ b/test-features/probes/mixins/basic.ts
@@ -0,0 +1,19 @@
+type Constructor = new (...args: any[]) => T;
+
+function Tagged(Base: TBase) {
+ return class extends Base {
+ tag() {
+ return "tag";
+ }
+ };
+}
+
+class Item {
+ name() {
+ return "item";
+ }
+}
+
+const Mixed = Tagged(Item);
+const value = new Mixed();
+console.log(`mixin:${value.name()}:${value.tag()}`);
diff --git a/test-features/probes/modules/support/type-only-values.ts b/test-features/probes/modules/support/type-only-values.ts
new file mode 100644
index 0000000000..12ebffce43
--- /dev/null
+++ b/test-features/probes/modules/support/type-only-values.ts
@@ -0,0 +1,10 @@
+export type MatrixPayload = {
+ label: string;
+ value: number;
+};
+
+export const matrixValue = 7;
+
+export function render(payload: MatrixPayload): string {
+ return payload.label + ":" + (payload.value + matrixValue);
+}
diff --git a/test-features/probes/modules/type-only-imports.ts b/test-features/probes/modules/type-only-imports.ts
new file mode 100644
index 0000000000..61ec42da76
--- /dev/null
+++ b/test-features/probes/modules/type-only-imports.ts
@@ -0,0 +1,5 @@
+import type { MatrixPayload } from "./support/type-only-values.ts";
+import { render } from "./support/type-only-values.ts";
+
+const payload: MatrixPayload = { label: "modules/type-only-imports", value: 5 };
+console.log(render(payload));
diff --git a/test-features/probes/optional_chaining/nullish.ts b/test-features/probes/optional_chaining/nullish.ts
new file mode 100644
index 0000000000..250e4af3a7
--- /dev/null
+++ b/test-features/probes/optional_chaining/nullish.ts
@@ -0,0 +1,10 @@
+const obj: any = {
+ nested: { value: 0 },
+ fn: undefined,
+};
+
+const present = obj.nested?.value ?? 7;
+const missing = obj.missing?.value ?? 7;
+const call = obj.fn?.("x") ?? "skip";
+
+console.log(`optional:${present}:${missing}:${call}`);
diff --git a/test-features/probes/proxy_reflect/construct.ts b/test-features/probes/proxy_reflect/construct.ts
new file mode 100644
index 0000000000..ac3f69a0d3
--- /dev/null
+++ b/test-features/probes/proxy_reflect/construct.ts
@@ -0,0 +1,17 @@
+class Thing {
+ value: string;
+
+ constructor(value: string) {
+ this.value = value;
+ }
+}
+
+const Wrapped = new Proxy(Thing, {
+ construct(target, args, newTarget) {
+ const instance = Reflect.construct(target, args, newTarget);
+ instance.value = `${instance.value}:proxy`;
+ return instance;
+ },
+});
+
+console.log(`proxy:${new (Wrapped as any)("x").value}`);
diff --git a/test-features/probes/syntax/destructuring-defaults.ts b/test-features/probes/syntax/destructuring-defaults.ts
new file mode 100644
index 0000000000..2c86158cce
--- /dev/null
+++ b/test-features/probes/syntax/destructuring-defaults.ts
@@ -0,0 +1,10 @@
+const input: Array<{ id: number; label?: string; rest?: number[] }> = [
+ { id: 1, label: "a", rest: [2, 3] },
+ { id: 2 },
+];
+
+const out = input.map(({ id, label = "n" + id, rest = [] }) => {
+ return label + ":" + rest.length;
+});
+
+console.log("syntax/destructuring-defaults:" + out.join(","));
diff --git a/test-features/probes/syntax/optional-nullish.ts b/test-features/probes/syntax/optional-nullish.ts
new file mode 100644
index 0000000000..3eee08ab3b
--- /dev/null
+++ b/test-features/probes/syntax/optional-nullish.ts
@@ -0,0 +1,11 @@
+type Nested = { child?: { value?: string } } | null;
+
+const missing: Nested = { child: {} };
+const present: Nested = { child: { value: "ok" } };
+
+const out = [
+ missing?.child?.value ?? "fallback",
+ present?.child?.value ?? "fallback",
+];
+
+console.log("syntax/optional-nullish:" + out.join(","));
diff --git a/test-features/probes/type_only_imports/basic.ts b/test-features/probes/type_only_imports/basic.ts
new file mode 100644
index 0000000000..c98d06a5e0
--- /dev/null
+++ b/test-features/probes/type_only_imports/basic.ts
@@ -0,0 +1,5 @@
+import type { User } from "./model.ts";
+import { prefix } from "./model.ts";
+
+const user: User = { name: "Ada" };
+console.log(`type-import:${prefix}:${user.name}`);
diff --git a/test-features/probes/type_only_imports/model.ts b/test-features/probes/type_only_imports/model.ts
new file mode 100644
index 0000000000..67a347ce57
--- /dev/null
+++ b/test-features/probes/type_only_imports/model.ts
@@ -0,0 +1,5 @@
+export type User = {
+ name: string;
+};
+
+export const prefix = "user";
diff --git a/test-features/probes/typed_arrays/basic.ts b/test-features/probes/typed_arrays/basic.ts
new file mode 100644
index 0000000000..c68d1d6c09
--- /dev/null
+++ b/test-features/probes/typed_arrays/basic.ts
@@ -0,0 +1,4 @@
+const bytes = new Uint8Array([1, 2, 3]);
+bytes[1] = 9;
+
+console.log(`typed-array:${bytes.length}:${bytes.join(",")}:${bytes.BYTES_PER_ELEMENT}`);