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}`);