TypeScript cores compile through the external compiler by default; the TS-to-Zig emitter is deleted (#271)

* feat: add a check-only frontend mode

- The @native-sdk/core CLI without -o checks the core (and writes the contract when asked) and emits nothing.
- native check runs the frontend in check-only mode; no scratch emission under .native/check.

* feat: TypeScript cores compile through the external core compiler by default

- The transpiled lane is gone: src/core.ts builds through the external core compiler with nothing stated, and core_compiler = "transpiler" is refused with a teaching naming the release that removed it.
- Mobile targets with a TypeScript core are taught before lane selection: TS cores are desktop-only until the external toolchain grows mobile targets; Zig/markup cores stay fully supported on mobile.

* feat: retarget the TypeScript-core suites to the compiled lane

- test-ts-core-e2e compiles every fixture core through the external core compiler in the build graph (no env gating; the compiler is a package dependency), with the markup battery in its own binary — one archive per process is the C-ABI contract.
- The paired/byte-compare machinery is gone (paired_core, gen_paired, extract.zig, test-contract-equivalence, test-compiled-core-parity); the conformance suite pins mirrors over frontend contracts and committed goldens, and the new test-external-core-abi suite holds the ABI laws over a real archive.
- The soundboard core-only dispatch budget is re-measured for the compiled lane (~5.3us on an M-class laptop, Debug; the C ABI crossing plus snapshot decode) and stays pinned at 1ms.

* feat: carry the mixed pair-return idiom on the compiled lane

- The contract sidecar gains additive init_returns_bare/update_returns_bare facts, and the generated facade narrows `Model | [Model, Cmd<Msg>]` returns (a tuple carries its command; a bare model the empty buffer).
- The scaffold starter bounds its counters with literal comparisons so the compiler's integer range proof takes them.

* feat: delete the TS-to-Zig emitter

- The transpiled lane's machinery is gone: emitter.ts (11.9k lines), the rt.zig kernel, the run1k gate, and the emitter/effects/run-fidelity suites; execution truth lives in the ts-core e2e batteries over real compiled archives.
- The frontend (transpile.ts -> frontend.ts) checks and emits the contract sidecar only; the CLI refuses -o with a teaching naming the release that removed the emitter.
- The conformance corpus and grammar matrices re-adjudicate: emitter-only gates (97 corpus cases, 7 matrix rows) are marked as accepted with their former emit-time rules kept readable.

* fix: ship the compiled lane in the npm CLI payload

- copy-framework mirrors packages/core/compile-surface + scripts and tools/corewire (the build compiles corewire from the dependency); the sync and files lists follow.
- scriptc rides as a regular dependency of @native-sdk/cli, pinned equal to packages/core by check-version-sync, and the build graph resolves its entry by node's ancestor walk from packages/core.

* ci: the compiled lane rides the package dependency

- Every ts-core-building job gets the compiler with the one npm ci in packages/core; the separate compiler install, the archive/sidecar env plumbing, and the opt-in example step are gone.
- The parity job becomes Core Compiler Fences: stage-core-contracts plus the determinism-fence negative control (the positive batteries ride zig build test in the Zig Core job).
- No compiler cache action on purpose: hosted runners are ephemeral, so runs stay hermetic by machine lifecycle.

* docs: the compiled lane is the documented truth

- The TypeScript docs, quick start, component pages, example READMEs, the scaffold templates, and the ts-core skill describe the check-and-compile pipeline; the eject-story and rt-kernel/frame-cap claims are gone, and the core dev loop is stated as restart-shaped with native dev --core for fast logic iteration.
- The evals grader checks cores with the frontend and grades ts harnesses against externally compiled archives through generated mirrors.
- The changelog fragment states the breaks deliberately: default lane switch, transpiled lane removed, mobile teaching, dev-loop latency, and the compiler dependency.

* fix: ownership.ts joins the frontend staleness set

- The checker, inference, and type layers import ownership.ts, so an edit there must re-run every cached check and contract step; the staleness array now carries it.

* chore: drop the unused TypeScript compat wrapper dependency (#274)

- Nothing imports the @typescript/typescript6 wrapper at run time — the frontend loads the exactly pinned @typescript/old alias directly — so the wrapper leaves both manifests and the lockfile.
- The version-sync check pins the alias on its own, the toolchain doctrine comments describe a stray consumer-tree wrapper (which resolution still ignores, as the twins' fixtures keep proving), and the prose pins follow the reworded doctrine.
This commit is contained in:
Chris Tate
2026-08-03 11:17:30 -05:00
committed by GitHub
parent 31c140e26f
commit d26428e11b
85 changed files with 1676 additions and 29401 deletions
+24 -46
View File
@@ -21,14 +21,19 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
# The TypeScript core suites transpile at build/test time; without
# node they skip silently, so CI must provide it.
# TypeScript cores compile through the external core compiler at
# build/test time; the compiler and the frontend's toolchain both
# arrive with this one install (without it the ts-core suites skip
# silently, so CI must provide it). No SCRIPTC_NO_CACHE and no
# cache action on purpose: hosted runners are ephemeral, so any
# per-run compiler cache dies with the VM and runs stay hermetic
# across commits by machine lifecycle.
- run: npm ci --prefix packages/core
- run: zig build test
- run: zig build validate
compiled-core-parity:
name: Compiled-Core Parity
core-compiler-fences:
name: Core Compiler Fences
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -38,45 +43,15 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
# The transpiled lane of every pairing runs the repo's own transpiler under node at build time; it needs its installed dependency.
# The exact-pinned external core compiler and the frontend's
# toolchain, one install (packages/core/package.json is the one
# place the pin lives).
- run: npm ci --prefix packages/core
# The external core compiler, at the release the profiles' determinism fence tables are pinned to. tests/compiled-core/core_compiler_pin is the ONE place the pin lives — build_core.sh refuses any other release, so a bump is a one-line change there and this step follows.
- name: Install the external core compiler
run: |
set -euo pipefail
pin="$(cat tests/compiled-core/core_compiler_pin)"
npm install --prefix .zig-cache/core-compiler "scriptc@${pin}"
compiler="$PWD/.zig-cache/core-compiler/node_modules/.bin/scriptc"
test "$("$compiler" -v)" = "$pin"
echo "NATIVE_SDK_CORE_COMPILER=$compiler" >> "$GITHUB_ENV"
# Per-fixture contract artifacts the external compile consumes: the effective sidecar plus its generated entry module and compiler profile, under zig-out/core-contracts.
# Per-fixture contract artifacts the fixture driver consumes: the effective sidecar plus its generated entry module and compiler profile, under zig-out/core-contracts.
- run: zig build stage-core-contracts
# Determinism-fence negative control: the pristine markup fixture compiles and its co-emitted sidecar attests deterministic: true, then one injected ambient read (Date.now() in update) must be refused by the profile's fences — proving the fences fire, not merely that clean cores pass under them.
# Determinism-fence negative control: the pristine markup fixture compiles and its co-emitted sidecar attests deterministic: true, then one injected ambient read (Date.now() in update) must be refused by the profile's fences — proving the fences fire, not merely that clean cores pass under them. The positive batteries (every fixture's e2e suite over its real archive) ride `zig build test` in the Zig Core job; this job holds the refusal half.
- name: Determinism fences fire (negative control)
run: tests/compiled-core/fence_check.sh .zig-cache/fence-check
- name: Build the five fixture cores
run: |
set -euo pipefail
for fixture in host-fixture soundboard system-monitor ai-chat markup; do
tests/compiled-core/build_core.sh "$fixture" ".zig-cache/compiled-cores/$fixture"
done
# Each fixture app's OWN e2e battery over a paired core — the transpiled lane vs the compiled archive, byte-compared at every seam. Locally this step is env-gated (no external compiler on a stock checkout); this job is where it always runs. Serial (-j1): the soundboard battery measures wall-clock dispatch budgets, and five test binaries racing on a two-core runner turn scheduler contention into failures the budgets were never meant to catch.
- name: Run the compiled-core parity battery
run: zig build test-compiled-core-parity -j1
env:
NATIVE_SDK_EXTERNAL_CORE_ARCHIVE_HOST: ${{ github.workspace }}/.zig-cache/compiled-cores/host-fixture/libhost_fixture_core.a
NATIVE_SDK_EXTERNAL_CORE_SIDECAR_HOST: ${{ github.workspace }}/.zig-cache/compiled-cores/host-fixture/core.contract.json
NATIVE_SDK_EXTERNAL_CORE_ARCHIVE_SOUNDBOARD: ${{ github.workspace }}/.zig-cache/compiled-cores/soundboard/libsoundboard_core.a
NATIVE_SDK_EXTERNAL_CORE_SIDECAR_SOUNDBOARD: ${{ github.workspace }}/.zig-cache/compiled-cores/soundboard/core.contract.json
NATIVE_SDK_EXTERNAL_CORE_ARCHIVE_SYSTEM_MONITOR: ${{ github.workspace }}/.zig-cache/compiled-cores/system-monitor/libsystem_monitor_core.a
NATIVE_SDK_EXTERNAL_CORE_SIDECAR_SYSTEM_MONITOR: ${{ github.workspace }}/.zig-cache/compiled-cores/system-monitor/core.contract.json
NATIVE_SDK_EXTERNAL_CORE_ARCHIVE_AI_CHAT: ${{ github.workspace }}/.zig-cache/compiled-cores/ai-chat/libai_chat_core.a
NATIVE_SDK_EXTERNAL_CORE_SIDECAR_AI_CHAT: ${{ github.workspace }}/.zig-cache/compiled-cores/ai-chat/core.contract.json
NATIVE_SDK_EXTERNAL_CORE_ARCHIVE_MARKUP: ${{ github.workspace }}/.zig-cache/compiled-cores/markup/libmarkup_core.a
NATIVE_SDK_EXTERNAL_CORE_SIDECAR_MARKUP: ${{ github.workspace }}/.zig-cache/compiled-cores/markup/core.contract.json
# One real example built and tested on the OPT-IN external core lane (-Dcore-compiler=external), through the same CLI verbs a user runs. Env-gated locally; this job is where it always runs.
- name: Build and test an example on the external core lane
run: zig build test-example-soundboard-ts-external
macos-webview:
name: macOS WebView
@@ -189,8 +164,9 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
# The TypeScript core suites transpile at build/test time; without
# node they skip silently, so CI must provide it.
# The TypeScript core suites compile through the external core
# compiler at build/test time; without the install they skip
# silently, so CI must provide it.
- run: npm ci --prefix packages/core
- run: zig build test-tooling
@@ -228,8 +204,9 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
# The TypeScript examples transpile at build time; the transpiler
# needs its installed dependency.
# The TypeScript examples compile through the external core
# compiler at build time; the compiler and the frontend toolchain
# arrive with this install.
- run: npm ci --prefix packages/core
# Every example test uses the null backend, so this lane needs no
# GTK/WebKitGTK packages. The root build owns the round-robin shard
@@ -361,8 +338,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
# The scaffold default is the TypeScript core; its transpiler runs
# under node at build time from this checkout's packages/core.
# The scaffold default is the TypeScript core; its frontend and
# compiler run at build time from this checkout's packages/core.
- run: npm ci --prefix packages/core
# No WebKitGTK dev package, same as linux-canvas-smoke: the scaffold
# declares no web use, so its host compiles with the stub seam.
@@ -467,7 +444,8 @@ jobs:
with:
node-version: 22
# The default scaffold is a TypeScript core: its build runs the
# @native-sdk/core transpiler from this checkout's own install.
# @native-sdk/core frontend and the external core compiler from
# this checkout's own install.
- run: npm ci --prefix packages/core
- run: zig build
- name: Scaffold and test the zero-config native app
+277 -427
View File
@@ -199,28 +199,26 @@ pub fn build(b: *std.Build) void {
// corewire, the contract-sidecar shim generator (tools/corewire):
// std-only unit suites, one test root per source file (tests live
// in the file they cover, and imported files' tests do not run
// under an importer's root). The conformance suite — both lanes per
// ts-core fixture, compared by layout fingerprint and
// model-contract artifact — rides the ts-core e2e block, since it
// needs node and the transpiler toolchain.
// under an importer's root). The conformance suite — every fixture's
// generated mirror validated over its frontend-emitted contract —
// rides the ts-core e2e block, since it needs node and the frontend
// toolchain.
const corewire_sidecar_tests = testArtifact(b, module(b, target, optimize, "tools/corewire/sidecar.zig"));
const corewire_emit_tests = testArtifact(b, module(b, target, optimize, "tools/corewire/emit.zig"));
const corewire_facade_tests = testArtifact(b, module(b, target, optimize, "tools/corewire/emit_facade.zig"));
const corewire_profile_tests = testArtifact(b, module(b, target, optimize, "tools/corewire/emit_profile.zig"));
const corewire_extract_tests = testArtifact(b, module(b, target, optimize, "tools/corewire/extract.zig"));
const corewire_shim_rt_tests = testArtifact(b, module(b, target, optimize, "tools/corewire/shim_rt.zig"));
// The paired-core root generator (tests/compiled-core): its emitted
// surface rules are pinned here so the env-gated batteries cannot
// drift silently between supplied-archive runs.
const gen_paired_tests = testArtifact(b, module(b, target, optimize, "tests/compiled-core/gen_paired.zig"));
// Transpiled-core end-to-end suite: tests/ts-core/fixture.ts is
// emitted by the repo's own transpiler AT BUILD TIME (never a
// committed Zig snapshot) and driven through the real runtime via
// `TsCoreHost`. Gated on node plus the transpiler package's
// installed dependency: absent either, the suite is skipped (the
// bridge itself stays covered by src/runtime/ts_core_host_tests.zig
// against a hand-written emitted-ABI core).
// TypeScript-core end-to-end suite: each fixture core is compiled
// through the external core compiler AT BUILD TIME (never a
// committed archive) and its e2e battery drives the linked archive
// through the real runtime via `TsCoreHost` over the generated
// mirror. Gated on node plus the package's installed dependencies
// (`npm ci` in packages/core — the compiler and the frontend
// toolchain arrive together): absent either, the suite is skipped
// (the bridge itself stays covered by
// src/runtime/ts_core_host_tests.zig against a hand-written
// core-ABI module).
const ts_core_e2e_tests = tsCoreE2eArtifact(b, target, optimize, desktop_mod, tooling_mod);
const ui_markup_mod = module(b, target, optimize, "src/primitives/canvas/ui_markup.zig");
@@ -481,12 +479,11 @@ pub fn build(b: *std.Build) void {
test_step.dependOn(&b.addRunArtifact(corewire_emit_tests).step);
test_step.dependOn(&b.addRunArtifact(corewire_facade_tests).step);
test_step.dependOn(&b.addRunArtifact(corewire_profile_tests).step);
test_step.dependOn(&b.addRunArtifact(corewire_extract_tests).step);
test_step.dependOn(&b.addRunArtifact(corewire_shim_rt_tests).step);
test_step.dependOn(&b.addRunArtifact(gen_paired_tests).step);
if (ts_core_e2e_tests) |ts_core_artifacts| {
const ts_core_e2e_step = b.step("test-ts-core-e2e", "Run the transpiled-core end-to-end suites (requires node)");
const ts_core_e2e_step = b.step("test-ts-core-e2e", "Run the TypeScript-core end-to-end suites over externally compiled fixture cores (requires node and `npm ci` in packages/core)");
const host_e2e_run = b.addRunArtifact(ts_core_artifacts.host);
const markup_e2e_run = b.addRunArtifact(ts_core_artifacts.markup);
const soundboard_e2e_run = b.addRunArtifact(ts_core_artifacts.soundboard);
const monitor_e2e_run = b.addRunArtifact(ts_core_artifacts.system_monitor);
const scaffold_ide_e2e_run = b.addRunArtifact(ts_core_artifacts.scaffold_ide);
@@ -495,34 +492,15 @@ pub fn build(b: *std.Build) void {
scaffold_ide_e2e_run.has_side_effects = true;
const ai_chat_e2e_run = b.addRunArtifact(ts_core_artifacts.ai_chat);
const sidecar_conformance_run = b.addRunArtifact(ts_core_artifacts.sidecar_conformance);
const sidecar_conformance_step = b.step("sidecar-conformance", "Prove corewire-generated mirrors fingerprint-identical to transpiler output (requires node)");
const sidecar_conformance_step = b.step("sidecar-conformance", "Validate corewire-generated mirrors over every fixture's frontend-emitted contract (requires node)");
sidecar_conformance_step.dependOn(&sidecar_conformance_run.step);
// Skipped unless the caller supplies a compiled-core archive:
// the repo builds none, so the step gates on the env var and
// `zig build test` stays green without it.
const parity_step = b.step("test-external-core-parity", "Run the compiled-core behavior-parity suite (requires node and NATIVE_SDK_EXTERNAL_CORE_ARCHIVE=<link input[" ++ [_]u8{std.fs.path.delimiter} ++ "link input...]>; skipped when unset)");
if (ts_core_artifacts.external_core_parity) |parity_tests| {
const parity_run = b.addRunArtifact(parity_tests);
parity_step.dependOn(&parity_run.step);
test_step.dependOn(&parity_run.step);
}
// The full-corpus twin: each fixture app's e2e battery over a
// paired core, gated per fixture on its archive/sidecar env
// pair; with none supplied the step is a clean no-op and
// `zig build test` is untouched.
const compiled_parity_step = b.step("test-compiled-core-parity", "Run each fixture app's e2e battery over a paired core — transpiled lane vs a caller-supplied compiled-core archive (requires node and NATIVE_SDK_EXTERNAL_CORE_ARCHIVE_<FIXTURE> + NATIVE_SDK_EXTERNAL_CORE_SIDECAR_<FIXTURE>; fixtures without both are skipped)");
for (ts_core_artifacts.compiled_core_parity) |battery| {
const battery_run = b.addRunArtifact(battery.tests);
compiled_parity_step.dependOn(&battery_run.step);
test_step.dependOn(&battery_run.step);
}
// The contract-equivalence pin: the frontend-emitted sidecar
// byte-identical to the extraction-path document, per fixture.
const equivalence_step = b.step("test-contract-equivalence", "Hold the frontend-emitted contract sidecar byte-identical to the extraction-path document, per ts-core fixture (requires node)");
for (ts_core_artifacts.contract_equivalence) |diff| {
equivalence_step.dependOn(&diff.step);
test_step.dependOn(&diff.step);
}
// ABI-law suite over a real compiled core: the markup fixture's
// archive driven directly through the C ABI (collect invariant,
// deterministic re-init, channel envelopes, integer classes).
const abi_laws_run = b.addRunArtifact(ts_core_artifacts.external_core_abi_laws);
const abi_laws_step = b.step("test-external-core-abi", "Run the compiled-core ABI-law suite over the markup fixture's archive (requires node and `npm ci` in packages/core)");
abi_laws_step.dependOn(&abi_laws_run.step);
test_step.dependOn(&abi_laws_run.step);
// The corpus contract artifacts an external core toolchain
// consumes: per fixture, the frontend-emitted contract sidecar
// (after projection), the generated entry module, and the
@@ -535,11 +513,13 @@ pub fn build(b: *std.Build) void {
contracts_step.dependOn(&b.addInstallFileWithDir(contract.profile, dir, "core_profile.json").step);
}
ts_core_e2e_step.dependOn(&host_e2e_run.step);
ts_core_e2e_step.dependOn(&markup_e2e_run.step);
ts_core_e2e_step.dependOn(&soundboard_e2e_run.step);
ts_core_e2e_step.dependOn(&monitor_e2e_run.step);
ts_core_e2e_step.dependOn(&scaffold_ide_e2e_run.step);
ts_core_e2e_step.dependOn(&ai_chat_e2e_run.step);
test_step.dependOn(&host_e2e_run.step);
test_step.dependOn(&markup_e2e_run.step);
test_step.dependOn(&soundboard_e2e_run.step);
test_step.dependOn(&monitor_e2e_run.step);
test_step.dependOn(&scaffold_ide_e2e_run.step);
@@ -577,7 +557,7 @@ pub fn build(b: *std.Build) void {
.{ .path = "packages/native-sdk/native-sdk.d.ts", .pattern = "export type NativeSdkGpuSurfaceBackendRequest = \"metal\" | \"software\";" },
.{ .path = "packages/native-sdk/native-sdk.d.ts", .pattern = "gpuBackend?: NativeSdkGpuSurfaceBackendRequest;" },
});
addFileContainsCheckStep(b, file_contains_checker, test_step, "test-ts-toolchain-twins", "Verify the CLI's toolchain-resolution gate and its direct-`zig build` twin stay in lockstep (both resolve the aliased real compiler @typescript/old from packages/core — the same origin runtime imports it from — hold its resolved version against the manifest-read pin, never probe the unused @typescript/typescript6 wrapper, and teach instead of panicking)", &.{
addFileContainsCheckStep(b, file_contains_checker, test_step, "test-ts-toolchain-twins", "Verify the CLI's toolchain-resolution gate and its direct-`zig build` twin stay in lockstep (both resolve the aliased real compiler @typescript/old from packages/core — the same origin runtime imports it from — hold its resolved version against the manifest-read pin, never probe a stray compat wrapper, and teach instead of panicking)", &.{
// The resolution twins probe the aliased REAL compiler
// (@typescript/old — the package typed_ast.ts and ts_run.mjs
// actually load) — manifest AND entrypoint, in lockstep. The
@@ -592,9 +572,9 @@ pub fn build(b: *std.Build) void {
// only what runtime loads, from runtime's own walk origin, and
// leave the declared-but-unimported wrapper out of the verdict.
.{ .path = "src/tooling/ts_core.zig", .pattern = "Validation tracks ONLY what runtime loads" },
.{ .path = "src/tooling/ts_core.zig", .pattern = "wrapper is deliberately NOT probed" },
.{ .path = "src/tooling/ts_core.zig", .pattern = "deliberately NOT probed" },
.{ .path = "build/app.zig", .pattern = "Validation tracks ONLY what runtime loads" },
.{ .path = "build/app.zig", .pattern = "wrapper is deliberately NOT probed" },
.{ .path = "build/app.zig", .pattern = "deliberately NOT probed" },
// The reciprocal cross-references that keep the twins findable
// from each other.
.{ .path = "src/tooling/ts_core.zig", .pattern = "build/app.zig's tsToolchainResolution" },
@@ -1458,36 +1438,6 @@ pub fn build(b: *std.Build) void {
.{ .path = "examples/capabilities/src/main.zig", .pattern = "native-sdk:drop:files" },
});
// The external-core example pin: one real managed example built AND
// tested on the OPT-IN external compile lane (-Dcore-compiler=external),
// through the same CLI verbs a user runs. Env-gated like the
// compiled-core batteries — the repo compiles no external core on a
// stock checkout, so without NATIVE_SDK_CORE_COMPILER the step is a
// clean no-op and every default step is untouched. CI runs it in the
// Compiled-Core Parity job, where the pinned compiler is installed.
const example_external_step = b.step(
"test-example-soundboard-ts-external",
"Build and test the soundboard-ts example on the opt-in external core lane (requires NATIVE_SDK_CORE_COMPILER; skipped when unset)",
);
if (b.graph.environ_map.get("NATIVE_SDK_CORE_COMPILER") != null) {
const external_build = managedExampleRun(b, host_cli_exe, &.{ "build", "-Dplatform=null", "-Dcore-compiler=external" });
external_build.setCwd(b.path("examples/soundboard-ts"));
external_build.has_side_effects = true;
_ = external_build.captureStdOut(.{});
_ = external_build.captureStdErr(.{});
external_build.setName("test-example-soundboard-ts-external (build)");
const external_test = managedExampleRun(b, host_cli_exe, &.{ "test", "-Dplatform=null", "-Dcore-compiler=external" });
external_test.setCwd(b.path("examples/soundboard-ts"));
external_test.has_side_effects = true;
_ = external_test.captureStdOut(.{});
_ = external_test.captureStdErr(.{});
external_test.setName("test-example-soundboard-ts-external (test)");
// Serial on purpose: both verbs drive one generated graph in the
// example's .native/build, and racing them races its cache.
external_test.step.dependOn(&external_build.step);
example_external_step.dependOn(&external_test.step);
}
const mobile_examples_step = b.step("test-examples-mobile", "Verify mobile example project layouts");
addLayoutCheckStep(b, mobile_examples_step, "test-example-ios-layout", "Verify iOS example layout", &.{
"examples/ios/README.md",
@@ -3025,6 +2975,10 @@ fn testArtifact(b: *std.Build, mod: *std.Build.Module) *std.Build.Step.Compile {
/// installed dependency (`npm ci` in packages/core) is missing.
const TsCoreE2eArtifacts = struct {
host: *std.Build.Step.Compile,
/// The markup battery is its own binary: the compiled-core symbol
/// set is a fixed-prefix C ABI, so one process carries ONE archive
/// — every fixture battery links exactly its own core.
markup: *std.Build.Step.Compile,
soundboard: *std.Build.Step.Compile,
system_monitor: *std.Build.Step.Compile,
/// The stock-IDE contract: a fresh scaffold (and the committed TS
@@ -3032,38 +2986,20 @@ const TsCoreE2eArtifacts = struct {
/// paths, and builds keep working with node_modules deleted.
scaffold_ide: *std.Build.Step.Compile,
ai_chat: *std.Build.Step.Compile,
/// Sidecar-shim conformance (tests/sidecar): every fixture built
/// through BOTH lanes — the transpiler and corewire's generated
/// mirror — and compared by layout fingerprint and model-contract
/// artifact.
/// Sidecar-shim conformance (tests/sidecar): every fixture's
/// generated mirror validated over its frontend-emitted contract
/// (plus the hand-written ground-truth sidecars), and every shim
/// fully analyzed and linked against the stub core.
sidecar_conformance: *std.Build.Step.Compile,
/// Behavior parity against a REAL compiled core: built only when
/// NATIVE_SDK_EXTERNAL_CORE_ARCHIVE names the archive(s) to link
/// (path-delimiter-separated link inputs exporting the markup
/// fixture's attested symbol set); null — the suite is skipped —
/// otherwise.
external_core_parity: ?*std.Build.Step.Compile,
/// The full-corpus compiled-core batteries: each entry is one
/// fixture app's OWN e2e suite compiled over a paired core — the
/// transpiled lane plus a generated mirror dispatching into a
/// caller-supplied compiled-core archive, byte-compared at every
/// seam. Built per fixture only when its archive/sidecar env pair
/// (NATIVE_SDK_EXTERNAL_CORE_ARCHIVE_<FIXTURE> and
/// NATIVE_SDK_EXTERNAL_CORE_SIDECAR_<FIXTURE>) is supplied; empty
/// — every battery skipped — otherwise.
compiled_core_parity: []const CompiledCoreParity,
/// The ABI-law suite over a REAL compiled core: the markup
/// fixture's archive driven directly through the C ABI bindings
/// (boot fence, collect invariant, deterministic re-init, channel
/// envelopes, integer classes).
external_core_abi_laws: *std.Build.Step.Compile,
/// Per-fixture contract artifacts for an external core toolchain:
/// the effective contract sidecar and its TypeScript facade/profile
/// projections, installed by the stage-core-contracts step.
core_contracts: []const CoreContract,
/// Per-fixture equivalence pins: the frontend-emitted contract
/// sidecar held byte-identical to the extraction-path document.
contract_equivalence: []const *std.Build.Step.Run,
};
const CompiledCoreParity = struct {
name: []const u8,
tests: *std.Build.Step.Compile,
};
const CoreContract = struct {
@@ -3073,34 +3009,6 @@ const CoreContract = struct {
profile: std.Build.LazyPath,
};
/// One fixture's compiled-core supply: the archive link input(s) and
/// the archive's own emitted contract sidecar. Both come as a pair —
/// a real archive's build_id can only match its co-emitted sidecar, so
/// one without the other is a misconfiguration, refused with a
/// teaching rather than skipped into silence.
const CompiledCoreSupply = struct {
archives: []const u8,
sidecar: std.Build.LazyPath,
};
fn compiledCoreEnv(b: *std.Build, comptime suffix: []const u8) ?CompiledCoreSupply {
const archive_var = "NATIVE_SDK_EXTERNAL_CORE_ARCHIVE_" ++ suffix;
const sidecar_var = "NATIVE_SDK_EXTERNAL_CORE_SIDECAR_" ++ suffix;
const archives = b.graph.environ_map.get(archive_var);
const sidecar = b.graph.environ_map.get(sidecar_var);
if (archives == null and sidecar == null) return null;
if (archives == null or sidecar == null) {
std.debug.panic(
"{s} and {s} come as a pair: a compiled-core fixture needs both its archive link input(s) and the archive's own emitted contract sidecar",
.{ archive_var, sidecar_var },
);
}
return .{
.archives = b.dupe(archives.?),
.sidecar = .{ .cwd_relative = b.dupe(sidecar.?) },
};
}
fn tsCoreE2eArtifact(
b: *std.Build,
target: std.Build.ResolvedTarget,
@@ -3109,30 +3017,84 @@ fn tsCoreE2eArtifact(
tooling_mod: *std.Build.Module,
) ?TsCoreE2eArtifacts {
const node = b.findProgram(&.{"node"}, &.{}) catch return null;
// Both toolchains arrive with one `npm ci` in packages/core: the
// frontend's TypeScript compiler and the external core compiler
// (unless NATIVE_SDK_CORE_COMPILER points at the pinned release's
// command directly).
b.build_root.handle.access(
b.graph.io,
"packages/core/node_modules/@typescript/typescript6",
"packages/core/node_modules/@typescript/old",
.{},
) catch return null;
if (b.graph.environ_map.get("NATIVE_SDK_CORE_COMPILER") == null) {
b.build_root.handle.access(
b.graph.io,
"packages/core/node_modules/scriptc/dist/main.js",
.{},
) catch return null;
}
// Each fixture stages its own copy of rt.zig, so each emitted core
// owns a distinct rt kernel instance — the process contract the
// coexistence e2e test pins (two live cores, no shared arenas).
const host_fixture = tsCoreFixtureModule(b, target, optimize, node, "tests/ts-core/fixture.ts");
// corewire (the contract-sidecar mirror/facade/profile generator),
// compiled for the build host: the fixture compiles and the
// conformance shims all run it.
const corewire_mod = b.createModule(.{
.root_source_file = b.path("tools/corewire/main.zig"),
.target = target,
.optimize = optimize,
});
const corewire_exe = b.addExecutable(.{
.name = "corewire",
.root_module = corewire_mod,
.use_llvm = @import("build/app.zig").useLlvmWorkaround(target),
});
// Each fixture core compiles through the external core compiler at
// build time; the battery drives the linked archive through the
// generated mirror. The compiled-core symbol set is a fixed-prefix
// C ABI, so one process carries ONE archive — each battery below is
// its own binary linking exactly its own core. The tests/ts-core
// fixtures are single-file cores, staged into their own
// compile-source directories.
const host_src = b.addWriteFiles();
_ = host_src.addCopyFile(b.path("tests/ts-core/fixture.ts"), "fixture.ts");
const host_fixture = externalCoreFixtureModule(b, target, optimize, node, corewire_exe, .{
.entry = "tests/ts-core/fixture.ts",
.src_dir = host_src.getDirectory(),
.name = "host_fixture_core",
// The fixture drives pastBytes to the f64-exact boundary (2^53):
// no honest i64 declaration exists there, so the compiled
// projection carries the slot as f64.
.f64_slots = &.{"Model.pastBytes"},
});
const fixture_mod = host_fixture.module;
const markup_fixture = tsCoreFixtureModule(b, target, optimize, node, "tests/ts-core/markup_fixture.ts");
const markup_src = b.addWriteFiles();
_ = markup_src.addCopyFile(b.path("tests/ts-core/markup_fixture.ts"), "markup_fixture.ts");
const markup_fixture = externalCoreFixtureModule(b, target, optimize, node, corewire_exe, .{
.entry = "tests/ts-core/markup_fixture.ts",
.src_dir = markup_src.getDirectory(),
.name = "markup_core",
});
const markup_fixture_mod = markup_fixture.module;
const e2e_mod = module(b, target, optimize, "tests/ts-core/host_e2e_tests.zig");
e2e_mod.addImport("native_sdk", desktop_mod);
e2e_mod.addImport("ts_core_fixture", fixture_mod);
e2e_mod.addImport("ts_markup_fixture", markup_fixture_mod);
// The markup battery: the .native view + automation + record/replay
// guarantees over the markup fixture's compiled core.
const markup_e2e_mod = module(b, target, optimize, "tests/ts-core/markup_e2e_tests.zig");
markup_e2e_mod.addImport("native_sdk", desktop_mod);
markup_e2e_mod.addImport("ts_markup_fixture", markup_fixture_mod);
// The soundboard-ts example's core and markup, tested as one app:
// the test root stages beside a copy of the example's app.native so
// the compiled markup engine builds the SHIPPING view over the
// emitted model.
const soundboard_fixture = tsCoreFixtureModule(b, target, optimize, node, "examples/soundboard-ts/src/core.ts");
// core's model.
const soundboard_fixture = externalCoreFixtureModule(b, target, optimize, node, corewire_exe, .{
.entry = "examples/soundboard-ts/src/core.ts",
.src_dir = b.path("examples/soundboard-ts/src"),
.name = "soundboard_core",
});
const soundboard_core_mod = soundboard_fixture.module;
const soundboard_stage = b.addWriteFiles();
const soundboard_root = soundboard_stage.addCopyFile(b.path("tests/ts-core/soundboard_e2e_tests.zig"), "soundboard_e2e_tests.zig");
@@ -3148,7 +3110,11 @@ fn tsCoreE2eArtifact(
// The system-monitor-ts example's core and markup, tested the same
// way — plus the ORIGINAL Zig example's committed sampler captures,
// staged as fixtures so both ports parse the same recorded truth.
const monitor_fixture = tsCoreFixtureModule(b, target, optimize, node, "examples/system-monitor-ts/src/core.ts");
const monitor_fixture = externalCoreFixtureModule(b, target, optimize, node, corewire_exe, .{
.entry = "examples/system-monitor-ts/src/core.ts",
.src_dir = b.path("examples/system-monitor-ts/src"),
.name = "system_monitor_core",
});
const monitor_core_mod = monitor_fixture.module;
const monitor_stage = b.addWriteFiles();
const monitor_root = monitor_stage.addCopyFile(b.path("tests/ts-core/system_monitor_e2e_tests.zig"), "system_monitor_e2e_tests.zig");
@@ -3173,7 +3139,11 @@ fn tsCoreE2eArtifact(
// The ai-chat-ts example's core and markup, tested the same way:
// the chat client for an OpenAI-compatible endpoint, driven through
// the fake fetch feed (no network) with its shipping markup.
const ai_chat_fixture = tsCoreFixtureModule(b, target, optimize, node, "examples/ai-chat-ts/src/core.ts");
const ai_chat_fixture = externalCoreFixtureModule(b, target, optimize, node, corewire_exe, .{
.entry = "examples/ai-chat-ts/src/core.ts",
.src_dir = b.path("examples/ai-chat-ts/src"),
.name = "ai_chat_core",
});
const ai_chat_core_mod = ai_chat_fixture.module;
const ai_chat_stage = b.addWriteFiles();
const ai_chat_root = ai_chat_stage.addCopyFile(b.path("tests/ts-core/ai_chat_e2e_tests.zig"), "ai_chat_e2e_tests.zig");
@@ -3186,56 +3156,39 @@ fn tsCoreE2eArtifact(
ai_chat_mod.addImport("native_sdk", desktop_mod);
ai_chat_mod.addImport("ts_ai_chat_core", ai_chat_core_mod);
// Sidecar-shim conformance: pair every fixture's transpiled module
// with a corewire-generated mirror. The markup fixture's sidecar is
// the committed hand-written one (independent ground truth for the
// schema); the rest are extracted from the transpiled modules at
// build time, so corpus fixtures cannot go stale against their
// sidecars.
const corewire_mod = b.createModule(.{
.root_source_file = b.path("tools/corewire/main.zig"),
.target = target,
.optimize = optimize,
});
const corewire_exe = b.addExecutable(.{
.name = "corewire",
.root_module = corewire_mod,
.use_llvm = @import("build/app.zig").useLlvmWorkaround(target),
});
const extract_mod = module(b, target, optimize, "tools/corewire/extract.zig");
// Sidecar-shim conformance: a corewire-generated mirror per corpus
// fixture. The markup fixture's sidecar is the committed
// hand-written one (independent ground truth for the schema); the
// rest are the frontend-emitted contracts from the fixture compiles
// above, so corpus mirrors cannot go stale against their cores.
const conformance_mod = module(b, target, optimize, "tests/sidecar/conformance_tests.zig");
conformance_mod.addImport("native_sdk", desktop_mod);
// The canonical value encoder the envelope and snapshot axes compare
// against (the same module the generated shims stage).
conformance_mod.addImport("corewire_rt", module(b, target, optimize, "tools/corewire/shim_rt.zig"));
conformance_mod.addImport("ts_markup_core", markup_fixture_mod);
conformance_mod.addImport("shim_markup_core", sidecarShimModule(b, target, optimize, corewire_exe, b.path("tests/sidecar/markup_fixture.contract.json")));
// The integer-class fixture: a hand-written sidecar attesting mixed
// i64/u64 slot classes, so the suite drives boundary and full-range
// integer values through a generated mirror's decode paths.
conformance_mod.addImport("shim_integer_core", sidecarShimModule(b, target, optimize, corewire_exe, b.path("tests/sidecar/integer_fixture.contract.json")));
const conformance_fixtures = [_]struct {
ts_import: []const u8,
shim_import: []const u8,
contract_name: []const u8,
core: TsCoreFixture,
entry: []const u8,
core: ExternalCoreFixture,
/// Attested integer slots the compiled projection carries as f64
/// (values that reach the f64-exact boundary have no honest i64
/// declaration on that side).
f64_slots: []const []const u8 = &.{},
}{
.{ .ts_import = "ts_host_core", .shim_import = "shim_host_core", .contract_name = "host-fixture", .core = host_fixture, .entry = "tests/ts-core/fixture.ts", .f64_slots = &.{"Model.pastBytes"} },
.{ .ts_import = "ts_soundboard_core", .shim_import = "shim_soundboard_core", .contract_name = "soundboard", .core = soundboard_fixture, .entry = "examples/soundboard-ts/src/core.ts" },
.{ .ts_import = "ts_monitor_core", .shim_import = "shim_monitor_core", .contract_name = "system-monitor", .core = monitor_fixture, .entry = "examples/system-monitor-ts/src/core.ts" },
.{ .ts_import = "ts_ai_chat_core", .shim_import = "shim_ai_chat_core", .contract_name = "ai-chat", .core = ai_chat_fixture, .entry = "examples/ai-chat-ts/src/core.ts" },
.{ .shim_import = "shim_host_core", .contract_name = "host-fixture", .core = host_fixture, .f64_slots = &.{"Model.pastBytes"} },
.{ .shim_import = "shim_soundboard_core", .contract_name = "soundboard", .core = soundboard_fixture },
.{ .shim_import = "shim_monitor_core", .contract_name = "system-monitor", .core = monitor_fixture },
.{ .shim_import = "shim_ai_chat_core", .contract_name = "ai-chat", .core = ai_chat_fixture },
};
// The corpus contract artifacts an external core toolchain consumes
// (stage-core-contracts): the effective sidecar plus its generated
// entry module and compiler profile, per fixture. Every corewire
// consumer reads the FRONTEND-emitted contract; the extraction path
// survives solely as the equivalence pin below.
// consumer reads the FRONTEND-emitted contract.
var core_contracts: std.ArrayList(CoreContract) = .empty;
{
const projections = facadeProjections(b, corewire_exe, markup_fixture.contract, &.{});
@@ -3247,7 +3200,6 @@ fn tsCoreE2eArtifact(
}) catch @panic("OOM");
}
for (conformance_fixtures) |fixture| {
conformance_mod.addImport(fixture.ts_import, fixture.core.module);
conformance_mod.addImport(fixture.shim_import, sidecarShimModule(b, target, optimize, corewire_exe, fixture.core.contract));
const projections = facadeProjections(b, corewire_exe, fixture.core.contract, fixture.f64_slots);
core_contracts.append(b.allocator, .{
@@ -3258,189 +3210,34 @@ fn tsCoreE2eArtifact(
}) catch @panic("OOM");
}
// The contract-equivalence pin: per fixture, extract the sidecar
// from the transpiled module (the historical producer) and hold the
// frontend-emitted document byte-identical to it. This is the fence
// that lets every corewire consumer read the frontend document while
// the extraction path still exists to attest it.
var contract_equivalence: std.ArrayList(*std.Build.Step.Run) = .empty;
const equivalence_fixtures = [_]struct {
name: []const u8,
core: TsCoreFixture,
entry: []const u8,
}{
.{ .name = "host-fixture", .core = host_fixture, .entry = "tests/ts-core/fixture.ts" },
.{ .name = "markup-fixture", .core = markup_fixture, .entry = "tests/ts-core/markup_fixture.ts" },
.{ .name = "soundboard", .core = soundboard_fixture, .entry = "examples/soundboard-ts/src/core.ts" },
.{ .name = "system-monitor", .core = monitor_fixture, .entry = "examples/system-monitor-ts/src/core.ts" },
.{ .name = "ai-chat", .core = ai_chat_fixture, .entry = "examples/ai-chat-ts/src/core.ts" },
};
for (equivalence_fixtures) |fixture| {
const extracted = sidecarExtractJson(b, target, optimize, extract_mod, fixture.core.module, fixture.entry);
const diff = b.addSystemCommand(&.{node});
diff.addFileArg(b.path("packages/core/scripts/contract_diff.mjs"));
diff.addFileArg(extracted);
diff.addFileArg(fixture.core.contract);
diff.setName(b.fmt("contract-equivalence-{s}", .{fixture.name}));
contract_equivalence.append(b.allocator, diff) catch @panic("OOM");
}
// Compiled-core behavior parity (tests/sidecar/
// external_core_parity_tests.zig): the executable half of the
// conformance story, gated on a caller-supplied compiled-core
// archive because the repo builds none itself. The env var carries
// one or more link inputs (path-delimiter-separated) that together
// export the markup fixture's attested symbol set; the binary pairs
// a FRESH generated mirror with them (the conformance binary's
// mirror links the stub core's exports — one process cannot carry
// both symbol sets). A real archive's sidecar carries the compile's
// own build_id, which the mirror's boot fence checks against the
// identity getters — so the caller supplies the archive's OWN
// emitted sidecar through NATIVE_SDK_EXTERNAL_CORE_SIDECAR (the
// committed fixture sidecar stays the default for stub-shaped
// callers that restate its identity).
const external_core_parity: ?*std.Build.Step.Compile = if (b.graph.environ_map.get("NATIVE_SDK_EXTERNAL_CORE_ARCHIVE")) |archives| blk: {
const parity_mod = module(b, target, optimize, "tests/sidecar/external_core_parity_tests.zig");
parity_mod.link_libc = true;
parity_mod.addImport("corewire_rt", module(b, target, optimize, "tools/corewire/shim_rt.zig"));
parity_mod.addImport("core_abi", module(b, target, optimize, "tools/corewire/core_abi.zig"));
parity_mod.addImport("ts_core", markup_fixture_mod);
const parity_sidecar: std.Build.LazyPath = if (b.graph.environ_map.get("NATIVE_SDK_EXTERNAL_CORE_SIDECAR")) |sidecar|
.{ .cwd_relative = b.dupe(sidecar) }
else
b.path("tests/sidecar/markup_fixture.contract.json");
parity_mod.addImport("shim_core", sidecarShimModule(b, target, optimize, corewire_exe, parity_sidecar));
var inputs = std.mem.tokenizeScalar(u8, archives, std.fs.path.delimiter);
while (inputs.next()) |input| {
parity_mod.addObjectFile(.{ .cwd_relative = b.dupe(input) });
}
// The generated mirror is the only projection this lane uses;
// sidecarShimModule validates exactly that surface. Do not run
// the all-projections checker here: an external compiler's
// sidecar may legitimately predate facade-only metadata while
// remaining a valid mirror contract for its linked archive.
break :blk filteredTestArtifact(b, parity_mod, "external-core-parity-tests", &.{});
} else null;
// The full-corpus compiled-core batteries: each supplied fixture's
// OWN e2e test root recompiled over a paired core (the transpiled
// lane plus a fresh generated mirror linked against the caller's
// archive), so every behavioral assertion the fixture app carries
// runs through the compiled core with byte parity checked at every
// seam (commands, snapshots, subscriptions, channels, helpers).
// Env-gated per fixture like the markup parity suite: unset means
// the battery is never built and `zig build test` stays green.
var compiled_core_parity: std.ArrayList(CompiledCoreParity) = .empty;
const compiled_core_fixtures = [_]struct {
name: []const u8,
supply: ?CompiledCoreSupply,
ts_mod: *std.Build.Module,
root: std.Build.LazyPath,
core_import: []const u8,
/// The host suite's second lane (the markup fixture) rides the
/// same binary, transpiler-only: one archive per process is the
/// compiled-core contract.
second_import: ?[]const u8 = null,
second_mod: ?*std.Build.Module = null,
}{
.{ .name = "host", .supply = compiledCoreEnv(b, "HOST"), .ts_mod = fixture_mod, .root = b.path("tests/ts-core/host_e2e_tests.zig"), .core_import = "ts_core_fixture", .second_import = "ts_markup_fixture", .second_mod = markup_fixture_mod },
// The markup battery is the mirror image of the host one: the
// markup fixture's core pairs, and the host fixture rides along
// transpiler-only as the second lane.
.{ .name = "markup", .supply = compiledCoreEnv(b, "MARKUP"), .ts_mod = markup_fixture_mod, .root = b.path("tests/ts-core/markup_e2e_tests.zig"), .core_import = "ts_markup_fixture", .second_import = "ts_core_fixture", .second_mod = fixture_mod },
.{ .name = "soundboard", .supply = compiledCoreEnv(b, "SOUNDBOARD"), .ts_mod = soundboard_core_mod, .root = soundboard_root, .core_import = "ts_soundboard_core" },
.{ .name = "system-monitor", .supply = compiledCoreEnv(b, "SYSTEM_MONITOR"), .ts_mod = monitor_core_mod, .root = monitor_root, .core_import = "ts_system_monitor_core" },
.{ .name = "ai-chat", .supply = compiledCoreEnv(b, "AI_CHAT"), .ts_mod = ai_chat_core_mod, .root = ai_chat_root, .core_import = "ts_ai_chat_core" },
};
for (compiled_core_fixtures) |entry| {
const supply = entry.supply orelse continue;
const paired_mod = pairedCoreModule(b, target, optimize, corewire_exe, entry.ts_mod, supply);
const battery_mod = b.createModule(.{
.root_source_file = entry.root,
.target = target,
.optimize = optimize,
});
battery_mod.addImport("native_sdk", desktop_mod);
battery_mod.addImport(entry.core_import, paired_mod);
if (entry.second_import) |second| battery_mod.addImport(second, entry.second_mod.?);
const battery = filteredTestArtifact(b, battery_mod, b.fmt("compiled-core-{s}-tests", .{entry.name}), &.{});
compiled_core_parity.append(b.allocator, .{ .name = b.dupe(entry.name), .tests = battery }) catch @panic("OOM");
}
// The ABI-law suite (tests/sidecar/external_core_abi_tests.zig):
// the executable half of the conformance story — the markup
// fixture's REAL archive driven directly through the C ABI
// bindings, beside a FRESH generated mirror (the conformance
// binary's mirror links the stub core's exports — one process
// cannot carry both symbol sets). The mirror generates from the
// archive's OWN co-emitted sidecar, so its boot fence checks the
// compile's real build_id against the identity getters.
const abi_laws_mod = module(b, target, optimize, "tests/sidecar/external_core_abi_tests.zig");
abi_laws_mod.link_libc = true;
abi_laws_mod.addImport("corewire_rt", module(b, target, optimize, "tools/corewire/shim_rt.zig"));
abi_laws_mod.addImport("core_abi", module(b, target, optimize, "tools/corewire/core_abi.zig"));
abi_laws_mod.addImport("shim_core", sidecarShimModule(b, target, optimize, corewire_exe, markup_fixture.sidecar));
abi_laws_mod.addObjectFile(markup_fixture.archive);
return .{
.host = filteredTestArtifact(b, e2e_mod, "ts-core-e2e-tests", &.{}),
.markup = filteredTestArtifact(b, markup_e2e_mod, "ts-markup-e2e-tests", &.{}),
.soundboard = filteredTestArtifact(b, soundboard_mod, "ts-soundboard-e2e-tests", &.{}),
.system_monitor = filteredTestArtifact(b, monitor_mod, "ts-system-monitor-e2e-tests", &.{}),
.scaffold_ide = filteredTestArtifact(b, scaffold_ide_mod, "ts-scaffold-ide-e2e-tests", &.{}),
.ai_chat = filteredTestArtifact(b, ai_chat_mod, "ts-ai-chat-e2e-tests", &.{}),
.sidecar_conformance = filteredTestArtifact(b, conformance_mod, "sidecar-conformance-tests", &.{}),
.external_core_parity = external_core_parity,
.compiled_core_parity = compiled_core_parity.toOwnedSlice(b.allocator) catch @panic("OOM"),
.external_core_abi_laws = filteredTestArtifact(b, abi_laws_mod, "external-core-abi-tests", &.{}),
.core_contracts = core_contracts.toOwnedSlice(b.allocator) catch @panic("OOM"),
.contract_equivalence = contract_equivalence.toOwnedSlice(b.allocator) catch @panic("OOM"),
};
}
/// One fixture's paired-core module (tests/compiled-core): generate
/// the lockstep root from the transpiled module's own export surface,
/// stage it beside the pairing library and the mirror-value converter,
/// bind the transpiled lane and a mirror generated from the supplied
/// contract sidecar, and link the caller's compiled-core archive(s).
fn pairedCoreModule(
b: *std.Build,
target: std.Build.ResolvedTarget,
optimize: std.builtin.OptimizeMode,
corewire_exe: *std.Build.Step.Compile,
ts_mod: *std.Build.Module,
supply: CompiledCoreSupply,
) *std.Build.Module {
const gen_root = b.addWriteFiles().add("gen_paired_main.zig",
\\//! Generated by the build: emit a fixture's paired-core root
\\//! from its transpiled module (tests/compiled-core/gen_paired.zig).
\\const std = @import("std");
\\const gen = @import("gen_paired");
\\const core = @import("ts_core");
\\pub fn main(init: std.process.Init) !void {
\\ try gen.emitMain(core, init);
\\}
\\
);
const gen_mod = b.createModule(.{
.root_source_file = gen_root,
.target = target,
.optimize = optimize,
});
gen_mod.addImport("gen_paired", module(b, target, optimize, "tests/compiled-core/gen_paired.zig"));
gen_mod.addImport("ts_core", ts_mod);
const gen_exe = b.addExecutable(.{
.name = "gen-paired",
.root_module = gen_mod,
.use_llvm = @import("build/app.zig").useLlvmWorkaround(target),
});
const gen_run = b.addRunArtifact(gen_exe);
const paired_src = gen_run.addOutputFileArg("paired.zig");
const staged = b.addWriteFiles();
const paired_root = staged.addCopyFile(paired_src, "paired.zig");
_ = staged.addCopyFile(b.path("tests/compiled-core/paired_core.zig"), "paired_core.zig");
_ = staged.addCopyFile(b.path("tests/sidecar/mirror_value.zig"), "mirror_value.zig");
const mod = b.createModule(.{
.root_source_file = paired_root,
.target = target,
.optimize = optimize,
});
mod.link_libc = true;
mod.addImport("ts_lane", ts_mod);
mod.addImport("shim_lane", sidecarShimModule(b, target, optimize, corewire_exe, supply.sidecar));
mod.addImport("corewire_rt", module(b, target, optimize, "tools/corewire/shim_rt.zig"));
mod.addImport("core_abi", module(b, target, optimize, "tools/corewire/core_abi.zig"));
var inputs = std.mem.tokenizeScalar(u8, supply.archives, std.fs.path.delimiter);
while (inputs.next()) |input| {
mod.addObjectFile(.{ .cwd_relative = b.dupe(input) });
}
return mod;
}
const FacadeProjections = struct {
sidecar: std.Build.LazyPath,
facade: std.Build.LazyPath,
@@ -3500,102 +3297,155 @@ fn sidecarShimModule(
});
}
/// Extract a fixture's contract sidecar from its transpiled module
/// (tools/corewire/extract.zig) — a generated one-line main per
/// fixture, so corpus sidecars regenerate whenever the fixture or the
/// transpiler changes.
fn sidecarExtractJson(
b: *std.Build,
target: std.Build.ResolvedTarget,
optimize: std.builtin.OptimizeMode,
extract_mod: *std.Build.Module,
core_mod: *std.Build.Module,
entry: []const u8,
) std.Build.LazyPath {
const root = b.addWriteFiles().add("extract_main.zig", b.fmt(
\\//! Generated by the build: extract the contract sidecar from a
\\//! transpiled fixture core (see tools/corewire/extract.zig).
\\const std = @import("std");
\\const extract = @import("extract");
\\const core = @import("ts_core");
\\pub fn main(init: std.process.Init) !void {{
\\ try extract.emitMain(core, "{s}", init);
\\}}
\\
, .{entry}));
const mod = b.createModule(.{
.root_source_file = root,
.target = target,
.optimize = optimize,
});
mod.addImport("extract", extract_mod);
mod.addImport("ts_core", core_mod);
const exe = b.addExecutable(.{
.name = "sidecar-extract",
.root_module = mod,
.use_llvm = @import("build/app.zig").useLlvmWorkaround(target),
});
const run = b.addRunArtifact(exe);
return run.addOutputFileArg("core.contract.json");
}
/// One transpiled TS fixture core: the emitted Zig paired with its rt
/// kernel in one generated module, plus the frontend-emitted contract
/// sidecar the same transpile invocation wrote (the document corewire's
/// projections consume; test-contract-equivalence pins it against the
/// extraction path).
const TsCoreFixture = struct {
/// One externally compiled TS fixture core: the corewire-generated
/// mirror over the fixture's compiled archive, staged as one module in
/// the app lane's exact shape (core.zig + its shim runtime), with the
/// archive linked behind it — plus the frontend-emitted contract
/// sidecar (the document corewire's projections consume) and the
/// archive's own co-emitted sidecar.
const ExternalCoreFixture = struct {
module: *std.Build.Module,
/// The frontend-emitted contract (stage-core-contracts, the
/// conformance shims).
contract: std.Build.LazyPath,
/// The compiled-core archive (already linked into `module`; the
/// ABI-law suite links it directly).
archive: std.Build.LazyPath,
/// The archive's OWN co-emitted contract sidecar — the document its
/// mirror must generate from (the boot identity fence pairs them).
sidecar: std.Build.LazyPath,
};
/// Transpile one TS fixture core at build time and pair the emitted
/// Zig with its rt kernel in one generated module.
fn tsCoreFixtureModule(
const ExternalCoreFixtureSpec = struct {
/// The entry module, build-root-relative (the contract's stated
/// entry spelling).
entry: []const u8,
/// The compile-source directory holding the core's whole import
/// graph (an example's src/, or a staged single-file fixture dir).
src_dir: std.Build.LazyPath,
/// The archive's symbol-safe stem (`lib<name>.a`).
name: []const u8,
/// Attested integer slots the compiled projection carries as f64
/// (values that reach the f64-exact boundary have no honest i64
/// declaration on that side) — corewire's --f64-slot demotions,
/// applied to the compile profile and every downstream projection.
f64_slots: []const []const u8 = &.{},
};
/// Compile one TS fixture core through the external core compiler at
/// build time — the same pipeline the app lane runs (build/app.zig
/// tsCoreStage): frontend check + contract, corewire facade/profile
/// projection, compile-tree staging, the exact-pinned compile, and the
/// mirror generated from the co-emitted sidecar.
fn externalCoreFixtureModule(
b: *std.Build,
target: std.Build.ResolvedTarget,
optimize: std.builtin.OptimizeMode,
node: []const u8,
fixture_path: []const u8,
) TsCoreFixture {
const transpile = b.addSystemCommand(&.{node});
transpile.addFileArg(b.path("packages/core/src/cli.ts"));
transpile.addFileArg(b.path(fixture_path));
transpile.addArg("-o");
const emitted_core = transpile.addOutputFileArg("core.zig");
// The contract sidecar, emitted from the SAME checked program in the
// same invocation, stating the build-root-relative entry spelling
// (the extractor's convention).
transpile.addArg("--contract");
const contract = transpile.addOutputFileArg("core.contract.json");
transpile.addArg("--contract-entry");
transpile.addArg(fixture_path);
// The transpiler reads its own sources, the SDK modules, and the
// core's WHOLE import graph at run time; declare them all so edits
// re-emit the fixture. The graph is declared as every sibling .ts of
// the entry (a superset of the reachable imports: over-approximation
// only re-runs the transpile, never misses a stale input).
tsCoreAddDirInputs(b, transpile, "packages/core/sdk");
tsCoreAddDirInputs(b, transpile, std.fs.path.dirname(fixture_path) orelse ".");
const transpiler_sources = [_][]const u8{
"checker.ts", "cli.ts", "contract.ts", "diagnostics.ts", "emitter.ts", "infer.ts", "modules.ts", "transpile.ts", "typed_ast.ts", "types.ts", "wyhash.ts",
corewire_exe: *std.Build.Step.Compile,
spec: ExternalCoreFixtureSpec,
) ExternalCoreFixture {
// The frontend, in check-only mode: the subset checker gates the
// compile, and the contract sidecar states the build-root-relative
// entry spelling. The frontend reads its own sources, the SDK
// modules, and the core's WHOLE import graph at run time; declare
// them all so edits re-check the fixture (every sibling .ts of the
// entry is a superset of the reachable imports: over-approximation
// only re-runs the check, never misses a stale input).
const check = b.addSystemCommand(&.{node});
check.addFileArg(b.path("packages/core/src/cli.ts"));
check.addFileArg(b.path(spec.entry));
check.addArg("--contract");
const contract = check.addOutputFileArg("core.contract.json");
check.addArg("--contract-entry");
check.addArg(spec.entry);
tsCoreAddDirInputs(b, check, "packages/core/sdk");
tsCoreAddDirInputs(b, check, std.fs.path.dirname(spec.entry) orelse ".");
const frontend_sources = [_][]const u8{
"checker.ts", "cli.ts", "contract.ts", "diagnostics.ts", "frontend.ts", "infer.ts", "modules.ts", "typed_ast.ts", "types.ts", "wyhash.ts",
};
for (transpiler_sources) |source| {
transpile.addFileInput(b.path(b.fmt("packages/core/src/{s}", .{source})));
for (frontend_sources) |source| {
check.addFileInput(b.path(b.fmt("packages/core/src/{s}", .{source})));
}
// The emitted core imports "rt.zig" relatively: stage both files
// into one generated directory to root the fixture module there.
// corewire projects the generated compile entry and its profile in
// one invocation, so the profile's entry spelling and the facade
// file can never skew.
const project = b.addRunArtifact(corewire_exe);
project.addArg("--sidecar");
project.addFileArg(contract);
project.addArg("--facade");
const facade = project.addOutputFileArg("core_facade.ts");
project.addArg("--profile");
const profile = project.addOutputFileArg("core_profile.json");
for (spec.f64_slots) |slot| {
project.addArg("--f64-slot");
project.addArg(slot);
}
// The compile stage: author sources + staged SDK + static surface +
// generated entry/profile, one scratch tree.
const stage_run = b.addSystemCommand(&.{node});
stage_run.addFileArg(b.path("packages/core/scripts/stage_external_core.mjs"));
stage_run.addArg("--src");
stage_run.addDirectoryArg(spec.src_dir);
stage_run.addArg("--sdk");
stage_run.addDirectoryArg(b.path("packages/core/sdk"));
stage_run.addArg("--static");
stage_run.addFileArg(b.path("packages/core/compile-surface/core.ts"));
stage_run.addArg("--facade");
stage_run.addFileArg(facade);
stage_run.addArg("--profile");
stage_run.addFileArg(profile);
stage_run.addArg("--out");
const stage_dir = stage_run.addOutputDirectoryArg("stage");
// The external compile: driver-verified against the SDK's exact
// pin, archive normalized, the co-emitted sidecar captured.
const compile = b.addSystemCommand(&.{node});
compile.addFileArg(b.path("packages/core/scripts/run_external_core_compiler.mjs"));
compile.addArg("--stage");
compile.addDirectoryArg(stage_dir);
compile.addArgs(&.{ "--name", spec.name });
compile.addArg("--manifest");
compile.addFileArg(b.path("packages/core/package.json"));
compile.addArg("--out-archive");
const archive = compile.addOutputFileArg(b.fmt("lib{s}.a", .{spec.name}));
compile.addArg("--out-sidecar");
const compiled_sidecar = compile.addOutputFileArg("core.contract.json");
if (b.graph.environ_map.get("NATIVE_SDK_CORE_COMPILER")) |override| {
// The development override: point at any toolchain command; the
// driver still refuses a release other than the SDK's pin.
compile.addArgs(&.{ "--compiler", override });
} else {
compile.addArg("--compiler-js");
compile.addFileArg(b.path("packages/core/node_modules/scriptc/dist/main.js"));
}
// The mirror, generated from the archive's OWN co-emitted contract,
// staged in the app lane's module shape with the archive linked
// behind it (the compiler's runtime needs libc).
const mirror = b.addRunArtifact(corewire_exe);
mirror.addArg("--sidecar");
mirror.addFileArg(compiled_sidecar);
mirror.addArg("--out");
const shim = mirror.addOutputFileArg("core_shim.zig");
const staged = b.addWriteFiles();
const core_root = staged.addCopyFile(emitted_core, "core.zig");
_ = staged.addCopyFile(b.path("packages/core/rt/rt.zig"), "rt.zig");
const shim_root = staged.addCopyFile(shim, "core.zig");
_ = staged.addCopyFile(b.path("tools/corewire/shim_rt.zig"), "shim_rt.zig");
_ = staged.addCopyFile(b.path("tools/corewire/core_abi.zig"), "core_abi.zig");
const mod = b.createModule(.{
.root_source_file = shim_root,
.target = target,
.optimize = optimize,
});
mod.link_libc = true;
mod.addObjectFile(archive);
return .{
.module = b.createModule(.{
.root_source_file = core_root,
.target = target,
.optimize = optimize,
}),
.module = mod,
.contract = contract,
.archive = archive,
.sidecar = compiled_sidecar,
};
}
+154 -158
View File
@@ -54,9 +54,10 @@ pub const AppOptions = struct {
};
/// Which core the app tree carries. No flag and no config anywhere: the
/// tree IS the truth — `src/core.ts` is a TypeScript core (transpiled at
/// build time, run through generated wiring), `src/main.zig` a Zig one,
/// and both at once is a teaching error naming the two files.
/// tree IS the truth — `src/core.ts` is a TypeScript core (compiled to
/// native code at build time, run through generated wiring),
/// `src/main.zig` a Zig one, and both at once is a teaching error
/// naming the two files.
const CoreTree = enum { zig, ts, both, neither };
fn detectCoreTree(b: *std.Build, app_root: []const u8) CoreTree {
@@ -73,32 +74,39 @@ fn appFileExists(b: *std.Build, app_root: []const u8, sub_path: []const u8) bool
return true;
}
/// How a TypeScript core compiles. `.transpiler` (the default) is the
/// emitted-Zig lane; `.external` is the OPT-IN external core compiler
/// lane — the frontend still checks the core and emits its contract
/// sidecar, corewire projects the compile entry and profile, the
/// exact-pinned external toolchain builds the archive, and the app
/// links corewire's generated mirror over it. Selected by
/// `-Dcore-compiler` orelse app.zon's `.core_compiler` orelse
/// transpiler; with neither stated the build is the transpiler lane,
/// byte for byte.
const CoreCompilerOption = enum { transpiler, external };
/// How a TypeScript core compiles: through the external core compiler,
/// always. The frontend checks the core and emits its contract sidecar,
/// corewire projects the compile entry and profile, the exact-pinned
/// external toolchain builds the archive, and the app links corewire's
/// generated mirror over it. `.core_compiler` in app.zon (and the
/// `-Dcore-compiler` flag) accept only "external" — the value exists so
/// a stated choice stays stateable; the removed transpiled lane's
/// spelling is refused with a teaching (see resolveCoreCompiler).
const core_compiler_teaching =
"\ncore_compiler = \"transpiler\" names the removed TS-to-Zig transpiled lane (v0.7.0" ++
" removed it): a TypeScript core compiles through the external core compiler now, and" ++
" that is the default.\nDelete the setting (or spell it \"external\").\n";
/// The staged TypeScript-core wiring: one generated directory holding the
/// core module (the transpiled core.zig with its rt kernel, or the
/// external lane's generated mirror with its staged shim runtime), the
/// core module (the generated mirror with its staged shim runtime), the
/// app's markup, and the SDK's generated-wiring entry (ts_core_main.zig
/// as main.zig). Built once per app build and shared by the exe and
/// test modules.
/// as main.zig) — plus the compiled-core archive the app module links
/// behind the mirror. Built once per app build and shared by the exe
/// and test modules.
const TsCoreStage = struct {
main_root: std.Build.LazyPath,
/// The external lane's compiled-core archive: the app module links
/// it (with libc, for the toolchain's runtime) beside the staged
/// mirror. Null on the transpiler lane.
external_archive: ?std.Build.LazyPath = null,
/// The compiled-core archive: the app module links it (with libc,
/// for the toolchain's runtime) beside the staged mirror.
archive: std.Build.LazyPath,
};
/// Whether the transpiler's TypeScript compiler (@typescript/old, the
/// The frontend's own sources — the staleness set of every build step
/// that runs it (a frontend edit re-checks every core).
const frontend_sources = [_][]const u8{
"checker.ts", "cli.ts", "contract.ts", "diagnostics.ts", "frontend.ts", "infer.ts", "modules.ts", "ownership.ts", "typed_ast.ts", "types.ts", "wyhash.ts",
};
/// Whether the frontend's TypeScript compiler (@typescript/old, the
/// exactly pinned npm alias of the real `typescript` package) RESOLVES
/// from the SDK's packages/core, by node's ancestor node_modules walk —
/// at the SDK's exactly pinned VERSION. The same semantics the CLI gates
@@ -116,17 +124,13 @@ const TsCoreStage = struct {
/// (nested under the package on global prefixes, hoisted to the project
/// root on local ones, pnpm's sibling node_modules).
///
/// The @typescript/typescript6 wrapper is deliberately NOT probed:
/// nothing imports it at run time (typed_ast.ts bypasses its one-line
/// re-export on purpose), so holding the wrapper's resolution — or the
/// alias's version as seen FROM the wrapper's origin against the pin
/// can only FALSE-REJECT healthy trees: npm's own conflict shape hoists
/// a consumer's conflicting `@typescript/old` at the project root while
/// our exact pin lands nested under the CLI, which is precisely the copy
/// runtime loads from packages/core; a consumer's own shadowing wrapper
/// must not sway the verdict either. The wrapper stays a DECLARED
/// dependency in both manifests — it is just not what validation vouches
/// for (see the twin's doc comment).
/// A stray `@typescript/typescript6` compat wrapper in a consumer tree
/// (a former dependency of this package, or the consumer's own) is
/// deliberately NOT probed: nothing imports it at run time, and holding
/// the alias's version as seen FROM a wrapper's origin against the pin
/// can only FALSE-REJECT healthy trees — a consumer's hoisted conflicting
/// `@typescript/old` wins the walk from there while the copy runtime
/// actually loads sits correctly pinned under packages/core.
///
/// Resolvable means the alias's manifest AND its entrypoint are present
/// (see tsAliasedCompilerVersion for node's error shape and the
@@ -234,6 +238,29 @@ fn tsParseQuotedManifestValue(manifest_json: []const u8, comptime key: []const u
return suffix;
}
/// The external core compiler's entry module (scriptc's dist/main.js),
/// resolved by node's ancestor node_modules walk from the SDK's
/// packages/core — the same origin the frontend toolchain resolves from
/// (tsAliasedCompilerVersion's walk, kept in lockstep). Repo checkouts
/// install it there with `npm ci`; the npm-installed CLI carries the
/// compiler as a regular dependency, nested under the package on global
/// prefixes and hoisted to the project root on local ones.
fn tsExternalCompilerJs(b: *std.Build, dep: *std.Build.Dependency) ?[]const u8 {
const io = b.graph.io;
const sdk_root = tsSdkRoot(b.allocator, io, dep);
var dir: []const u8 = b.pathJoin(&.{ sdk_root, "packages", "core" });
while (true) {
if (!std.mem.eql(u8, std.fs.path.basename(dir), "node_modules")) {
const candidate = b.pathJoin(&.{ dir, "node_modules", "scriptc", "dist", "main.js" });
found: {
std.Io.Dir.cwd().access(io, candidate, .{}) catch break :found;
return candidate;
}
}
dir = std.fs.path.dirname(dir) orelse return null;
}
}
/// The SDK dependency's real root, resolved the way both the toolchain
/// check and its teaching name it.
fn tsSdkRoot(allocator: std.mem.Allocator, io: std.Io, dep: *std.Build.Dependency) []const u8 {
@@ -241,19 +268,19 @@ fn tsSdkRoot(allocator: std.mem.Allocator, io: std.Io, dep: *std.Build.Dependenc
return std.Io.Dir.cwd().realPathFileAlloc(io, raw_root, allocator) catch raw_root;
}
/// The shared TS-core preflight — the markup view, node, and the
/// transpiler toolchain gate — returning the resolved node program.
/// Both lanes run it: the external lane still runs the frontend for
/// checking and the contract sidecar.
/// The TS-core preflight — the markup view, node, and the frontend
/// toolchain gate — returning the resolved node program. The frontend
/// (the subset checker and the contract-sidecar emitter) runs under
/// node; the compile itself is the external toolchain's.
fn tsCorePreflight(b: *std.Build, dep: *std.Build.Dependency, app_root: []const u8) []const u8 {
if (!appFileExists(b, app_root, "src/app.native")) {
@panic("\nthis app has a TypeScript core (src/core.ts) but no view: TS apps render markup," ++
" so add src/app.native (the whole view tier binds the core's emitted model)\n");
" so add src/app.native (the whole view tier binds the core's model)\n");
}
const node = b.findProgram(&.{"node"}, &.{}) catch {
@panic("\nbuilding a TypeScript app core needs node on PATH (the @native-sdk/core transpiler runs at" ++
" build time; the binary it emits ships no JS runtime).\nInstall Node.js 22.15+ (on the 23 line: 23.5+)" ++
" — https://nodejs.org or `brew install node` — and re-run.\n");
@panic("\nbuilding a TypeScript app core needs node on PATH (the @native-sdk/core frontend checks the" ++
" core at build time; the binary you ship carries no JS runtime).\nInstall Node.js 22.15+ (on the 23" ++
" line: 23.5+) — https://nodejs.org or `brew install node` — and re-run.\n");
};
switch (tsToolchainResolution(b, dep)) {
.resolved => {},
@@ -267,7 +294,7 @@ fn tsCorePreflight(b: *std.Build, dep: *std.Build.Dependency, app_root: []const
const sdk_root = tsSdkRoot(dep.builder.allocator, dep.builder.graph.io, dep);
std.debug.print(
\\
\\error: the @native-sdk/core transpiler cannot resolve its TypeScript toolchain
\\error: the @native-sdk/core frontend cannot resolve its TypeScript toolchain
\\(its compiler, @typescript/old). On a repo checkout, install it once with:
\\ cd {s}/packages/core && npm ci --include=dev
\\(An npm-installed @native-sdk/cli carries the toolchain automatically; if it
@@ -285,7 +312,7 @@ fn tsCorePreflight(b: *std.Build, dep: *std.Build.Dependency, app_root: []const
// to move.
std.debug.print(
\\
\\error: the @native-sdk/core transpiler's TypeScript compiler resolves at the
\\error: the @native-sdk/core frontend's TypeScript compiler resolves at the
\\wrong version: @typescript/old resolves to typescript {s}, but the SDK pins
\\npm:typescript@{s}. Another package in this tree pins a conflicting
\\@typescript/old - align it with the SDK's pin (or remove it) and reinstall,
@@ -299,83 +326,47 @@ fn tsCorePreflight(b: *std.Build, dep: *std.Build.Dependency, app_root: []const
return node;
}
fn tsCoreStage(b: *std.Build, dep: *std.Build.Dependency, app_root: []const u8) TsCoreStage {
/// The TypeScript-core compile lane: the frontend checks the core and
/// emits its contract sidecar, corewire projects the generated compile
/// entry and library-mode profile, the stager assembles the compile
/// tree (author sources with the mechanical staging transforms, the
/// staged SDK modules, the static compile surface), the exact-pinned
/// external toolchain builds the archive AND co-emits the archive's own
/// contract sidecar, and corewire generates the mirror module from THAT
/// document — so the boot identity fence always pairs the mirror with
/// its own compile. The staged module directory carries core.zig (the
/// mirror) + its staged runtime + app.native + main.zig, so the
/// generated wiring imports one fixed shape.
fn tsCoreStage(b: *std.Build, dep: *std.Build.Dependency, app_root: []const u8, app_name: []const u8) TsCoreStage {
const node = tsCorePreflight(b, dep, app_root);
// The transpiler runs through build/ts_run.mjs, not as `node cli.ts`:
// on the npm-installed layout the transpiler's .ts sources live inside
// node_modules, where node refuses its builtin type stripping — the
// runner strips those modules with the transpiler's own installed
// TypeScript and is a pass-through on a repo checkout.
const transpile = b.addSystemCommand(&.{node});
transpile.addFileArg(dep.path("build/ts_run.mjs"));
transpile.addFileArg(dep.path("packages/core/src/cli.ts"));
transpile.addFileArg(b.path(appPath(b, app_root, "src/core.ts")));
transpile.addArg("-o");
const emitted_core = transpile.addOutputFileArg("core.zig");
// The transpiler reads its own sources, the SDK modules, and the core's
// WHOLE import graph at run time; declare them so a transpiler upgrade
// or an edit to ANY module of a multi-file core re-emits it. The graph
// is declared as every .ts under the app's src/ (a superset of the
// reachable imports: over-approximation only re-runs the transpile,
// never misses a stale input). Failure mode: the checker's NS
// diagnostics stream to stderr verbatim — they are the teaching layer,
// nothing wraps them.
addTsDirInputs(b, dep.builder, transpile, "packages/core/sdk");
addAppTsDirInputs(b, transpile, appPath(b, app_root, "src"));
const transpiler_sources = [_][]const u8{
"checker.ts", "cli.ts", "diagnostics.ts", "emitter.ts", "infer.ts", "modules.ts", "transpile.ts", "typed_ast.ts", "types.ts",
};
for (transpiler_sources) |source| {
transpile.addFileInput(dep.path(b.fmt("packages/core/src/{s}", .{source})));
}
// The wiring imports core.zig and app.native relatively; the emitted
// core imports rt.zig relatively: stage all four into one directory.
const staged = b.addWriteFiles();
_ = staged.addCopyFile(emitted_core, "core.zig");
_ = staged.addCopyFile(dep.path("packages/core/rt/rt.zig"), "rt.zig");
_ = staged.addCopyFile(b.path(appPath(b, app_root, "src/app.native")), "app.native");
const main_root = staged.addCopyFile(dep.path("src/app_runner/ts_core_main.zig"), "main.zig");
return .{ .main_root = main_root };
}
/// The OPT-IN external-compile lane for a TypeScript core (see
/// CoreCompilerOption): the frontend checks the core and emits its
/// contract sidecar, corewire projects the generated compile entry and
/// library-mode profile, the stager assembles the compile tree (author
/// sources with the mechanical staging transforms, the staged SDK
/// modules, the static compile surface), the exact-pinned external
/// toolchain builds the archive AND co-emits the archive's own contract
/// sidecar, and corewire generates the mirror module from THAT document
/// — so the boot identity fence always pairs the mirror with its own
/// compile. The staged module directory has the transpiler lane's exact
/// shape (core.zig + its staged runtime + app.native + main.zig), so
/// the generated wiring runs unchanged over either lane.
fn externalCoreStage(b: *std.Build, dep: *std.Build.Dependency, app_root: []const u8, app_name: []const u8) TsCoreStage {
const node = tsCorePreflight(b, dep, app_root);
// The frontend, for checking and the contract: the transpile runs in
// full (its emitted Zig is discarded), so every transpile-time
// teaching gates this lane exactly as it gates the default one.
const transpile = b.addSystemCommand(&.{node});
transpile.addFileArg(dep.path("build/ts_run.mjs"));
transpile.addFileArg(dep.path("packages/core/src/cli.ts"));
transpile.addFileArg(b.path(appPath(b, app_root, "src/core.ts")));
transpile.addArg("-o");
_ = transpile.addOutputFileArg("core.zig");
transpile.addArg("--contract");
const contract = transpile.addOutputFileArg("core.contract.json");
// The frontend, in check-only mode: the subset checker and the
// contract sidecar, no emission. Every check-time teaching gates the
// compile here — the checker's NS diagnostics stream to stderr
// verbatim; nothing wraps them. The frontend runs through
// build/ts_run.mjs, not as `node cli.ts`: on the npm-installed
// layout the frontend's .ts sources live inside node_modules, where
// node refuses its builtin type stripping — the runner strips those
// modules with the frontend's own installed TypeScript and is a
// pass-through on a repo checkout. The frontend reads its own
// sources, the SDK modules, and the core's WHOLE import graph at
// run time; declare them all so an edit to ANY module of a
// multi-file core re-checks it (every .ts under src/ is a superset
// of the reachable imports: over-approximation only re-runs the
// check, never misses a stale input).
const check = b.addSystemCommand(&.{node});
check.addFileArg(dep.path("build/ts_run.mjs"));
check.addFileArg(dep.path("packages/core/src/cli.ts"));
check.addFileArg(b.path(appPath(b, app_root, "src/core.ts")));
check.addArg("--contract");
const contract = check.addOutputFileArg("core.contract.json");
// The document's entry spelling is app-relative (the sidecar/facade
// contract carries no machine paths).
transpile.addArgs(&.{ "--contract-entry", "src/core.ts" });
addTsDirInputs(b, dep.builder, transpile, "packages/core/sdk");
addAppTsDirInputs(b, transpile, appPath(b, app_root, "src"));
const transpiler_sources = [_][]const u8{
"checker.ts", "cli.ts", "contract.ts", "diagnostics.ts", "emitter.ts", "infer.ts", "modules.ts", "transpile.ts", "typed_ast.ts", "types.ts", "wyhash.ts",
};
for (transpiler_sources) |source| {
transpile.addFileInput(dep.path(b.fmt("packages/core/src/{s}", .{source})));
check.addArgs(&.{ "--contract-entry", "src/core.ts" });
addTsDirInputs(b, dep.builder, check, "packages/core/sdk");
addAppTsDirInputs(b, check, appPath(b, app_root, "src"));
for (frontend_sources) |source| {
check.addFileInput(dep.path(b.fmt("packages/core/src/{s}", .{source})));
}
// corewire, compiled from the SDK dependency for the build host: one
@@ -426,23 +417,24 @@ fn externalCoreStage(b: *std.Build, dep: *std.Build.Dependency, app_root: []cons
// driver still refuses a release other than the SDK's pin.
compile.addArgs(&.{ "--compiler", override });
} else {
const main_js = "packages/core/node_modules/scriptc/dist/main.js";
dep.builder.build_root.handle.access(b.graph.io, main_js, .{}) catch {
const compiler_js = tsExternalCompilerJs(b, dep) orelse {
const sdk_root = tsSdkRoot(dep.builder.allocator, dep.builder.graph.io, dep);
std.debug.print(
\\
\\error: the external core compiler is not installed (app.zon/-Dcore-compiler
\\selected core_compiler = external). It ships as an exact-pinned dependency of
\\the SDK's packages/core — install it once with:
\\error: the external core compiler is not installed (TypeScript cores compile
\\through it). It ships as an exact-pinned dependency of the SDK's packages/core
\\— install it once with:
\\ cd {s}/packages/core && npm ci
\\(or point NATIVE_SDK_CORE_COMPILER at the pinned release's command).
\\(or point NATIVE_SDK_CORE_COMPILER at the pinned release's command; an
\\npm-installed @native-sdk/cli carries the compiler automatically — if it is
\\missing there, the install is broken: reinstall @native-sdk/cli).
\\
\\
, .{sdk_root});
std.process.exit(1);
};
compile.addArg("--compiler-js");
compile.addFileArg(dep.path(main_js));
compile.addFileArg(.{ .cwd_relative = compiler_js });
}
// The mirror, generated from the archive's OWN co-emitted contract.
@@ -452,16 +444,15 @@ fn externalCoreStage(b: *std.Build, dep: *std.Build.Dependency, app_root: []cons
mirror.addArg("--out");
const shim = mirror.addOutputFileArg("core_shim.zig");
// The staged module directory, in the transpiler lane's exact shape:
// the mirror is the app's core.zig, and it imports its staged shim
// runtime relatively exactly as the transpiled core imports rt.zig.
// The staged module directory: the mirror is the app's core.zig,
// and it imports its staged shim runtime relatively.
const staged = b.addWriteFiles();
_ = staged.addCopyFile(shim, "core.zig");
_ = staged.addCopyFile(dep.path("tools/corewire/shim_rt.zig"), "shim_rt.zig");
_ = staged.addCopyFile(dep.path("tools/corewire/core_abi.zig"), "core_abi.zig");
_ = staged.addCopyFile(b.path(appPath(b, app_root, "src/app.native")), "app.native");
const main_root = staged.addCopyFile(dep.path("src/app_runner/ts_core_main.zig"), "main.zig");
return .{ .main_root = main_root, .external_archive = archive };
return .{ .main_root = main_root, .archive = archive };
}
/// corewire (the contract-sidecar shim generator), compiled from the SDK
@@ -681,31 +672,36 @@ pub fn addAppArtifacts(b: *std.Build, dep: *std.Build.Dependency, app_options: A
" src/ are fine either way.)\n");
}
const app_config = appManifestBuildConfig(b, app_options.app_root);
// The OPT-IN external-compile lane: the flag overrides app.zon's
// `.core_compiler`, and with neither stated the transpiler lane runs
// byte for byte.
const core_compiler_override = b.option(CoreCompilerOption, "core-compiler", "How a TypeScript core compiles: transpiler (default), external");
const core_compiler = core_compiler_override orelse app_config.core_compiler;
if (core_compiler == .external and core_tree != .ts) {
@panic("\ncore_compiler = external applies to TypeScript cores (src/core.ts) only; this app" ++
" has a Zig core.\nDrop the opt-in (-Dcore-compiler / app.zon .core_compiler) or port the" ++
" core to TypeScript.\n");
// The core-compiler setting names the one lane there is; the flag
// overrides app.zon's `.core_compiler` and both exist so a stated
// choice stays stateable (and so the removed lane's spelling teaches
// instead of failing opaquely).
if (b.option([]const u8, "core-compiler", "How a TypeScript core compiles: external (the default and only lane)")) |flag| {
if (std.mem.eql(u8, flag, "transpiler")) @panic(core_compiler_teaching);
if (!std.mem.eql(u8, flag, "external")) @panic("\n-Dcore-compiler must be \"external\" (the default and only lane)\n");
if (core_tree != .ts) {
@panic("\n-Dcore-compiler applies to TypeScript cores (src/core.ts) only; this app has" ++
" a Zig core.\nDrop the flag or port the core to TypeScript.\n");
}
}
const ts_stage: ?TsCoreStage = if (core_tree == .ts) switch (core_compiler) {
.transpiler => tsCoreStage(b, dep, app_options.app_root),
.external => externalCoreStage(b, dep, app_options.app_root, app_options.name),
} else null;
// Mobile targets are taught BEFORE lane selection: TypeScript cores
// are desktop-only until the external core toolchain grows mobile
// targets; Zig/markup cores stay fully supported on mobile.
if (core_tree == .ts and (target.result.os.tag == .ios or target.result.abi.isAndroid())) {
@panic("\nTypeScript app cores are desktop-only today: the external core compiler does not" ++
" target mobile yet.\nBuild for a desktop target, or port the core to a Zig" ++
" `mobileOptions` app — Zig and markup cores are fully supported on mobile.\n");
}
const ts_stage: ?TsCoreStage = if (core_tree == .ts)
tsCoreStage(b, dep, app_options.app_root, app_options.name)
else
null;
// Mobile targets get the embed static library as a `lib` step: the
// artifact the toolkit-owned iOS host (and any hand-written shim)
// links, so `native dev|package --target ios` works against every
// standard app build — generated graph or ejected — with nothing but
// `-Dtarget`. Desktop targets keep the step absent.
if (ts_stage != null and (target.result.os.tag == .ios or target.result.abi.isAndroid())) {
@panic("\nTypeScript app cores build desktop apps today; the mobile embed library for TS" ++
" cores lands with the mobile host tier.\nBuild for a desktop target, or port the core" ++
" to a Zig `mobileOptions` app for mobile.\n");
}
if (target.result.os.tag == .ios or target.result.abi.isAndroid()) {
addMobileLibWithTarget(b, dep, target, optimize, .{
.name = app_options.name,
@@ -980,8 +976,8 @@ fn appModule(b: *std.Build, dep: *std.Build.Dependency, target: std.Build.Resolv
const app_mod = if (ts_stage) |stage|
// TypeScript core: the app module roots at the staged generated
// wiring (ts_core_main.zig beside the transpiled core.zig, its rt
// kernel, and the app's markup).
// wiring (ts_core_main.zig beside the mirror core.zig, its shim
// runtime, and the app's markup).
b.createModule(.{
.root_source_file = stage.main_root,
.target = target,
@@ -996,12 +992,10 @@ fn appModule(b: *std.Build, dep: *std.Build.Dependency, target: std.Build.Resolv
app_mod.addImport("app_manifest_zon", manifest_mod);
}
if (ts_stage) |stage| {
if (stage.external_archive) |archive| {
// The external lane links the compiled-core archive behind
// the staged mirror; the toolchain's runtime needs libc.
app_mod.link_libc = true;
app_mod.addObjectFile(archive);
}
// The compiled-core archive links behind the staged mirror; the
// toolchain's runtime needs libc.
app_mod.link_libc = true;
app_mod.addObjectFile(stage.archive);
}
return app_mod;
}
@@ -1391,9 +1385,6 @@ const AppManifestBuildConfig = struct {
cef_dir: []const u8 = "third_party/cef/macos",
cef_auto_install: bool = false,
webview_layer: WebLayerOption = .auto,
/// The app.zon core-compiler opt-in (see CoreCompilerOption);
/// `-Dcore-compiler` overrides it per invocation.
core_compiler: CoreCompilerOption = .transpiler,
/// The first web declaration found (for teaching messages), or null
/// when app.zon declares no web use. `web_engine = "system"` alone is
/// NOT web intent — it is the default in many canvas manifests.
@@ -1408,7 +1399,7 @@ const InferenceManifest = struct {
capabilities: []const []const u8 = &.{},
web_engine: []const u8 = "system",
webview_layer: []const u8 = "auto",
core_compiler: []const u8 = "transpiler",
core_compiler: []const u8 = "external",
cef: struct {
dir: []const u8 = "third_party/cef/macos",
auto_install: bool = false,
@@ -1449,12 +1440,17 @@ fn appManifestBuildConfig(b: *std.Build, app_root: []const u8) AppManifestBuildC
const source_z = b.allocator.dupeZ(u8, source) catch return fallback;
@setEvalBranchQuota(2000);
const raw = std.zon.parse.fromSliceAlloc(InferenceManifest, b.allocator, source_z, null, .{ .ignore_unknown_fields = true }) catch return fallback;
// `.core_compiler` names the one lane there is; validated here so
// the removed transpiled lane's spelling teaches at configure time.
if (!std.mem.eql(u8, raw.core_compiler, "external")) {
if (std.mem.eql(u8, raw.core_compiler, "transpiler")) @panic(core_compiler_teaching);
@panic("\napp.zon .core_compiler must be \"external\" (the default and only lane)\n");
}
return .{
.web_engine = web_layer_contract.parseWebEngine(raw.web_engine) orelse .system,
.cef_dir = raw.cef.dir,
.cef_auto_install = raw.cef.auto_install,
.webview_layer = web_layer_contract.parseWebViewLayer(raw.webview_layer) orelse @panic("app.zon .webview_layer must be \"auto\", \"include\", or \"exclude\""),
.core_compiler = std.meta.stringToEnum(CoreCompilerOption, raw.core_compiler) orelse @panic("app.zon .core_compiler must be \"transpiler\" or \"external\""),
.web_declaration = web_layer_contract.manifestDeclaration(raw),
};
}
+6 -8
View File
@@ -61,15 +61,13 @@ if (typeof module.registerHooks !== 'function') {
load(url, context, nextLoad) {
if (url.startsWith('file:') && url.endsWith('.ts')) {
const filePath = fileURLToPath(url);
// The transpiler's own pinned compiler, resolved from the target
// The frontend's own pinned compiler, resolved from the target
// module's location (packages/core/node_modules after the taught
// `npm ci`, or the dependency npm installed beside the CLI). The
// ALIAS is required directly — not the @typescript/typescript6
// wrapper — because the wrapper's re-export resolves
// "@typescript/old" from the WRAPPER's own location, where a
// consumer tree's conflicting hoisted copy would win nearest-wins
// over our exact pin; resolving from the target finds our own
// nested/hoisted pin first (same reasoning as typed_ast.ts).
// `npm ci`, or the dependency npm installed beside the CLI):
// resolving from the target finds our own nested/hoisted exact
// pin first, so a consumer tree's conflicting hoisted typescript
// never wins nearest-wins over it (same reasoning as
// typed_ast.ts).
if (ts === null) {
try {
ts = createRequire(targetPath)('@typescript/old');
+1
View File
@@ -0,0 +1 @@
The unused `@typescript/typescript6` compat wrapper is no longer a dependency of `@native-sdk/cli` or `packages/core`. The frontend's compiler was already imported directly through the exactly pinned `@typescript/old` alias; the wrapper shipped in every install without ever being loaded. Consumer trees that carry their own copy of the wrapper are unaffected — toolchain validation never probed it.
+5 -1
View File
@@ -1,2 +1,6 @@
feature: **External core compiler lane (opt-in)**: a TypeScript core can now compile through the external core compiler — set `.core_compiler = "external"` in app.zon or pass `-Dcore-compiler=external`; with neither stated, the default transpiler lane is byte-for-byte unchanged.
feature: **TypeScript cores compile through the external core compiler**: the frontend checks `src/core.ts` and emits its contract sidecar, the exact-pinned compiler builds a native archive, and the app links a generated mirror over it — no JS runtime in the binary, nothing to configure.
- **The TS-to-Zig transpiled lane is removed** (a deliberate pre-1.0 break): `core_compiler = "transpiler"` in app.zon (and `-Dcore-compiler=transpiler`) is refused with a teaching, and `native check` runs the checker and contract only — no emitted Zig lands under `.native/check/`.
- **The compiler is a package dependency**: it ships exact-pinned with the SDK's `packages/core` (repo checkouts install it with `npm ci` there; an npm-installed CLI carries it automatically).
- **The core dev loop is restart-shaped**: markup hot reload and the instant `native dev --core` node loop are unchanged, and a core edit now pays a native compile measured in seconds on rebuild.
- **TypeScript cores are desktop-only for now**: a mobile target with `src/core.ts` is taught before lane selection (the external toolchain does not target mobile yet); Zig and markup cores stay fully supported on mobile.
- **Shipped type declarations**: `@native-sdk/core` now ships generated `sdk/*.d.ts` declaration files beside its TypeScript sources, so external tooling can resolve the SDK's types without compiling them.
+2 -2
View File
@@ -11,7 +11,7 @@ A Native SDK app is one loop with four parts:
The runtime owns everything else: window creation, GPU presentation, resize, pointer and keyboard dispatch, timers, accessibility, and hot reload. Your code never handles a raw event — input lands on a widget, the widget's bound message dispatches into `update`, the view rebuilds from the new model, and the engine repaints what changed.
The loop is the same in both authoring languages. By default the core is TypeScript (`src/core.ts`, compiled to native code at build time — [TypeScript Cores](/docs/typescript) covers that tier in depth); a Zig core (`src/main.zig`, from `native init --template zig-core`) is first-class by choice, and the rest of this page — wiring, identity, hot reload — applies to both. The Zig-specific wiring sections below are exactly what the build generates for a TypeScript app, so they double as its eject story.
The loop is the same in both authoring languages. By default the core is TypeScript (`src/core.ts`, compiled to native code at build time — [TypeScript Cores](/docs/typescript) covers that tier in depth); a Zig core (`src/main.zig`, from `native init --template zig-core`) is first-class by choice, and the rest of this page — wiring, identity, hot reload — applies to both. The Zig-specific wiring sections below are exactly what the build generates for a TypeScript app, so they double as the blueprint for porting a core to Zig by hand.
## The loop in full
@@ -81,7 +81,7 @@ Markup can never mutate state. `{count}` is a read; `on-press="increment"` names
## Wiring
`native_sdk.UiApp(Model, Msg)` ties the loop to the runtime. A zero-config app never writes this — the build graph generates it (for a TypeScript core, over the transpiled model) — but it is ordinary code you can own any time. From the Zig template's `main`:
`native_sdk.UiApp(Model, Msg)` ties the loop to the runtime. A zero-config app never writes this — the build graph generates it (for a TypeScript core, over the compiled core's model) — but it is ordinary code you can own any time. From the Zig template's `main`:
```zig
const CounterApp = native_sdk.UiApp(Model, Msg);
+1 -1
View File
@@ -72,7 +72,7 @@ Run the app's test suite, printing the zig build summary (step/test tally) plus
native check [dir] [--strict]
```
Validate the whole tree without building the app. A TypeScript core (`src/core.ts`) runs the subset checker first — real tsc semantics plus the app-core rules, diagnostics verbatim — then every `src/**.native` markup file and `app.zon` are checked as before. With a fresh model contract (`zig-out/model-contract.zon`, refreshed by `native test`) it also checks bindings, iterables, and message tags against your `Model`/`Msg` — for a TypeScript core, against its emitted model — and warns on model state no view uses. Without the artifact it degrades to structural checking and says so loudly: "model contract: not yet built - bindings checked structurally only; run `native test` to enable typed checks". Markup accessibility findings are reported per file in full, and a failing `src/*.native` file that no Zig source embeds gets a leftover-file hint.
Validate the whole tree without building the app. A TypeScript core (`src/core.ts`) runs the subset checker first — real tsc semantics plus the app-core rules, diagnostics verbatim — then every `src/**.native` markup file and `app.zon` are checked as before. With a fresh model contract (`zig-out/model-contract.zon`, refreshed by `native test`) it also checks bindings, iterables, and message tags against your `Model`/`Msg` — for a TypeScript core, against its model contract — and warns on model state no view uses. Without the artifact it degrades to structural checking and says so: "model contract: not yet built - bindings checked structurally only; run `native test` to enable typed checks". Markup accessibility findings are reported per file in full, and a failing `src/*.native` file that no Zig source embeds gets a leftover-file hint.
<dl>
<dt><code>--strict</code></dt>
+1 -1
View File
@@ -4,7 +4,7 @@ import { CodeToggle } from "@/components/code-toggle";
# Scroll
A scroll view: wrap multiple children in a single column inside it. The engine owns wheel, kinetic, and keyboard scrolling and draws the scrollbar while a scroll is in flight; `on-scroll` names a Msg variant with a `canvas.ScrollState` payload — or, in a transpiled TypeScript core, a declared record of the same two-axis fields (`offset_x`/`offset_y`, `velocity_x`/`velocity_y`, `viewport_extent_x`/`viewport_extent_y`, `content_extent_x`/`content_extent_y`), matched by name — that delivers the post-scroll offsets and viewport/content extents on both axes, so the model can observe position without owning it. Echo `offset_y` into a model field bound as `value` and the model owns the position too: setting the field scrolls the region (the controlled-scroll shape).
A scroll view: wrap multiple children in a single column inside it. The engine owns wheel, kinetic, and keyboard scrolling and draws the scrollbar while a scroll is in flight; `on-scroll` names a Msg variant with a `canvas.ScrollState` payload — or, in a compiled TypeScript core, a declared record of the same two-axis fields (`offset_x`/`offset_y`, `velocity_x`/`velocity_y`, `viewport_extent_x`/`viewport_extent_y`, `content_extent_x`/`content_extent_y`), matched by name — that delivers the post-scroll offsets and viewport/content extents on both axes, so the model can observe position without owning it. Echo `offset_y` into a model field bound as `value` and the model owns the position too: setting the field scrolls the region (the controlled-scroll shape).
`axis` declares which axes the region scrolls: `vertical` (the default), `horizontal`, or `both`. A horizontal grant opts the region into wheel/trackpad `delta_x`, a bottom-edge scrollbar, and the horizontal keymap — Left/Right step in lines, and a horizontal-only region takes Home/End/PageUp/PageDown on its one axis too. The horizontal offset rides `value-x`, the sideways counterpart of `value` with the same source-wins reconcile. Nested regions route each wheel axis independently: every axis of a scroll gesture travels to the nearest ancestor that scrolls on that axis, so inside a horizontal timeline holding a vertical list, `delta_y` scrolls the list while `delta_x` reaches the timeline — one diagonal gesture, two regions, no fighting. Virtualized scrolls stay vertical (windowed virtualization prices rows, not columns). Scrolling pins at the content edges by default — no rubber-band bounce; kinetic motion stops cleanly at the boundary. `overscroll="rubber_band"` opts one region into bouncing past its edges (both the engine physics and the native macOS scroller honor it), and the `ScrollPhysics.overscroll` design token flips the app-wide default, which per-region values override. `on-reach-end` dispatches a plain Msg when a scroll comes within one viewport of the content end — the infinite-fetch signal, fired once per approach with hysteresis (appending a batch grows the extent and re-arms the next approach). A programmatic jump to the end fires once and never re-arms while the offset stays near the end — re-arming needs a post-scroll observation at least 1.5 viewports from it. Pair with [list](/docs/components/list) for layout-culled rows, or the builder's [virtual list](/docs/components/virtual-list) for dataset-scale windows.
+1 -1
View File
@@ -4,7 +4,7 @@ import { CodeToggle } from "@/components/code-toggle";
# Slider
A draggable value control. The model owns `value` as a 0..1 fraction and renders it back on every rebuild; `on-change` dispatches when the user moves the thumb (drag, keyboard step, or accessibility set-value). In markup, `on-change` resolves by the named Msg arm's shape: a bare tag naming a VALUE arm — `f32`, or a transpiled core's one-number float arm — dispatches the applied fraction as its payload (the seek-bar shape), while a bare tag naming a void arm stays the plain "something changed" signal. Either way, `update` echoes the delivered value back into the model field. Reconcile follows the scroll rule: a source-side move wins (a model-driven value — playback progress on a seek bar — renders every rebuild), a source replaying the same value keeps the user's drag, and a live drag is never yanked mid-gesture. For a display-only bar, use [progress](/docs/components/progress).
A draggable value control. The model owns `value` as a 0..1 fraction and renders it back on every rebuild; `on-change` dispatches when the user moves the thumb (drag, keyboard step, or accessibility set-value). In markup, `on-change` resolves by the named Msg arm's shape: a bare tag naming a VALUE arm — `f32`, or a compiled core's one-number float arm — dispatches the applied fraction as its payload (the seek-bar shape), while a bare tag naming a void arm stays the plain "something changed" signal. Either way, `update` echoes the delivered value back into the model field. Reconcile follows the scroll rule: a source-side move wins (a model-driven value — playback progress on a seek bar — renders every rebuild), a source replaying the same value keeps the user's drag, and a live drag is never yanked mid-gesture. For a display-only bar, use [progress](/docs/components/progress).
<ComponentPreview name="slider" alt="Sliders rendered by the engine" caption="a bound slider and a disabled slider" />
+4 -4
View File
@@ -7,7 +7,7 @@ Native SDK is the complete toolkit for building beautiful native desktop applica
## Prerequisites
- macOS 11 or newer, Linux, or Windows
- Node.js 22.15+ (on the 23 line: 23.5+) for the default TypeScript scaffold — the TypeScript-to-native transpiler and the core dev loop run under it at build and dev time; the binary you ship carries no JS runtime. A Zig-core app (`--template zig-core`) does not need node.
- Node.js 22.15+ (on the 23 line: 23.5+) for the default TypeScript scaffold — the TypeScript frontend (the checker) and the core dev loop run under it at build and dev time, and the external core compiler that builds the checked core to native code ships as an exact-pinned dependency of the CLI; the binary you ship carries no JS runtime. A Zig-core app (`--template zig-core`) does not need node.
## Get the CLI
@@ -65,7 +65,7 @@ This scaffolds a native-rendered app — and nothing else. There are no build fi
</tbody>
</table>
Editor support is stock tsc — no extension, no plugin. `package.json` and `tsconfig.json` exist for editors and versioning only, and `node_modules/@native-sdk/core` is a CLI-managed copy of the SDK package: materialized at init, kept fresh by `native check`/`dev`/`build`, and replaced transparently by `npm install` once the package is published to npm. None of it is build truth — builds transpile against the SDK the CLI ships with and never read node_modules; delete it and every `native` verb still works.
Editor support is stock tsc — no extension, no plugin. `package.json` and `tsconfig.json` exist for editors and versioning only, and `node_modules/@native-sdk/core` is a CLI-managed copy of the SDK package: materialized at init, kept fresh by `native check`/`dev`/`build`, and replaced transparently by `npm install` once the package is published to npm. None of it is build truth — builds check and compile against the SDK the CLI ships with and never read node_modules; delete it and every `native` verb still works.
There is no Zig in this tree and no language flag anywhere: the build detects `src/core.ts` and wires everything (`package.json` is not a language marker). Prefer to write the core in Zig? `native init my_app --template zig-core` scaffolds the same app with `src/main.zig` (plus generated full-loop tests in `src/tests.zig`) — the tree is the truth, and the build detects whichever core it carries. Zig is the language the whole toolkit is built in, and a Zig core is first-class by choice, not a fallback. Prefer to own `build.zig` from day one? Add `--full` to either template.
@@ -239,7 +239,7 @@ That is the whole loop: the model holds state, messages describe what happened,
## Edit while it runs
`src/app.native` is embedded into the binary and watched while `native dev` runs — `native dev` runs a Debug build by default, which is what arms the hot-reload watcher. Edit it — change a label, add a button — and the window updates within a couple of seconds without losing the count. Parse failures keep the last good view on screen.
`src/app.native` is embedded into the binary and watched while `native dev` runs — `native dev` runs a Debug build by default, which is what arms the hot-reload watcher. Edit it — change a label, add a button — and the window updates within a couple of seconds without losing the count. Parse failures keep the last good view on screen. A `src/core.ts` edit is different: the core rebuilds through the external core compiler and the app restarts — a few seconds per rebuild, not sub-second — so for fast logic iteration use `native dev --core` (next section).
## The fastest loop: the core under node
@@ -280,7 +280,7 @@ info[manifest.valid]: app.zon is valid
checked 1 markup file, app.zon and src/core.ts (subset checker clean)
```
The first line is honest about what a fresh tree can check: once a build has produced the model contract, the markup pass also verifies bindings, iterables, and message tags against the core's emitted `Model`/`Msg`. Markup errors come back with `file:line:column` and a teaching message (`native markup lsp` provides the same diagnostics plus completion and hover in your editor). `native test` runs the app's test suite; the Zig template additionally scaffolds `src/tests.zig` — full-loop UI tests that click buttons through typed dispatch, headless, on any machine. See [Testing](/docs/testing) for the full tiers, including driving the live app from the outside with [automation](/docs/automation).
The first line is honest about what a fresh tree can check: once a build has produced the model contract, the markup pass also verifies bindings, iterables, and message tags against the core's `Model`/`Msg`. Markup errors come back with `file:line:column` and a teaching message (`native markup lsp` provides the same diagnostics plus completion and hover in your editor). `native test` runs the app's test suite; the Zig template additionally scaffolds `src/tests.zig` — full-loop UI tests that click buttons through typed dispatch, headless, on any machine. See [Testing](/docs/testing) for the full tiers, including driving the live app from the outside with [automation](/docs/automation).
## Build a release binary
@@ -150,6 +150,6 @@ import { containsIgnoreCase } from "@native-sdk/core/text"; // the SDK library c
```
- **Vendor it under `src/`.** Subset-clean TypeScript compiles into the core like your own modules ([splitting a core into modules](/docs/typescript#splitting-a-core-into-modules)); the subset checker tells you immediately — by rule ID, with the rewrite — whether a vendored file fits. Code that leans on classes, exceptions, or regexes generally wants rewriting rather than vendoring, and the rewrite is usually smaller than the dependency.
- **`@native-sdk/core/*` is the curated library channel**: SDK modules written in the same subset, transpiled into your core when imported and absent when not. Today that is `@native-sdk/core/text` — the byte-splice text engine (caret, selection, IME, case-insensitive search) — and `@native-sdk/core/events` — the canonical event record types markup and the wiring channels match. The channel grows with the toolkit; JSON encoding/parsing over bytes, today demonstrated in `examples/ai-chat-ts/src/api.ts`, is the kind of module it exists to absorb.
- **`@native-sdk/core/*` is the curated library channel**: SDK modules written in the same subset, compiled into your core when imported and absent when not. Today that is `@native-sdk/core/text` — the byte-splice text engine (caret, selection, IME, case-insensitive search) — and `@native-sdk/core/events` — the canonical event record types markup and the wiring channels match. The channel grows with the toolkit; JSON encoding/parsing over bytes, today demonstrated in `examples/ai-chat-ts/src/api.ts`, is the kind of module it exists to absorb.
One thing deliberately does not exist: a package manager for cores. A core's import graph is exactly the files under `src/` plus the SDK modules — the whole program is readable, the build is hermetic, and nothing arrives at build time that you have not checked in.
+14 -12
View File
@@ -2,7 +2,7 @@ import { CodeToggle } from "@/components/code-toggle";
# TypeScript Cores
An app core is the logic tier of a Native SDK app: `Model` (the app state), `Msg` (a discriminated union of everything that can happen), `update(model, msg)` (the one pure transition function), and the pure helpers they call. By default you write it as one TypeScript module — `src/core.ts` — and the `@native-sdk/core` transpiler compiles it to native code at build time. No JS engine ships in the binary: the program either passes the subset checker and compiles to native, or you get a teaching error naming the rule, the fix, and the reason.
An app core is the logic tier of a Native SDK app: `Model` (the app state), `Msg` (a discriminated union of everything that can happen), `update(model, msg)` (the one pure transition function), and the pure helpers they call. By default you write it as one TypeScript module — `src/core.ts` — the `@native-sdk/core` frontend checks it, and the external core compiler builds it to native code at build time. No JS engine ships in the binary: the program either passes the subset checker and compiles to native, or you get a teaching error naming the rule, the fix, and the reason.
This is the two-tier shape of the toolkit: Zig is how everything works — the engine, the runtime, every widget — and TypeScript plus [Native markup](/docs/native-ui) are how applications are authored. A whole app is three files and zero Zig: `src/core.ts`, `src/app.native`, and `app.zon`. Writing the core in Zig instead ([App Model](/docs/app-model)) is first-class by choice — same loop, same runtime — and extending the toolkit itself (custom widgets, host services, render passes) is always Zig.
@@ -119,17 +119,17 @@ export function update(model: Model, msg: Msg): Model {
}
```
Markup binds your model's field names exactly as you wrote them: `nextId` binds as `{nextId}` (the emitted Zig keeps the TS spellings), string-literal unions bind as their member name (`{filter}` renders `all`), and record arrays iterate with `<for each="tasks" as="t" key="id">`. Exported helpers taking exactly one `Model` parameter join the binding surface as derived values — `{doneCount}` reads `doneCount`, and slice-returning ones like `visibleTasks` drive `for each` — so derived data needs no model field. Update-only state nothing in markup binds (host-fired timer arms, bookkeeping fields) is declared once as `export const viewUnbound = ["tick"] as const;` so `native check`'s unbound-state lint stays honest.
Markup binds your model's field names exactly as you wrote them: `nextId` binds as `{nextId}` (the core's model keeps the TS spellings), string-literal unions bind as their member name (`{filter}` renders `all`), and record arrays iterate with `<for each="tasks" as="t" key="id">`. Exported helpers taking exactly one `Model` parameter join the binding surface as derived values — `{doneCount}` reads `doneCount`, and slice-returning ones like `visibleTasks` drive `for each` — so derived data needs no model field. Update-only state nothing in markup binds (host-fired timer arms, bookkeeping fields) is declared once as `export const viewUnbound = ["tick"] as const;` so `native check`'s unbound-state lint stays honest.
## Why the immutable style is free
Everything `update` builds lives in a per-dispatch arena that is freed wholesale after the returned model is committed. At commit, only nodes your update actually created are copied into the persistent model heap — everything you spread through unchanged is shared with the previous model. `{ ...model, tasks: model.tasks.map(...) }` copies one small struct and one pointer array, never the world.
Both regions have fixed, build-time capacities (1 MiB each by default): the frame arena bounds one dispatch's transients, the model heap bounds the committed model. They are knobs of the emitted core — `--frame-cap <bytes>` / `--heap-cap <bytes>` on the transpiler CLI — and never grow at runtime, so binaries stay allocation-free and replay stays trivially deterministic. Overflowing one is a loud runtime panic naming the knob to raise, never silent corruption.
Both regions have fixed, build-time capacities (1 MiB each by default): the frame arena bounds one dispatch's transients, the model heap bounds the committed model. Neither grows at runtime, so binaries stay allocation-free and replay stays trivially deterministic. Overflowing one is a defined runtime panic naming the region, never silent corruption.
## The subset posture
App cores are written in a closed subset of TypeScript, and the subset means one precise thing: TypeScript minus the ecosystem minus the purity violations — never minus basic syntax. Every basic statement, operator, and declaration form compiles: plain interfaces, discriminated unions, `switch` (with `default` arms), every loop shape (`for`, `for...of`, `while`, `do...while`, labels with labeled `break`/`continue`), the full operator and assignment family (`**`, shifts, `+=` through `??=`), const record destructuring, namespace imports, spreads, the array methods (`.map`/`.filter`/`.find`/`.reduce`/`.toSorted`/...), `Math`, template literals — everything with exact JS semantics, pinned so node and native always agree (a machine-checked grammar matrix classifies every production of the language, so nothing is missing by accident). Classes and exceptions compile too: data classes (fields, a constructor, methods, `static` methods and `static readonly` consts, erased `private`/`protected` — `new Task(...)`, `this.count`, `Task.fromRow(...)`, mutation under the same local-ownership rule as arrays) emit as plain structs plus functions, and `throw`/`try`/`catch`/`finally` is deterministic control flow — a thrown kind-tagged subset value unwinds to the nearest catch (several distinct shapes may throw; the checker collects them into the core's thrown union, and `catch (e)` narrows it with plain kind tests, no `as` ceremony), `finally` runs on every path, and an uncaught throw is a defined panic exactly where node would crash. What isn't available is exactly two families: the ecosystem the binary cannot carry (npm packages, regexes, `JSON`, Promises, `eval` — no JS engine ships) and constructs that would break the core's guarantees (class inheritance, `async`/`await` — asynchrony is command data, `Map`/`Set`, module-level `let`, `Date.now()`/`Math.random()` inside `update`, runtime type tests, text as indexable strings — a core's text is bytes). Each has an idiomatic replacement the checker teaches by ID (NS1001NS1059) — kind-tagged error shapes narrowed in the catch, time and randomness arrive as message payloads, keyed data is an id-keyed array. Immutability is a rule about SHARED data, not a style: mutation is legal on locally-owned arrays — a scratch array your function creates (a literal or a `.slice()` copy) takes `push`/`pop`/`splice`/in-place `sort`, the `xs[xs.length] = v` append, and the rest with exact JS semantics until the value escapes; a `let` reassigned only from fresh copies stays owned, passing into a `readonly T[]` reader parameter borrows instead of escaping, and the checker teaches only at the real boundaries. Generics are ordinary TypeScript too: a module-level generic function, interface, or type alias monomorphizes per call site from tsc's own resolved type arguments — one readable native function per instantiation. These rules scope to app cores, the logic tier; they say nothing about the TypeScript you write anywhere else. Where the npm ecosystem fits — calling APIs (AI endpoints included), embedding npm-heavy web UIs, running node as a worker, vendoring utilities — has its own page: [Where Packages Go](/docs/typescript/packages).
App cores are written in a closed subset of TypeScript, and the subset means one precise thing: TypeScript minus the ecosystem minus the purity violations — never minus basic syntax. Every basic statement, operator, and declaration form compiles: plain interfaces, discriminated unions, `switch` (with `default` arms), every loop shape (`for`, `for...of`, `while`, `do...while`, labels with labeled `break`/`continue`), the full operator and assignment family (`**`, shifts, `+=` through `??=`), const record destructuring, namespace imports, spreads, the array methods (`.map`/`.filter`/`.find`/`.reduce`/`.toSorted`/...), `Math`, template literals — everything with exact JS semantics, pinned so node and native always agree (a machine-checked grammar matrix classifies every production of the language, so nothing is missing by accident). Classes and exceptions compile too: data classes (fields, a constructor, methods, `static` methods and `static readonly` consts, erased `private`/`protected` — `new Task(...)`, `this.count`, `Task.fromRow(...)`, mutation under the same local-ownership rule as arrays) compile to plain structs plus functions, and `throw`/`try`/`catch`/`finally` is deterministic control flow — a thrown kind-tagged subset value unwinds to the nearest catch (several distinct shapes may throw; the checker collects them into the core's thrown union, and `catch (e)` narrows it with plain kind tests, no `as` ceremony), `finally` runs on every path, and an uncaught throw is a defined panic exactly where node would crash. What isn't available is exactly two families: the ecosystem the binary cannot carry (npm packages, regexes, `JSON`, Promises, `eval` — no JS engine ships) and constructs that would break the core's guarantees (class inheritance, `async`/`await` — asynchrony is command data, `Map`/`Set`, module-level `let`, `Date.now()`/`Math.random()` inside `update`, runtime type tests, text as indexable strings — a core's text is bytes). Each has an idiomatic replacement the checker teaches by ID (NS1001NS1059) — kind-tagged error shapes narrowed in the catch, time and randomness arrive as message payloads, keyed data is an id-keyed array. Immutability is a rule about SHARED data, not a style: mutation is legal on locally-owned arrays — a scratch array your function creates (a literal or a `.slice()` copy) takes `push`/`pop`/`splice`/in-place `sort`, the `xs[xs.length] = v` append, and the rest with exact JS semantics until the value escapes; a `let` reassigned only from fresh copies stays owned, passing into a `readonly T[]` reader parameter borrows instead of escaping, and the checker teaches only at the real boundaries. Generics are ordinary TypeScript too: a module-level generic function, interface, or type alias monomorphizes per call site from tsc's own resolved type arguments — one native function per instantiation. These rules scope to app cores, the logic tier; they say nothing about the TypeScript you write anywhere else. Where the npm ecosystem fits — calling APIs (AI endpoints included), embedding npm-heavy web UIs, running node as a worker, vendoring utilities — has its own page: [Where Packages Go](/docs/typescript/packages).
<CodeToggle>
@@ -144,7 +144,7 @@ export function lastNum(ns: readonly number[]): number { return pick(ns, ns.leng
```
```zig
// The emitted core: one monomorphic fn per distinct instantiation, deduped.
// The Zig-core equivalent: one monomorphic fn per distinct instantiation.
pub fn pick__Task(xs: []const Task, i: i64) Task {
return xs[uz(i)];
}
@@ -164,9 +164,9 @@ const label = asciiBytes(`${done} of ${total} done`); // per-dispatch bytes
const seed = asciiBytes("Stretch"); // rodata, free to commit
```
The transpiler folds every `asciiBytes` call at compile time; under node the same import runs as a plain function with the same result. Observing a `string`'s code units (`.length`, `s[i]`) is a taught error because UTF-16 and UTF-8 would disagree, and `+` concatenation is taught away because runtime string building needs a JS string heap the binary does not carry.
The compiler folds every `asciiBytes` call at compile time; under node the same import runs as a plain function with the same result. Observing a `string`'s code units (`.length`, `s[i]`) is a taught error because UTF-16 and UTF-8 would disagree, and `+` concatenation is taught away because runtime string building needs a JS string heap the binary does not carry.
Bytes still read like text: the everyday string methods work directly on `Uint8Array` values, with **byte-honest semantics** — every length, offset, and index is a BYTE length/offset (never a character count: `é` measures 2), search is byte-wise, and case mapping is Unicode simple case mapping (code point to code point from the Unicode tables, locale-free, no special casing — `ß` stays `ß`; invalid UTF-8 passes through unchanged). The native build lowers each call onto the runtime kernel and node runs the same methods from the same generated tables, so both produce identical bytes by construction.
Bytes still read like text: the everyday string methods work directly on `Uint8Array` values, with **byte-honest semantics** — every length, offset, and index is a BYTE length/offset (never a character count: `é` measures 2), search is byte-wise, and case mapping is Unicode simple case mapping (code point to code point from the Unicode tables, locale-free, no special casing — `ß` stays `ß`; invalid UTF-8 passes through unchanged). The compiled core and node run the same methods from the same generated tables, so both produce identical bytes by construction.
<table>
<thead>
@@ -361,7 +361,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
}
```
External sources — sockets, file watchers, native worker threads — reach `update` through a channel. `Cmd.channelOpen(key, { event })` opens a long-lived stream under an app-chosen numeric key, and every event dispatches the one `event` arm as a five-field record; `state` must be a named string-literal-union alias carrying exactly the three members — a narrower union would silently drop states the host emits, so the build refuses it. Posting is not a TS verb: transpiled cores are single-threaded by design, so the posting handle lives on the native side (`Effects.channelHandle(key)`), where embedders and platform-services extensions post bytes from their own threads. Back-pressure is honest — posts the native handle refused count into `droppedPending`/`droppedTotal` on the next delivered event, never silence — and a duplicate open on a live key dispatches `rejected`. `Cmd.channelClose(key)` ends the stream: staged posts flush, exactly one `closed` event carries the final totals, and the key frees.
External sources — sockets, file watchers, native worker threads — reach `update` through a channel. `Cmd.channelOpen(key, { event })` opens a long-lived stream under an app-chosen numeric key, and every event dispatches the one `event` arm as a five-field record; `state` must be a named string-literal-union alias carrying exactly the three members — a narrower union would silently drop states the host emits, so the build refuses it. Posting is not a TS verb: compiled cores are single-threaded by design, so the posting handle lives on the native side (`Effects.channelHandle(key)`), where embedders and platform-services extensions post bytes from their own threads. Back-pressure is honest — posts the native handle refused count into `droppedPending`/`droppedTotal` on the next delivered event, never silence — and a duplicate open on a live key dispatches `rejected`. `Cmd.channelClose(key)` ends the stream: staged posts flush, exactly one `closed` event carries the final totals, and the key frees.
```ts:src/core.ts
import { Cmd } from "@native-sdk/core";
@@ -463,7 +463,7 @@ A markup text control (`<text-field text="{draft}" on-input="draft_edit" />`) ne
## Splitting a core into modules
A core that outgrows one file splits into modules under `src/`: relative imports spelled with their real filenames (`./parsers.ts` — the same file runs under node, whose loader resolves real files), `src/` as the hard boundary (`../` and npm packages are teaching errors), and no runtime cycles (`import type` back-edges are fine and idiomatic — a helper module typically type-imports `Model` from the entry). Export lists and value re-exports are ordinary module surface: `export { helper, doneCount as remaining }` binds names over existing declarations, and `export { parsePs } from "./parsers.ts"` forwards another module's export by name — what stays out is `export default`, `export =`, and `export * from` (the flat emitted namespace resolves by name, so every export names what it binds). `core.ts` stays the entry module and the app's public face: `update`, `initialModel`, `subscriptions`, the wiring channels, and the exported binding helpers live there (declared and exported under their own names — a rename or re-export cannot bind an entry point), and imported modules hold the machinery they call. The SDK also ships library modules in the same subset — `@native-sdk/core/text` is the byte-splice text engine (caret, selection, IME composition, ASCII case-insensitive compare), and `@native-sdk/core/events` is the canonical event record types (`TextInputEvent` re-exported, `ScrollState`, `FrameEvent`, `KeyEvent`, `PinchPhase`/`PinchEvent`, `ColorScheme`, the chrome records, `AudioState`/`AudioEvent`) so no core re-types the vocabulary — transpiled into your core when imported and absent when not. Everything still emits as ONE readable native module, one section per source file.
A core that outgrows one file splits into modules under `src/`: relative imports spelled with their real filenames (`./parsers.ts` — the same file runs under node, whose loader resolves real files), `src/` as the hard boundary (`../` and npm packages are teaching errors), and no runtime cycles (`import type` back-edges are fine and idiomatic — a helper module typically type-imports `Model` from the entry). Export lists and value re-exports are ordinary module surface: `export { helper, doneCount as remaining }` binds names over existing declarations, and `export { parsePs } from "./parsers.ts"` forwards another module's export by name — what stays out is `export default`, `export =`, and `export * from` (the core's flat namespace resolves by name, so every export names what it binds). `core.ts` stays the entry module and the app's public face: `update`, `initialModel`, `subscriptions`, the wiring channels, and the exported binding helpers live there (declared and exported under their own names — a rename or re-export cannot bind an entry point), and imported modules hold the machinery they call. The SDK also ships library modules in the same subset — `@native-sdk/core/text` is the byte-splice text engine (caret, selection, IME composition, ASCII case-insensitive compare), and `@native-sdk/core/events` is the canonical event record types (`TextInputEvent` re-exported, `ScrollState`, `FrameEvent`, `KeyEvent`, `PinchPhase`/`PinchEvent`, `ColorScheme`, the chrome records, `AudioState`/`AudioEvent`) so no core re-types the vocabulary — compiled into your core when imported and absent when not.
<CodeToggle>
@@ -494,15 +494,17 @@ pub fn parseSample(bytes: []const u8) ?Sample { ... }
`native dev --core` is the fastest loop for logic work: the core runs under node with a virtual host — dispatch Msgs as JSON lines (`{"kind":"add"}`, `{"$bytes":"…"}` for bytes payloads), advance a virtual clock (`{"advance":1000}`) to fire timers deterministically, and watch the committed model and effect transcript. Effects the virtual host does not perform (files, fetch, spawn) print as `cmd ...` lines — feed their results back yourself as ordinary Msg lines; that is the point, results are plain messages. Pair `--script msgs.ndjson` with `--watch` to replay a scenario on every edit. [Quick Start](/docs/quick-start#the-fastest-loop-the-core-under-node) shows a full transcript.
`native dev` keeps markup instant — `.native` edits hot-reload into the running window — but a `src/core.ts` edit rebuilds the core through the external core compiler and restarts the app: seconds per rebuild (roughly 3-6s warm), not sub-second. The core loop in the real window is restart-shaped; keep logic iteration under `native dev --core` and rebuild when you want to see it live.
`native check` runs the subset checker (real tsc semantics plus the app-core rules) over `src/core.ts` and its whole import graph (diagnostics carry each module's own path), then validates markup and `app.zon`. Every diagnostic names the rule, the idiomatic rewrite, and the reason — write to them up front and the loop stays fast.
## Editor support
Editor support is stock tsc — no extension, no plugin. The scaffold ships `package.json` and `tsconfig.json` as the editor-and-versioning surface: the tsconfig mirrors the compiler options the checker itself builds its program with (strict, `moduleResolution: "bundler"`, `verbatimModuleSyntax`, `exactOptionalPropertyTypes`, …), so what your editor flags is what `native check` flags, and `@native-sdk/core` (plus subpaths like `@native-sdk/core/text`) resolves through `node_modules` like any package. Until `@native-sdk/core` is published to npm, the CLI materializes that `node_modules` copy itself — exactly the files the published package will contain — and `native check`/`dev`/`build` keep it fresh against the SDK (`native doctor` reports skew). After the publish, a plain `npm install` writes identical content and takes over. None of it is build truth: builds transpile against the SDK the CLI ships with and never read `node_modules` — delete it and every `native` verb still works.
Editor support is stock tsc — no extension, no plugin. The scaffold ships `package.json` and `tsconfig.json` as the editor-and-versioning surface: the tsconfig mirrors the compiler options the checker itself builds its program with (strict, `moduleResolution: "bundler"`, `verbatimModuleSyntax`, `exactOptionalPropertyTypes`, …), so what your editor flags is what `native check` flags, and `@native-sdk/core` (plus subpaths like `@native-sdk/core/text`) resolves through `node_modules` like any package. Until `@native-sdk/core` is published to npm, the CLI materializes that `node_modules` copy itself — exactly the files the published package will contain — and `native check`/`dev`/`build` keep it fresh against the SDK (`native doctor` reports skew). After the publish, a plain `npm install` writes identical content and takes over. None of it is build truth: builds check and compile against the SDK the CLI ships with and never read `node_modules` — delete it and every `native` verb still works.
## Reading the output: the eject story
## Outgrowing the subset
The transpiler emits ordinary, readable Zig — your names are your names (fields, helpers, and locals keep their TS spellings), your switch arms become tagged-union switches, one commented module. `native check` leaves the latest emission in `.native/check/core.zig`, and the transpiler CLI writes it wherever you point `-o`. If an app outgrows the subset, that emitted module is the migration path: adopt it as handwritten source (the [App Model](/docs/app-model) page covers the Zig wiring) and keep building — nothing about the runtime changes, because the emitted core was already an ordinary Zig app core.
The compiled core is a native static archive, not generated source: `native check` checks and leaves nothing behind, and there is no emitted Zig to read or adopt. If an app outgrows the subset, the migration path is porting the core to Zig by hand — the [App Model](/docs/app-model) page covers the Zig wiring, and the wiring the build generates for a TypeScript app doubles as the port's blueprint: same loop, same runtime, so the port is a translation of `update` and friends, not a redesign.
## Where the subset ends
+5 -5
View File
@@ -139,10 +139,10 @@ Add a case by creating `cases/<name>/eval.json` (see `src/types.ts` for the sche
Cases with `"frontend": "ts-core"` measure the other authoring surface: the app core written in the TypeScript subset and compiled by `packages/core`. The scaffold is not an app — it is `src/core.ts` (the case's `starter/` overlay when it ships one, else a minimal counter core), a README with the check loop, and the `ts-core` skill delivered via `native skills get ts-core`. Two graders replace build/markup/snapshot:
- `ts_transpile` — the transpiler must exit clean on `src/core.ts`: tsc-semantics typecheck, every subset rule (NS1001-NS1050), Zig emission. Failing diagnostics stay in the result as the violation evidence.
- `ts_harness` — behavioral grading: transpile the core, assemble a scratch dir with the emitted `core.zig`, the rt kernel, and the case's `harness.zig`, then `zig test harness.zig`. The harness drives the real dispatch cycle (`update``commitModelRoot``frameReset`) and asserts the prompt's requirements, so the case prompt pins the Model/Msg/export contract exactly (an API spec, not a solution).
- `ts_transpile` — the frontend check must exit clean on `src/core.ts`: tsc-semantics typecheck, every subset rule (NS1001-NS1050) — no Zig emission; the literal name is the `eval.json` check key, the semantics are `ts_check`. Failing diagnostics stay in the result as the violation evidence.
- `ts_harness` — behavioral grading: compile the core through the external core compiler and drive the compiled archive through a generated mirror — the scratch dir carries the mirror module, the archive, and the case's `harness.zig`, then `zig test harness.zig`. The harness drives the real dispatch cycle (`update``commitModelRoot``frameReset`) and asserts the prompt's requirements, so the case prompt pins the Model/Msg/export contract exactly (an API spec, not a solution).
Because the grading harness compiles against the agent's code, ts-core prompts pin names; behavior stays requirements-only. The transpiler compiles pure `update(model, msg): Model` cores and effectful `Model | [Model, Cmd<Msg>]` pair-returns (the Cmd surface); the wave-2 dual-track cases below are the effects coverage.
Because the grading harness compiles against the agent's code, ts-core prompts pin names; behavior stays requirements-only. The compiler handles pure `update(model, msg): Model` cores and effectful `Model | [Model, Cmd<Msg>]` pair-returns (the Cmd surface); the wave-2 dual-track cases below are the effects coverage.
The ts-core cases:
@@ -155,7 +155,7 @@ The ts-core cases:
Wave 1's four ts-* cases measured subset compliance on toy katas and compared against Zig numbers gathered **before** the `zig` 0.16-idioms skill existed. Wave 2 replaces that comparison with an honest, contemporaneous one: six realistic asks, each ONE language-blind spec (`"frontend": "app-dual"`) that runs on **both authoring tracks**`<case>@ts` scaffolds a full TypeScript app (`native init --frontend native --template ts-core`), `<case>@zig` the Zig app template (`--template zig-core`). Identical prompt, identical shared checks, one behavioral spec asserted by two thin per-track harnesses; `--track ts|zig` selects a lane, the default runs both.
Grading per track: the shared checks (`native test -Dplatform=null`, markup check, view greps, the judge with a case rubric) plus the track's behavioral harness. On the ts track, `ts_harness` transpiles `src/core.ts` (its whole import graph) and `zig test`s the case's `harness-ts.zig` against the emitted core, the rt kernel, and `harness-lib/cmdview.zig` — a decoder over the Cmd/Sub wire format, so harnesses assert effects semantically ("one GET to the pinned URL", "the delay re-armed on the same key"). On the zig track, `zig_harness` injects the case's `harness-zig.zig` into the workspace as `src/eval_behavior_spec.zig` (a test import appended to `src/main.zig`, restored afterward) and runs `native test`, so it compiles against the agent's real Model/Msg/update and drives the SDK's deterministic **fake effects executor** (`fx.executor = .fake`, `pendingSpawnAt`/`feedLine`/`feedExit`/`fireTimer`/`feedResponse`/`feedFileResult`).
Grading per track: the shared checks (`native test -Dplatform=null`, markup check, view greps, the judge with a case rubric) plus the track's behavioral harness. On the ts track, `ts_harness` compiles `src/core.ts` (its whole import graph) through the external core compiler and `zig test`s the case's `harness-ts.zig` against the compiled core's generated mirror and `harness-lib/cmdview.zig` — a decoder over the Cmd/Sub wire format, so harnesses assert effects semantically ("one GET to the pinned URL", "the delay re-armed on the same key"). On the zig track, `zig_harness` injects the case's `harness-zig.zig` into the workspace as `src/eval_behavior_spec.zig` (a test import appended to `src/main.zig`, restored afterward) and runs `native test`, so it compiles against the agent's real Model/Msg/update and drives the SDK's deterministic **fake effects executor** (`fx.executor = .fake`, `pendingSpawnAt`/`feedLine`/`feedExit`/`fireTimer`/`feedResponse`/`feedFileResult`).
The cases — every prompt reads like a real user ask, and every effect result is fed by the harness (no network, no processes, no clocks during grading):
@@ -172,7 +172,7 @@ Starters (`starter-ts/`, `starter-zig/`) overlay the scaffold for the feature-ad
### Authoring metrics
`pnpm metrics results/<stamp> [...]` post-processes finished runs' transcripts into the agent-authoring metrics the checks cannot see, per case and per track — **ts** (the ts-core cases and the `@ts` side of dual cases) vs **zig** (the pre-existing native cases and the `@zig` side): **first-pass compliance** (did the agent's first compliance check after touching sources pass — the transpiler run, `native check`, `native test`, or `native build` on the ts track; `native test` / `native build` / `native check` / `native markup check` on the zig track), **retries-to-green** (failing compliance runs before the first green), **teaching-error encounters** (failing compliance runs that carried a teaching diagnostic — the "did the diagnostics work" round-trip count wave 2 compares across tracks), **violation taxonomy** (NS/TS rule IDs on the ts track; zig error lines on the zig track, with `no member named 'X'` bucketed by member so the 0.16-idiom class is visible) raw and per 1k generated LOC (lines written through Write/Edit to source files, `.native` markup included), and **task success** (the run's own pass verdict). Harness friction — permission-refused commands, errored compounds with no diagnostic in the output — is dropped from the event stream so it never masquerades as an authoring failure. It writes `authoring-metrics.json` next to each `summary.json`.
`pnpm metrics results/<stamp> [...]` post-processes finished runs' transcripts into the agent-authoring metrics the checks cannot see, per case and per track — **ts** (the ts-core cases and the `@ts` side of dual cases) vs **zig** (the pre-existing native cases and the `@zig` side): **first-pass compliance** (did the agent's first compliance check after touching sources pass — the frontend check, `native check`, `native test`, or `native build` on the ts track; `native test` / `native build` / `native check` / `native markup check` on the zig track), **retries-to-green** (failing compliance runs before the first green), **teaching-error encounters** (failing compliance runs that carried a teaching diagnostic — the "did the diagnostics work" round-trip count wave 2 compares across tracks), **violation taxonomy** (NS/TS rule IDs on the ts track; zig error lines on the zig track, with `no member named 'X'` bucketed by member so the 0.16-idiom class is visible) raw and per 1k generated LOC (lines written through Write/Edit to source files, `.native` markup included), and **task success** (the run's own pass verdict). Harness friction — permission-refused commands, errored compounds with no diagnostic in the output — is dropped from the event stream so it never masquerades as an authoring failure. It writes `authoring-metrics.json` next to each `summary.json`.
## CI
+1 -1
View File
@@ -16,7 +16,7 @@ const ALLOWED_TOOLS = [
"Grep",
"Bash(zig *)",
"Bash(native *)",
// ts-core cases: the agent's check loop is the @native-sdk/core transpiler run
// ts-core cases: the agent's check loop is the @native-sdk/core frontend check
// through node, and the subset runs under node for behavioral pokes.
"Bash(node *)",
"Bash(ls *)",
+1 -1
View File
@@ -7,7 +7,7 @@
// the deterministic checks cannot see:
//
// - first-pass compliance: did the FIRST compliance check the agent ran after
// first touching the sources pass? (ts-core: the @native-sdk/core transpiler run;
// first touching the sources pass? (ts-core: the @native-sdk/core frontend check;
// native: `native test` / `zig build test`.) Pre-edit runs don't count —
// starters compile clean, so they would grade the scaffold, not the agent.
// - retries-to-green: failing compliance runs before the first passing one
+2 -2
View File
@@ -11,7 +11,7 @@
// the agent itself ran and its exit status. So `firstGreenTurn` is the first
// time the agent's OWN verification loop went green (the same command set the
// graders' build_test/transpile checks run: `native test`, `zig build test`,
// `native check`, the @native-sdk/core transpiler CLI, `markup check`) after
// `native check`, the @native-sdk/core frontend CLI, `markup check`) after
// the first source edit — a proxy for "the work was done", not a claim that
// the full graded check set (file greps, behavioral harnesses, snapshots)
// passed at that turn. It can read early (agent's check is weaker than the
@@ -207,7 +207,7 @@ export function isSourceFile(path: string, track: MetricsTrack): boolean {
/**
* The commands that constitute a compliance check. ts track: any invocation
* of the @native-sdk/core transpiler CLI (typecheck + subset rules +
* of the @native-sdk/core frontend CLI (typecheck + subset rules
* emission), plus the app verbs an app-workspace loop runs (`native check`
* runs the same checker; `native test`/`native build` compile the emitted
* core and the markup bindings). zig track: the app build/test verbs the
+95 -23
View File
@@ -9,7 +9,7 @@ import type { Workspace } from "./scaffold.ts";
export interface GradeContext {
workspace: Workspace;
/** SDK repo root (the @native-sdk/core transpiler and rt kernel live here). */
/** SDK repo root (the @native-sdk/core frontend and the external core compiler live here). */
repoRoot: string;
/** The case directory (cases/<name>): ts_harness reads harness.zig from it. */
caseDir: string;
@@ -67,7 +67,7 @@ function checkDescription(check: CheckSpec): string {
case "snapshot_grep":
return `snapshot: ${check.description}`;
case "ts_transpile":
return `@native-sdk/core transpile ${check.entry ?? "src/core.ts"}`;
return `@native-sdk/core check ${check.entry ?? "src/core.ts"}`;
case "ts_harness":
return `harness: ${check.description}`;
case "zig_harness":
@@ -108,24 +108,26 @@ async function runCheck(check: CheckSpec, context: GradeContext): Promise<Pendin
}
}
/** Path to the @native-sdk/core transpiler CLI inside the SDK repo. */
function transpilerCli(repoRoot: string): string {
/** Path to the @native-sdk/core frontend CLI inside the SDK repo. */
function frontendCli(repoRoot: string): string {
return join(repoRoot, "packages", "core", "src", "cli.ts");
}
/**
* Compliance grading for ts-core cases: the core module must typecheck (tsc
* semantics), pass every subset rule, and emit Zig. Failing diagnostics stay
* in the detail — the NS rule IDs there are the violation taxonomy.
* semantics) and pass every subset rule — the frontend's check-only pass,
* the exact gate every build runs before the external core compiler takes
* the graph. Failing diagnostics stay in the detail — the NS rule IDs there
* are the violation taxonomy.
*/
async function tsTranspile(check: TsTranspileCheck, context: GradeContext): Promise<PendingResult> {
const entry = check.entry ?? "src/core.ts";
const description = `@native-sdk/core transpile ${entry}`;
const description = `@native-sdk/core check ${entry}`;
const entryPath = join(context.workspace.path, entry);
if (!existsSync(entryPath)) {
return { type: "ts_transpile", description, status: "fail", detail: `${entry} not found in the workspace` };
}
const result = await exec("node", [transpilerCli(context.repoRoot), entryPath, "-o", "/dev/null"], {
const result = await exec("node", [frontendCli(context.repoRoot), entryPath], {
cwd: context.workspace.path,
timeoutMs: 2 * 60 * 1000,
});
@@ -139,10 +141,13 @@ async function tsTranspile(check: TsTranspileCheck, context: GradeContext): Prom
}
/**
* Behavioral grading for ts-core cases: transpile the core, assemble a
* scratch dir with the emitted core.zig, the rt kernel, and the case's
* harness.zig, then `zig test harness.zig`. The harness drives the real
* dispatch cycle and asserts the case's required behavior.
* Behavioral grading for ts-core cases: compile the core through the
* external core compiler (the same pipeline every build runs — frontend
* check + contract, corewire facade/profile, staging, the pinned compile,
* the mirror over the co-emitted sidecar), assemble a scratch dir with the
* mirror as core.zig beside its shim runtime, the archive, and the case's
* harness.zig, then `zig test harness.zig <archive> -lc`. The harness
* drives the real dispatch cycle and asserts the case's required behavior.
*/
async function tsHarness(check: TsHarnessCheck, context: GradeContext): Promise<PendingResult> {
const entry = check.entry ?? "src/core.ts";
@@ -159,23 +164,90 @@ async function tsHarness(check: TsHarnessCheck, context: GradeContext): Promise<
const scratch = join(context.workspace.path, ".harness");
rmSync(scratch, { recursive: true, force: true });
mkdirSync(scratch, { recursive: true });
const transpile = await exec(
const core = join(context.repoRoot, "packages", "core");
// 1. Frontend check + the contract sidecar.
const contract = join(scratch, "core.contract.json");
const checkRun = await exec(
"node",
[transpilerCli(context.repoRoot), entryPath, "-o", join(scratch, "core.zig")],
[frontendCli(context.repoRoot), entryPath, "--contract", contract, "--contract-entry", entry.split("\\").join("/")],
{ cwd: context.workspace.path, timeoutMs: 2 * 60 * 1000 },
);
if (transpile.code !== 0) {
return {
type: "ts_harness",
description,
status: "fail",
detail: `transpile failed:\n${tailLines(transpile)}`,
};
if (checkRun.code !== 0) {
return { type: "ts_harness", description, status: "fail", detail: `frontend check failed:\n${tailLines(checkRun)}` };
}
copyFileSync(join(context.repoRoot, "packages", "core", "rt", "rt.zig"), join(scratch, "rt.zig"));
// 2. corewire, compiled once into the scratch (facade/profile + mirror).
const corewire = join(scratch, "corewire");
const buildCorewire = await exec(
"zig",
["build-exe", join(context.repoRoot, "tools", "corewire", "main.zig"), `-femit-bin=${corewire}`],
{ cwd: scratch, timeoutMs: 5 * 60 * 1000 },
);
if (buildCorewire.code !== 0) {
return { type: "ts_harness", description, status: "fail", detail: `corewire build failed:\n${tailLines(buildCorewire)}` };
}
const facade = join(scratch, "core_facade.ts");
const profile = join(scratch, "core_profile.json");
const project = await exec(corewire, ["--sidecar", contract, "--facade", facade, "--profile", profile], {
cwd: scratch,
timeoutMs: 60 * 1000,
});
if (project.code !== 0) {
return { type: "ts_harness", description, status: "fail", detail: `corewire projection failed:\n${tailLines(project)}` };
}
// 3. Stage and compile through the pinned external toolchain.
const stage = join(scratch, "stage");
const staged = await exec(
"node",
[
join(core, "scripts", "stage_external_core.mjs"),
"--src", join(context.workspace.path, "src"),
"--sdk", join(core, "sdk"),
"--static", join(core, "compile-surface", "core.ts"),
"--facade", facade,
"--profile", profile,
"--out", stage,
],
{ cwd: scratch, timeoutMs: 60 * 1000 },
);
if (staged.code !== 0) {
return { type: "ts_harness", description, status: "fail", detail: `compile staging failed:\n${tailLines(staged)}` };
}
const archive = join(scratch, "libeval_core.a");
const compiledSidecar = join(scratch, "compiled.contract.json");
const compile = await exec(
"node",
[
join(core, "scripts", "run_external_core_compiler.mjs"),
"--stage", stage,
"--name", "eval_core",
"--manifest", join(core, "package.json"),
"--out-archive", archive,
"--out-sidecar", compiledSidecar,
"--compiler-js", join(core, "node_modules", "scriptc", "dist", "main.js"),
],
{ cwd: scratch, timeoutMs: 10 * 60 * 1000 },
);
if (compile.code !== 0) {
return { type: "ts_harness", description, status: "fail", detail: `external core compile failed:\n${tailLines(compile)}` };
}
// 4. The mirror over the archive's OWN co-emitted contract, staged as
// the harness's core.zig beside its shim runtime.
const mirror = await exec(corewire, ["--sidecar", compiledSidecar, "--out", join(scratch, "core.zig")], {
cwd: scratch,
timeoutMs: 60 * 1000,
});
if (mirror.code !== 0) {
return { type: "ts_harness", description, status: "fail", detail: `mirror generation failed:\n${tailLines(mirror)}` };
}
copyFileSync(join(context.repoRoot, "tools", "corewire", "shim_rt.zig"), join(scratch, "shim_rt.zig"));
copyFileSync(join(context.repoRoot, "tools", "corewire", "core_abi.zig"), join(scratch, "core_abi.zig"));
copyFileSync(harnessPath, join(scratch, "harness.zig"));
copyHarnessLib(context, scratch);
const test = await exec("zig", ["test", "harness.zig"], {
const test = await exec("zig", ["test", "harness.zig", archive, "-lc"], {
cwd: scratch,
timeoutMs: 10 * 60 * 1000,
});
+16 -52
View File
@@ -145,7 +145,7 @@ async function scaffoldAppWorkspace(
* core), a README documenting the check loop, and the ts-core skill
* delivered along the documented user path (`native skills get ts-core`).
* No app scaffold: the core module is the whole deliverable, graded through
* the @native-sdk/core transpiler and the case's zig-test harness.
* the @native-sdk/core frontend and the case's zig-test harness.
*/
async function scaffoldTsCoreWorkspace(
repoRoot: string,
@@ -202,8 +202,7 @@ export function update(model: Model, msg: Msg): Model {
`;
function tsCoreReadme(repoRoot: string): string {
const transpiler = join(repoRoot, "packages", "core", "src", "cli.ts");
const rt = join(repoRoot, "packages", "core", "rt", "rt.zig");
const frontend = join(repoRoot, "packages", "core", "src", "cli.ts");
return `# App-core workspace
This workspace holds one deliverable: \`src/core.ts\`, an app core written in the
@@ -212,24 +211,15 @@ app-core TypeScript subset. The authoring guide is
## Check loop
Transpile after every meaningful edit; the diagnostics teach the rule, the fix,
Check after every meaningful edit; the diagnostics teach the rule, the fix,
and the reason:
\`\`\`sh
node ${transpiler} src/core.ts -o /tmp/core.zig
node ${frontend} src/core.ts
\`\`\`
Exit 0 means the module typechecks, passes the subset checker, and emits Zig.
To sanity-check behavior natively, build the emitted core against the runtime
kernel with a scratch test file that imports both:
\`\`\`sh
mkdir -p .check && cp ${rt} .check/rt.zig
node ${transpiler} src/core.ts -o .check/core.zig
# write .check/smoke.zig with zig tests importing core.zig, then:
cd .check && zig test smoke.zig
\`\`\`
Exit 0 means the module typechecks and passes the subset checker — the exact
gate every build runs before the external core compiler takes the graph.
The subset is erasable TypeScript, so node can also import \`src/core.ts\`
directly for quick behavioral pokes — semantics match the native build.
@@ -260,50 +250,24 @@ export async function prewarmWorkspace(
}
/**
* Pre-warm a ts-core workspace: transpile the starter core once (proves the
* scaffold compiles) and `zig test` a trivial harness against it so the zig
* std/test-runner graph is cached before the agent's own check loops and the
* ts_harness grader hit it.
* Pre-warm a ts-core workspace: run the frontend check over the starter
* core once — proves the scaffold is subset-clean before spending model
* tokens (the ts_harness grader compiles through the external toolchain
* itself and needs no warm zig graph here).
*/
export async function prewarmTsCoreWorkspace(
repoRoot: string,
workspace: Workspace,
log: (line: string) => void,
): Promise<void> {
log("[prewarm] transpile starter + zig test smoke...");
const scratch = join(workspace.path, ".prewarm");
mkdirSync(scratch, { recursive: true });
const transpile = await exec(
log("[prewarm] frontend check over the starter core...");
const check = await exec(
"node",
[
join(repoRoot, "packages", "core", "src", "cli.ts"),
join(workspace.path, "src", "core.ts"),
"-o",
join(scratch, "core.zig"),
],
[join(repoRoot, "packages", "core", "src", "cli.ts"), join(workspace.path, "src", "core.ts")],
{ cwd: workspace.path, timeoutMs: 2 * 60 * 1000 },
);
if (transpile.code !== 0) {
throw new Error(`pre-warm transpile failed — starter core is broken:\n${tailLines(transpile)}`);
if (check.code !== 0) {
throw new Error(`pre-warm check failed — starter core is broken:\n${tailLines(check)}`);
}
cpSync(join(repoRoot, "packages", "core", "rt", "rt.zig"), join(scratch, "rt.zig"));
writeFileSync(
join(scratch, "smoke.zig"),
`const core = @import("core.zig");
test "starter core initializes" {
core.rt.resetAll();
_ = core.commitModelRoot(core.initialModel());
core.rt.frameReset();
}
`,
);
const smoke = await exec("zig", ["test", "smoke.zig"], {
cwd: scratch,
timeoutMs: 10 * 60 * 1000,
});
if (smoke.code !== 0) {
throw new Error(`pre-warm zig test failed — starter core is broken:\n${tailLines(smoke)}`);
}
rmSync(scratch, { recursive: true, force: true });
log(`[prewarm] done in ${((transpile.durationMs + smoke.durationMs) / 1000).toFixed(0)}s`);
log(`[prewarm] done in ${(check.durationMs / 1000).toFixed(0)}s`);
}
+2 -2
View File
@@ -10,7 +10,7 @@ export interface EvalCase {
* Workspace shape. "native" scaffolds with `native init --frontend native`
* (the Zig-core app template); "ts-core" scaffolds a core-only TypeScript
* workspace (src/core.ts starter, README, the ts-core skill) graded
* through the @native-sdk/core transpiler; "app-dual" is a wave-2
* through the @native-sdk/core frontend; "app-dual" is a wave-2
* dual-track case: ONE language-blind spec that runs on both authoring
* tracks — the ts track scaffolds a full TypeScript app
* (`native init --frontend native --template ts-core`), the zig track the
@@ -96,7 +96,7 @@ export interface MarkupCheckCheck extends CheckCommon {
}
/**
* Run the @native-sdk/core transpiler on the workspace core (ts-core cases).
* Run the @native-sdk/core frontend on the workspace core (ts-core cases).
* Pass = the module typechecks (tsc semantics), passes every subset rule
* (NS1001-NS1050), and emits Zig. The diagnostics tail is kept as evidence,
* so violation taxonomy can be read off failing runs.
+2 -2
View File
@@ -1,6 +1,6 @@
# Native SDK ai-chat-ts example
A chat client for an OpenAI-compatible chat-completions endpoint, authored entirely in **TypeScript + Native markup**. Zero Zig: the logic tier is the app-core subset under `src/`, transpiled to native at build time as one module; `src/app.native` is the whole view tier and `app.zon` the manifest. The build detects `src/core.ts` in the tree and stages the wiring itself; no JS runtime ships in the binary.
A chat client for an OpenAI-compatible chat-completions endpoint, authored entirely in **TypeScript + Native markup**. Zero Zig: the logic tier is the app-core subset under `src/`, compiled to native code at build time; `src/app.native` is the whole view tier and `app.zon` the manifest. The build detects `src/core.ts` in the tree and stages the wiring itself; no JS runtime ships in the binary.
This is the reference answer to "can a TypeScript core call an AI API?": the network surface is one `Cmd.fetch` with a real `Authorization: Bearer <key>` header built at runtime from the launch environment, the JSON wire format is pure byte math in the subset, and because the whole exchange is effect data, a recorded conversation **replays byte-identically with zero network and zero env reads** — the e2e suite pins the exact request bytes and replays a two-turn conversation, transport failure and retry included, with no endpoint in the room and none of the launch variables set.
@@ -8,7 +8,7 @@ The core is two modules plus one SDK library:
- `src/core.ts` — the entry module: Model (the conversation, the composer, the request phase, the launch configuration), Msg, update, the env channel, and every exported binding helper.
- `src/api.ts` — the chat-completions wire format over bytes: request encoding (JSON escaping included) and response parsing (`choices[0].message.content` on success, `error.message` on failure; anything malformed is `null`, never a half-parsed conversation).
- `@native-sdk/core/text` — the SDK's byte-splice text engine, transpiled in for the composer's caret/selection/IME fidelity.
- `@native-sdk/core/text` — the SDK's byte-splice text engine, compiled in for the composer's caret/selection/IME fidelity.
```sh
NATIVE_SDK_CHAT_ENDPOINT="http://127.0.0.1:11434/v1/chat/completions" \
+2 -2
View File
@@ -1,13 +1,13 @@
# Native SDK soundboard-ts example
The soundboard music library authored entirely in **TypeScript + Native markup** — the launch-bar port of `examples/soundboard`. Zero Zig: the logic tier is the app-core subset under `src/`, transpiled to native at build time as one module; `src/app.native` is the whole view tier and `app.zon` the manifest plus the committed cover assets. The build detects `src/core.ts` in the tree and stages the wiring itself; no JS runtime ships in the binary.
The soundboard music library authored entirely in **TypeScript + Native markup** — the launch-bar port of `examples/soundboard`. Zero Zig: the logic tier is the app-core subset under `src/`, compiled to native code at build time; `src/app.native` is the whole view tier and `app.zon` the manifest plus the committed cover assets. The build detects `src/core.ts` in the tree and stages the wiring itself; no JS runtime ships in the binary.
The core is a multi-module reference split of the app-core subset's import-graph support:
- `src/core.ts` — the entry module: Model, Msg, update, subscriptions, the wiring channels, and every exported binding helper (the app's public face — markup and node both see exactly its exports).
- `src/library.ts` — the committed music catalog tables and the pure catalog/presentation helpers over them.
- `src/player.ts` — the pure playback state machine (track starts, queue advance, the launch-override stream rule); type-imports `Model` from the entry (the legal back-edge shape).
- `@native-sdk/core/text` — the SDK's byte-splice text engine, transpiled in for the search field's caret/selection/IME fidelity.
- `@native-sdk/core/text` — the SDK's byte-splice text engine, compiled in for the search field's caret/selection/IME fidelity.
Everything the Zig soundboard's core does is here: the committed music catalog (the same `music_manifest.zon` data, flattened into rodata tables), REAL audio playback through `Cmd.audioPlay` with the engine's source cascade (prepared local file first, the hosted URL as the streaming fallback, size-verified against the manifest's per-track bytes and cached under the platform caches directory), play/pause/prev/next with album wrap, scrub-to-seek on the transport slider, the play-next queue with its context-menu entry, Copy Title onto the clipboard, live search over albums/artists/titles through the full byte-splice text engine, the duration rule (the platform player's estimate never replaces the manifest's measured total), the never-rewind rendered clock, the honest degraded states (stream notice, buffering line, and the local-only assets notice under an empty `NATIVE_SDK_MUSIC_URL_BASE`), registered album covers, the width-adaptive album grid, and the media-key fallback.
+2 -2
View File
@@ -1,13 +1,13 @@
# Native SDK system monitor example (TypeScript)
The live CPU / memory / process monitor authored entirely in **TypeScript + Native markup** — the spawn-showcase port of `examples/system-monitor`. Zero Zig: the logic tier is the app-core subset under `src/`, transpiled to native at build time as one module; `src/app.native` is the whole view tier and `app.zon` the manifest. The build detects `src/core.ts` in the tree and stages the wiring itself; no JS runtime ships in the binary.
The live CPU / memory / process monitor authored entirely in **TypeScript + Native markup** — the spawn-showcase port of `examples/system-monitor`. Zero Zig: the logic tier is the app-core subset under `src/`, compiled to native code at build time; `src/app.native` is the whole view tier and `app.zon` the manifest. The build detects `src/core.ts` in the tree and stages the wiring itself; no JS runtime ships in the binary.
The core is a multi-module reference split of the app-core subset's import-graph support:
- `src/core.ts` — the entry module: Model, Msg, update, subscriptions, the chromeMsg channel, and every exported binding helper (the app's public face — markup and node both see exactly its exports).
- `src/parsers.ts` — the pure byte parsers over the sampler tools' output (`ps`, `vm_stat`, `/proc/meminfo`, the probes), the integer number tier (`intDiv`), and the byte/format helpers.
- `src/table.ts` — the process table's search/sort/row-formatting machinery; type-imports `Model` from the entry (the legal back-edge shape).
- `@native-sdk/core/text` — the SDK's byte-splice text engine, transpiled in for the filter field's caret/selection/IME fidelity.
- `@native-sdk/core/text` — the SDK's byte-splice text engine, compiled in for the filter field's caret/selection/IME fidelity.
Everything the Zig monitor's core does is here: the 2 s sampling cadence as a declarative `Sub.timer` that exists exactly while sampling is live, collect-mode `Cmd.spawn` for the OS's own commands (`ps axo pid=,pcpu=,pmem=,rss=,etime=,comm=` shared across platforms, `vm_stat` or `/proc/meminfo` for memory, a boot host-info probe), pure byte parsers over the collected stdout (fixture-proven against the Zig example's committed real captures), skipped-tick accounting (a tick that lands mid-spawn is counted, never overlapped), the exact top-128-by-CPU row selection with the full count and CPU sum still covering every process, uptime from pid 1's elapsed time, 60-sample NaN-padded sparkline windows drawn by markup `<chart>` elements, the search/sort/filter table on the real table register with controlled scroll, the confirmed SIGTERM context-menu action (`/bin/kill -TERM <pid>` — no SIGKILL anywhere), Copy Name onto the clipboard, the journaled `Cmd.now` sample timestamp, the honest no-sampler empty state, and the tall hidden-inset titlebar header driven by the `chromeMsg` channel.
+1 -14
View File
@@ -11,8 +11,7 @@
"scriptc": "0.0.22"
},
"devDependencies": {
"@typescript/old": "npm:typescript@6.0.3",
"@typescript/typescript6": "6.0.2"
"@typescript/old": "npm:typescript@6.0.3"
}
},
"node_modules/@scriptc/compiler": {
@@ -344,18 +343,6 @@
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript6": {
"version": "6.0.2",
"integrity": "sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@typescript/old": "npm:typescript@^6"
},
"bin": {
"tsc6": "bin/tsc6"
}
},
"node_modules/scriptc": {
"version": "0.0.22",
"integrity": "sha512-gJQ5Johx477Al1EqZOTeXOPsPgwzSOvVljIc9l1S0BqhaffyZtxXa1Mm3YFKZaCV3u4YNAEMsbO9EBgvW1awDg==",
+2 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@native-sdk/core",
"version": "0.7.2",
"description": "The TypeScript authoring tier: the app-core subset, its dev-time transpiler to arena-backed Zig, and the SDK module cores import",
"description": "The TypeScript authoring tier: the app-core subset, its checker and contract frontend, the exact-pinned core compiler dependency, and the SDK module cores import",
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/native.git",
@@ -30,12 +30,10 @@
},
"scripts": {
"test": "node --test \"test/*.test.ts\"",
"gate": "node scripts/gate.mjs",
"build:declarations": "node scripts/gen_declarations.mjs"
},
"devDependencies": {
"@typescript/old": "npm:typescript@6.0.3",
"@typescript/typescript6": "6.0.2"
"@typescript/old": "npm:typescript@6.0.3"
},
"dependencies": {
"scriptc": "0.0.22"
File diff suppressed because it is too large Load Diff
-60
View File
@@ -1,60 +0,0 @@
#!/usr/bin/env node
// Contract-equivalence pin: hold the frontend-emitted contract sidecar
// byte-identical to the extraction-path document (tools/corewire/
// extract.zig over the transpiled module). Byte equality is the pin —
// the two producers construct the same document by design, identity
// hashes included; on divergence the report walks the parsed trees and
// names the first differing path so the drift reads as a fact, not a
// wall of JSON.
//
// node contract_diff.mjs <extracted.contract.json> <frontend.contract.json>
import fs from "node:fs";
function walk(a, b, path, report) {
if (report.length >= 20) return;
if (typeof a !== typeof b) {
report.push(`${path}: ${typeof a} vs ${typeof b}`);
return;
}
if (a === null || b === null || typeof a !== "object") {
if (a !== b) report.push(`${path}: ${JSON.stringify(a)} vs ${JSON.stringify(b)}`);
return;
}
if (Array.isArray(a) !== Array.isArray(b)) {
report.push(`${path}: array vs object`);
return;
}
if (Array.isArray(a)) {
if (a.length !== b.length) report.push(`${path}: length ${a.length} vs ${b.length}`);
const n = Math.min(a.length, b.length);
for (let i = 0; i < n; i++) walk(a[i], b[i], `${path}[${i}]`, report);
return;
}
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
for (const k of keys) {
if (!(k in a)) report.push(`${path}.${k}: only in the frontend document`);
else if (!(k in b)) report.push(`${path}.${k}: only in the extracted document`);
else walk(a[k], b[k], `${path}.${k}`, report);
}
}
const [extractedPath, frontendPath] = process.argv.slice(2);
if (!extractedPath || !frontendPath) {
console.error("usage: contract_diff.mjs <extracted.contract.json> <frontend.contract.json>");
process.exit(2);
}
const extracted = fs.readFileSync(extractedPath, "utf8");
const frontend = fs.readFileSync(frontendPath, "utf8");
if (extracted === frontend) process.exit(0);
const report = [];
try {
walk(JSON.parse(extracted), JSON.parse(frontend), "$", report);
} catch (e) {
report.push(`unparseable document: ${e}`);
}
if (report.length === 0) report.push("byte-level difference only (formatting/ordering)");
console.error(`the frontend-emitted contract diverges from the extraction-path document (${extractedPath} vs ${frontendPath}):`);
for (const line of report) console.error(` ${line}`);
process.exit(1);
-90
View File
@@ -1,90 +0,0 @@
// The transpiler gate: transpile the fixture core, build it against the rt
// kernel with the fixture shim, replay the deterministic 1k-message run, and
// require the digest of the snapshot+effect log to equal the hand-written
// Zig oracle's. Then run the 10k keystroke bench for the perf band.
//
// Usage: node scripts/gate.mjs [--keep] [--skip-bench]
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const pkg = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
// MD5 of the run1k output (snapshot hex + effect-log hex) produced by the
// hand-written oracle core over the fixed SplitMix64 message sequence. The
// transpiled core must reproduce it exactly — same model bytes, same effect
// stream, for all 1000 dispatches.
const ORACLE_RUN1K_MD5 = "4e1140c16ba5569ab73db860894f4eba";
const keep = process.argv.includes("--keep");
const skipBench = process.argv.includes("--skip-bench");
const work = fs.mkdtempSync(path.join(os.tmpdir(), "native-core-gate-"));
const log = (msg) => console.log(`[gate] ${msg}`);
try {
log(`work dir ${work}`);
// 1. Transpile the fixture core.
execFileSync(process.execPath, [
path.join(pkg, "src/cli.ts"),
path.join(pkg, "test/fixtures/inbox_core_subset.ts"),
"-o",
path.join(work, "inbox_core.zig"),
], { stdio: "inherit" });
log("transpiled inbox_core_subset.ts");
// 2. Assemble the build: rt kernel + shim + harness.
for (const [src, dst] of [
["rt/rt.zig", "rt.zig"],
["test/fixtures/shim.zig", "shim.zig"],
["test/fixtures/impl.zig", "impl.zig"],
["test/fixtures/bench.zig", "bench.zig"],
]) {
fs.copyFileSync(path.join(pkg, src), path.join(work, dst));
}
const zig = (args) => execFileSync("zig", args, { cwd: work, stdio: "inherit" });
// 3. Build ReleaseSafe (the shipping mode: index checks stay on).
zig(["build-lib", "-OReleaseSafe", "-femit-bin=libinbox.a", "shim.zig"]);
zig([
"build-exe", "-OReleaseSafe", "-femit-bin=bench_safe",
"--dep", "impl", "-Mroot=bench.zig", "-Mimpl=impl.zig", "libinbox.a", "-lc",
]);
log("built ReleaseSafe");
// 4. run1k digest gate.
execFileSync(path.join(work, "bench_safe"), ["run1k", "run1k.txt"], { cwd: work, stdio: "inherit" });
const digest = createHash("md5").update(fs.readFileSync(path.join(work, "run1k.txt"))).digest("hex");
if (digest !== ORACLE_RUN1K_MD5) {
console.error(`[gate] FAIL run1k digest ${digest} != oracle ${ORACLE_RUN1K_MD5}`);
process.exit(1);
}
log(`run1k digest matches oracle (${digest})`);
// 5. Perf band.
if (!skipBench) {
log("bench10k (ReleaseSafe):");
execFileSync(path.join(work, "bench_safe"), ["bench10k"], { cwd: work, stdio: "inherit" });
zig(["build-lib", "-OReleaseFast", "-femit-bin=libinbox_fast.a", "shim.zig"]);
zig([
"build-exe", "-OReleaseFast", "-femit-bin=bench_fast",
"--dep", "impl", "-Mroot=bench.zig", "-Mimpl=impl.zig", "libinbox_fast.a", "-lc",
]);
log("bench10k (ReleaseFast):");
execFileSync(path.join(work, "bench_fast"), ["bench10k"], { cwd: work, stdio: "inherit" });
}
log("PASS");
} finally {
if (keep) {
log(`kept ${work}`);
} else {
fs.rmSync(work, { recursive: true, force: true });
}
}
+9 -50
View File
@@ -1,16 +1,15 @@
// Generate the Unicode simple case-mapping tables the ONE source both
// runtimes case-map from. Reads UnicodeData.txt (fields 12/13: Simple
// Uppercase / Simple Lowercase — never SpecialCasing, never locale rules)
// and emits the same compressed range tables twice:
// Generate the Unicode simple case-mapping tables the node devhost
// polyfill case-maps from. Reads UnicodeData.txt (fields 12/13: Simple
// Uppercase / Simple Lowercase — never SpecialCasing, never locale
// rules) and emits the compressed range tables:
//
// rt/rt.zig the marked GENERATED CASE TABLES region (in place)
// src/text_tables.ts the whole file (the node-polyfill mirror)
// src/text_tables.ts the whole file (the node-polyfill source)
//
// Byte-honest contract: simple case mapping is code point -> code point,
// locale-independent, with no special casing (no ß -> SS, no context
// forms), so `.toUpperCase()`/`.toLowerCase()` on core bytes produce the
// same bytes under node (polyfilled from these tables) and native (rt
// helpers over the same tables) by construction.
// same bytes under node as in the compiled core (the e2e batteries pin
// the native side against real archives).
//
// Compression: consecutive mapped code points sharing one delta collapse
// into {lo, count, stride, delta} runs (stride 2 covers the alternating
@@ -88,21 +87,6 @@ function compressRanges(entries) {
return out;
}
function hex(cp) {
return "0x" + cp.toString(16).toUpperCase();
}
function zigRanges(name, ranges) {
const lines = [`const ${name} = [_]CaseRange{`];
for (const r of ranges) {
lines.push(
` .{ .lo = ${hex(r.lo)}, .count = ${r.count}, .stride2 = ${r.stride === 2}, .delta = ${r.delta} },`,
);
}
lines.push("};");
return lines.join("\n");
}
function tsRanges(name, ranges) {
// Flat quadruples [lo, count, stride, delta] — the same runs, same order.
const parts = ranges.map((r) => `${r.lo}, ${r.count}, ${r.stride}, ${r.delta}`);
@@ -112,9 +96,6 @@ function tsRanges(name, ranges) {
return lines.join("\n");
}
const BEGIN = "// ---- BEGIN GENERATED CASE TABLES";
const END = "// ---- END GENERATED CASE TABLES";
const text = await loadUnicodeData();
const upper = compressRanges(collectMappings(text, 12));
const lower = compressRanges(collectMappings(text, 13));
@@ -128,34 +109,12 @@ const header = (comment) =>
`${comment} ${upper.length} + ${lower.length} ranges, 8 bytes each = ${tableBytes} bytes of table data.`,
].join("\n");
// ---------------------------------------------------------------- rt.zig
const rtPath = path.join(pkg, "rt", "rt.zig");
const rt = fs.readFileSync(rtPath, "utf8");
const begin = rt.indexOf(BEGIN);
const endMark = rt.indexOf(END);
if (begin < 0 || endMark < 0) throw new Error(`rt.zig is missing the ${BEGIN} / ${END} markers`);
const end = rt.indexOf("\n", endMark); // splice through the END marker's whole line
const zigBlock = [
`${BEGIN} ----`,
header("//"),
"",
zigRanges("simple_upper_ranges", upper),
"",
zigRanges("simple_lower_ranges", lower),
`${END} ----`,
].join("\n");
fs.writeFileSync(rtPath, rt.slice(0, begin) + zigBlock + rt.slice(end));
console.log(`wrote ${rtPath} (${upper.length} upper + ${lower.length} lower ranges, ${tableBytes} bytes)`);
// ---------------------------------------------------------- text_tables.ts
const tsPath = path.join(pkg, "src", "text_tables.ts");
const tsOut = [
"// The node-side mirror of rt.zig's simple case-mapping tables: the same",
"// generator emits both from one UnicodeData.txt read, so the devhost",
"// polyfill (text_polyfill.ts) and the native rt helpers case-map from",
"// byte-identical data by construction.",
"// The devhost polyfill's simple case-mapping tables (text_polyfill.ts),",
"// generated from one UnicodeData.txt read.",
header("//"),
"",
"// Flat quadruples [lo, count, stride, delta]: `count` mapped code points",
@@ -1,8 +1,8 @@
#!/usr/bin/env node
// Drive one external core compile for the opt-in build lane: verify the
// toolchain release against the SDK's exact pin, run a library-mode
// build over the staged tree, and normalize the outputs to the paths
// the build graph declared. The compile itself co-emits the archive's
// Drive one external core compile the lane every TypeScript core
// builds through: verify the toolchain release against the SDK's exact
// pin, run a library-mode build over the staged tree, and normalize
// the outputs to the paths the build graph declared. The compile itself co-emits the archive's
// OWN contract sidecar — the document the mirror module generates from,
// so the boot identity fence always pairs an archive with its own
// compile (the fixture driver, tests/compiled-core/build_core.sh, holds
@@ -58,8 +58,7 @@ const argv0 = args.compiler
// tools/corewire/emit_profile.zig): its ids resolve against one
// toolchain release's surface manifest, so the supplied command must BE
// the release the SDK pins — the exact-pinned dependency of
// packages/core (the one place the product lane's pin lives, held equal
// to tests/compiled-core/core_compiler_pin by the package's own tests).
// packages/core (the ONE place the pin lives).
const manifest = JSON.parse(fs.readFileSync(args.manifest, "utf8"));
const pin = manifest.dependencies?.scriptc;
if (typeof pin !== "string" || !/^\d+\.\d+\.\d+$/.test(pin)) {
+3 -3
View File
@@ -1142,10 +1142,10 @@ export class SubsetChecker {
}
}
/// NS1038 — one flat emitted namespace: type names (which the whole
/// NS1038 — one flat compiled namespace: type names (which the whole
/// pipeline resolves by name) and EXPORTED value names must be unique
/// across the core's modules. Private value collisions are fine — the
/// emitter uniques those with a per-module prefix.
/// compile uniques those per module.
private checkNameCollisions(): void {
interface Claim {
readonly file: ts.SourceFile;
@@ -1754,7 +1754,7 @@ export class SubsetChecker {
// NS1045 — destructuring: record fields into const locals only.
if (ts.isArrayBindingPattern(node)) {
// `for (const [i, x] of xs.entries())` keeps its own tailored
// teaching (the emitter names the classic-loop rewrite).
// teaching (it names the classic-loop rewrite).
if (!isEntriesLoopBinding(node)) {
this.report("NS1045", "Array destructuring binds element positions.", node);
}
+23 -50
View File
@@ -1,58 +1,38 @@
#!/usr/bin/env node
// @native-sdk/core: transpile an app-core subset TypeScript module to Zig.
// @native-sdk/core: check an app-core subset TypeScript module and emit
// its contract sidecar. The external core compiler does the compiling;
// this CLI is the frontend — the subset's teaching surface and the
// contract source.
//
// native-core <entry.ts> -o <out.zig> [--frame-cap <bytes>] [--heap-cap <bytes>]
// [--contract <out.contract.json>] [--contract-entry <spelling>]
// native-core <entry.ts> [--contract <out.contract.json>] [--contract-entry <spelling>]
//
// --frame-cap / --heap-cap set the emitted core's rt kernel capacities (the
// frame arena and the per-space model heap) as comptime constants; omitted,
// the rt defaults apply.
//
// --contract also writes the contract sidecar (core.contract.json, schema
// format 1) emitted directly from the checked program — the document an
// external core toolchain's projections consume. --contract-entry sets the
// --contract writes the contract sidecar (core.contract.json, schema
// format 1) emitted directly from the checked program — the document the
// external core compiler's projections consume. --contract-entry sets the
// entry spelling the document states (default: the entry argument as
// given, POSIX separators); the transpile still runs in full, so every
// transpile-time teaching gates the contract too.
// given, POSIX separators).
//
// Exit codes: 0 emitted; 1 subset/type errors (teaching diagnostics on
// stderr); 2 usage.
// Exit codes: 0 checked (and contract written when asked); 1 subset/type
// errors (teaching diagnostics on stderr); 2 usage.
import { transpileFile, formatDiagnostic, type TranspileOptions } from "./transpile.ts";
import { checkFile, formatDiagnostic, type FrontendOptions } from "./frontend.ts";
import fs from "node:fs";
function parseByteCount(flag: string, raw: string | undefined): number | null {
const v = raw === undefined ? NaN : Number(raw);
if (!Number.isSafeInteger(v) || v <= 0) {
console.error(`${flag} needs a positive integer byte count, got ${raw ?? "<missing>"}`);
return null;
}
return v;
}
function main(argv: string[]): number {
const args = argv.slice(2);
let entry: string | null = null;
let out: string | null = null;
let contractOut: string | null = null;
let contractEntry: string | null = null;
let frameCap: number | undefined;
let heapCap: number | undefined;
for (let i = 0; i < args.length; i++) {
if (args[i] === "-o" || args[i] === "--out") {
out = args[++i] ?? null;
} else if (args[i] === "--contract") {
if (args[i] === "--contract") {
contractOut = args[++i] ?? null;
} else if (args[i] === "--contract-entry") {
contractEntry = args[++i] ?? null;
} else if (args[i] === "--frame-cap") {
const v = parseByteCount("--frame-cap", args[++i]);
if (v === null) return 2;
frameCap = v;
} else if (args[i] === "--heap-cap") {
const v = parseByteCount("--heap-cap", args[++i]);
if (v === null) return 2;
heapCap = v;
} else if (args[i] === "-o" || args[i] === "--out") {
console.error(
"-o named the removed TS-to-Zig emitter (v0.7.0 removed it): TypeScript cores compile through the external core compiler now, and this CLI checks the core and emits its contract sidecar (--contract). Drop the flag.",
);
return 2;
} else if (!args[i].startsWith("-")) {
entry = args[i];
} else {
@@ -62,35 +42,28 @@ function main(argv: string[]): number {
}
if (!entry) {
console.error(
"usage: native-core <entry.ts> -o <out.zig> [--frame-cap <bytes>] [--heap-cap <bytes>] [--contract <out.contract.json>] [--contract-entry <spelling>]",
"usage: native-core <entry.ts> [--contract <out.contract.json>] [--contract-entry <spelling>]",
);
return 2;
}
const options: TranspileOptions = {
frameCap,
heapCap,
const options: FrontendOptions = {
// The document's entry spelling defaults to the argument's own,
// POSIX separators (the sidecar/facade contract is platform-free).
contractEntry: contractOut !== null ? (contractEntry ?? entry.split("\\").join("/")) : undefined,
};
const result = transpileFile(entry, options);
const result = checkFile(entry, options);
for (const e of result.typeErrors) console.error(e);
for (const d of result.diagnostics) console.error(formatDiagnostic(d));
// Teaching notices (NS1028): printed, never failing the build.
for (const w of result.warnings) console.error(formatDiagnostic(w, "warning"));
if (!result.ok || result.zig === null) return 1;
if (!result.ok) return 1;
if (contractOut !== null) {
if (result.contract === null) {
console.error("internal: the transpile produced no contract sidecar");
console.error("internal: the check produced no contract sidecar");
return 1;
}
fs.writeFileSync(contractOut, result.contract);
}
if (out) {
fs.writeFileSync(out, result.zig);
} else {
process.stdout.write(result.zig);
}
return 0;
}
+18 -4
View File
@@ -220,9 +220,8 @@ class ContractEmitter {
// --------------------------------------------------------------- origins
/// The declaring module of every named table type — the same walk the
/// emitter's type_origins table performs (emitter.ts emitTypeOrigins):
/// SDK modules spell their shipped staging path, core modules their
/// The declaring module of every named table type: SDK modules spell
/// their shipped staging path, core modules their
/// entry-relative POSIX path; a private declaration carries the
/// additive `"exported": false` marker; synthesized names stay out.
private collectOrigins(): void {
@@ -407,7 +406,7 @@ class ContractEmitter {
}
/// The spec's effectful pair shape on update/initialModel — the
/// emitter's cmdReturnShape, reduced to the presence fact.
/// presence fact the sidecar restates as *_returns_cmd.
private returnsCmdPair(decl: ts.FunctionDeclaration | null): boolean {
const t = decl?.type;
if (!t) return false;
@@ -424,6 +423,15 @@ class ContractEmitter {
);
}
/// Whether the declared return ALSO admits the bare model (the mixed
/// idiom, `Model | [Model, Cmd<Msg>]`) — the additive fact the facade
/// emitter keys its narrowing wrapper on (*_returns_bare).
private returnsBareModel(decl: ts.FunctionDeclaration | null): boolean {
const t = decl?.type;
if (!t || !ts.isUnionTypeNode(t)) return false;
return t.types.some((m) => !ts.isTupleTypeNode(m));
}
/// The `export const viewUnbound = [...]` opt-out list, split by side
/// (the emitter's viewUnboundNames twin; shapes are checker-taught, so
/// unresolvable spellings simply stay off both lists here).
@@ -586,6 +594,10 @@ class ContractEmitter {
// same pair-shape discrimination the emitter applies.
const initReturnsCmd = this.returnsCmdPair(this.entryExportedFunction("initialModel"));
const updateReturnsCmd = this.returnsCmdPair(this.entryExportedFunction("update"));
// The mixed-idiom facts ride only when true (additive fields; a
// compiler's co-emitted sidecar never carries them).
const initReturnsBare = initReturnsCmd && this.returnsBareModel(this.entryExportedFunction("initialModel"));
const updateReturnsBare = updateReturnsCmd && this.returnsBareModel(this.entryExportedFunction("update"));
const hasSubscriptions = this.entryExportedFunction("subscriptions") !== null;
let abiExports =
@@ -646,6 +658,8 @@ class ContractEmitter {
` "msg": {\n "name": ${js(msgName)},\n "arms": [\n ${msgArms}\n ],\n "unbound": [${msgUnbound}]\n },\n` +
` "init_returns_cmd": ${boolJson(initReturnsCmd)},\n` +
` "update_returns_cmd": ${boolJson(updateReturnsCmd)},\n` +
(initReturnsBare ? ' "init_returns_bare": true,\n' : "") +
(updateReturnsBare ? ' "update_returns_bare": true,\n' : "") +
` "has_subscriptions": ${boolJson(hasSubscriptions)},\n` +
' "channels": {\n' +
` "command_msg": ${boolJson(hasCommand)},\n` +
+4 -3
View File
@@ -22,7 +22,8 @@
// sub arm|re-arm|cancel <key> subscription reconciliation by key
// fire <key> -> <kind> @ <ms> a virtual timer fired (dispatched)
//
// Virtual-host semantics match the run-fidelity harness (node = native):
// Virtual-host semantics match the compiled core's (node = native; the
// SDK's ts-core e2e batteries pin the native side over real archives):
// - Cmd.now dispatches its arm immediately at the current virtual time;
// - Sub.timer reconciles by key after every commit (new key or changed
// interval arms, missing key cancels), each fire dispatching the named
@@ -70,8 +71,8 @@ if (!entry) usage();
// The resolver hook maps "@native-sdk/core" onto this package's own SDK
// module (app trees carry no node_modules for bare resolution to find),
// and the byte-text methods (s.toUpperCase(), s.split(sep), ...) install
// on Uint8Array.prototype before the core loads — the same tables the
// native rt helpers use, so node runs are byte-identical by construction.
// on Uint8Array.prototype before the core loads — locale-free simple
// case tables, the semantics the compiled core carries natively.
installTextMethods();
register(new URL("./devhost_resolver.mjs", import.meta.url));
-8
View File
@@ -400,14 +400,6 @@ export const rules = {
fix: "Spell the crossing in a schema-carried form: value-stored records (object-literal aliases) for message payloads, named records around optional or array payloads, and integer aliases whose values reach past 255.",
why: "The contract sidecar is the machine-readable twin of the core's surface, and a shape its schema cannot state would silently drop from every consumer — so the build stops here with the spelling that carries it instead.",
},
// NS9xxx: internal emit-time verification. A checker gap becomes a loud
// internal error naming the construct, never silent misbehavior.
NS9001: {
id: "NS9001",
title: "construct not covered by the v1 emitter",
fix: "Rewrite with the constructs in the subset table, or report this file so the mapping gains a rule.",
why: "The emitter re-derives every subset rule during emission; anything it cannot prove a mapping for must stop the build.",
},
} as const satisfies Record<string, RuleCopy>;
export type RuleId = keyof typeof rules;
File diff suppressed because it is too large Load Diff
@@ -1,73 +1,51 @@
// Orchestration: resolve the core's import graph (teaching diagnostics for
// every module-boundary mistake), check every module with the provider's
// own checker (same semantics as the author-facing tsc by upstream design),
// run the subset checker, run integer inference, then emit ONE Zig module
// for the whole graph.
// The frontend orchestration: resolve the core's import graph (teaching
// diagnostics for every module-boundary mistake), check every module with
// the provider's own checker (same semantics as the author-facing tsc by
// upstream design), run the subset checker, run integer inference, then
// when asked — emit the contract sidecar for the whole graph. The
// external core compiler does the compiling; this pass is the subset's
// teaching surface and the contract source.
import { ts, TypedAst, createSubsetProgram, lineColumn } from "./typed_ast.ts";
import { resolveModuleGraph } from "./modules.ts";
import { TypeTable } from "./types.ts";
import { IntInference } from "./infer.ts";
import { SubsetChecker } from "./checker.ts";
import { Emitter, EmitError, type KernelCapacities } from "./emitter.ts";
import { emitContractSidecar, ContractError } from "./contract.ts";
import { makeDiagnostic, formatDiagnostic, type SubsetDiagnostic } from "./diagnostics.ts";
import path from "node:path";
import fs from "node:fs";
export interface TranspileOptions {
/// Frame arena capacity in bytes: the transient budget of one dispatch.
/// Emitted as a comptime parameter of the core's rt kernel instantiation
/// (rt default when omitted).
readonly frameCap?: number;
/// Model heap capacity in bytes PER SPACE (the two-space committed-model
/// heap): the live model graph plus not-yet-compacted garbage must fit.
readonly heapCap?: number;
export interface FrontendOptions {
/// When set, also emit the contract sidecar (core.contract.json,
/// schema format 1) from the checked program — the entry spelling the
/// document states (never a filesystem-absolute leak). The emitted Zig
/// still gates the run: transpile-clean stays the contract's floor.
/// document states (never a filesystem-absolute leak).
readonly contractEntry?: string;
}
export interface TranspileResult {
export interface FrontendResult {
readonly ok: boolean;
readonly zig: string | null;
/// The contract sidecar JSON, when options.contractEntry asked for it
/// (null otherwise, and on any failed transpile).
/// (null otherwise, and on any failed check).
readonly contract: string | null;
readonly diagnostics: SubsetDiagnostic[];
/// Non-fatal teaching notices (NS1028 today): surfaced as warnings,
/// never failing the transpile.
/// never failing the check.
readonly warnings: SubsetDiagnostic[];
/// Provider (tsc-semantics) diagnostics, already formatted.
readonly typeErrors: string[];
/// Every file the core is built from, absolute, entry first — the
/// build-graph staleness set (a change to any of them re-emits).
/// build-graph staleness set (a change to any of them re-checks).
readonly inputs: string[];
}
function validateCapacities(options: TranspileOptions): KernelCapacities {
for (const [name, v] of [
["frameCap", options.frameCap],
["heapCap", options.heapCap],
] as const) {
if (v !== undefined && (!Number.isSafeInteger(v) || v <= 0)) {
throw new Error(`${name} must be a positive integer byte count, got ${v}`);
}
}
return { frameCap: options.frameCap, heapCap: options.heapCap };
}
export function transpileFile(entry: string, options: TranspileOptions = {}): TranspileResult {
const capacities = validateCapacities(options);
export function checkFile(entry: string, options: FrontendOptions = {}): FrontendResult {
// Module-boundary mistakes (NS1034-NS1037) teach BEFORE the type-checked
// program is built: a missing file or an escaped src/ boundary would
// otherwise surface as a raw resolution error.
const graph = resolveModuleGraph(entry);
if (graph.diagnostics.length > 0) {
return { ok: false, zig: null, contract: null, diagnostics: graph.diagnostics, warnings: [], typeErrors: [], inputs: [...graph.files] };
return { ok: false, contract: null, diagnostics: graph.diagnostics, warnings: [], typeErrors: [], inputs: [...graph.files] };
}
const program = createSubsetProgram(entry);
@@ -77,12 +55,12 @@ export function transpileFile(entry: string, options: TranspileOptions = {}): Tr
for (const p of graph.files) {
const file = byPath.get(path.resolve(p));
if (!file) {
return { ok: false, zig: null, contract: null, diagnostics: [], warnings: [], typeErrors: [`cannot read ${p}`], inputs: [...graph.files] };
return { ok: false, contract: null, diagnostics: [], warnings: [], typeErrors: [`cannot read ${p}`], inputs: [...graph.files] };
}
files.push(file);
}
if (files.length === 0) {
return { ok: false, zig: null, contract: null, diagnostics: [], warnings: [], typeErrors: [`cannot read ${entry}`], inputs: [] };
return { ok: false, contract: null, diagnostics: [], warnings: [], typeErrors: [`cannot read ${entry}`], inputs: [] };
}
const typeErrors: string[] = [];
@@ -96,14 +74,14 @@ export function transpileFile(entry: string, options: TranspileOptions = {}): Tr
}
}
if (typeErrors.length > 0) {
return { ok: false, zig: null, contract: null, diagnostics: [], warnings: [], typeErrors: [...new Set(typeErrors)], inputs: [...graph.files] };
return { ok: false, contract: null, diagnostics: [], warnings: [], typeErrors: [...new Set(typeErrors)], inputs: [...graph.files] };
}
const table = new TypeTable(tast, files);
const checker = new SubsetChecker(tast, table, files);
const checkResult = checker.check();
if (checkResult.diagnostics.length > 0) {
return { ok: false, zig: null, contract: null, diagnostics: checkResult.diagnostics, warnings: checkResult.warnings, typeErrors: [], inputs: [...graph.files] };
return { ok: false, contract: null, diagnostics: checkResult.diagnostics, warnings: checkResult.warnings, typeErrors: [], inputs: [...graph.files] };
}
const infer = new IntInference(tast, table, files);
@@ -121,45 +99,33 @@ export function transpileFile(entry: string, options: TranspileOptions = {}): Tr
column,
);
});
return { ok: false, zig: null, contract: null, diagnostics, warnings: checkResult.warnings, typeErrors: [], inputs: [...graph.files] };
return { ok: false, contract: null, diagnostics, warnings: checkResult.warnings, typeErrors: [], inputs: [...graph.files] };
}
const emitter = new Emitter(tast, table, infer, checkResult, files, path.basename(entry), capacities);
try {
const zig = emitter.emitModule();
// The contract sidecar emits from the SAME checked analysis, after
// the emitter's own layer-3 re-derivations have all passed — so a
// contract-bearing run keeps every transpile-time teaching.
// The contract sidecar emits from the SAME checked analysis, so a
// contract-bearing run keeps every check-time teaching.
const contract =
options.contractEntry !== undefined
? emitContractSidecar({ tast, table, infer, checkResult, files, entry: options.contractEntry })
: null;
return { ok: true, zig, contract, diagnostics: [], warnings: checkResult.warnings, typeErrors: [], inputs: [...graph.files] };
return { ok: true, contract, diagnostics: [], warnings: checkResult.warnings, typeErrors: [], inputs: [...graph.files] };
} catch (e) {
if (e instanceof ContractError) {
const file = e.node.getSourceFile();
const { line, column } = lineColumn(file, e.node.getStart());
const d = makeDiagnostic("NS1063", `${e.message[0].toUpperCase()}${e.message.slice(1)}.`, file.fileName, line, column);
return { ok: false, zig: null, contract: null, diagnostics: [d], warnings: checkResult.warnings, typeErrors: [], inputs: [...graph.files] };
}
if (e instanceof EmitError) {
const file = e.node.getSourceFile();
const { line, column } = lineColumn(file, e.node.getStart());
// Layer-3 re-derivations of taught rules keep their own rule copy;
// everything else is the internal NS9001 stop.
const site = e.ruleId === "NS9001" ? `Internal: no v1 mapping for ${e.message}.` : `${e.message[0].toUpperCase()}${e.message.slice(1)}.`;
const d = makeDiagnostic(e.ruleId, site, file.fileName, line, column);
return { ok: false, zig: null, contract: null, diagnostics: [d], warnings: checkResult.warnings, typeErrors: [], inputs: [...graph.files] };
return { ok: false, contract: null, diagnostics: [d], warnings: checkResult.warnings, typeErrors: [], inputs: [...graph.files] };
}
throw e;
}
}
export function transpileSource(source: string, name = "core.ts", options: TranspileOptions = {}): TranspileResult {
export function checkSource(source: string, name = "core.ts", options: FrontendOptions = {}): FrontendResult {
// Test seam: materialize an in-memory file through a temp path-less host.
const tmp = path.join(process.env.TMPDIR ?? "/tmp", `native-core-${process.pid}-${Math.random().toString(36).slice(2)}.ts`);
fs.writeFileSync(tmp, source);
try {
return transpileFile(tmp, options);
return checkFile(tmp, options);
} finally {
fs.unlinkSync(tmp);
}
+1 -1
View File
@@ -1323,7 +1323,7 @@ export class IntInference {
// comparisons against proven/demanded integer sides. Comparison-origin
// demand never claims a host-boundary slot, nor any slot a host-boundary
// slot feeds: the comparison itself does not require an integer (the
// emitter widens the integer side into f64 instead), and an i64-claimed
// integer side widens into f64 instead), and an i64-claimed
// exported signature would truncate host f64 values that node compares
// exactly — claiming only part of such a chain would manufacture an
// NS1016 conflict out of thin air. Genuine demand chains (index,
+11 -15
View File
@@ -1,11 +1,11 @@
// The TypedAst seam — the ONLY file that imports the type-checker provider.
//
// The transpiler needs resolved types (union discrimination, literal types,
// The frontend needs resolved types (union discrimination, literal types,
// readonly-ness, symbol resolution, contextual types) but must not couple to
// a particular checker API: today the provider is the `@typescript/typescript6`
// compat package (same checker semantics as the author-facing TS7 tsc by
// upstream design); when the stable Go-native programmatic API ships, only
// this adapter changes.
// a particular checker API: today the provider is the `@typescript/old`
// alias (the 6.x compiler line, same checker semantics as the author-facing
// TS7 tsc by upstream design); when the stable Go-native programmatic API
// ships, only this adapter changes.
//
// Surface discipline:
// - Syntax (node kinds, tree walking) passes through as `ts` — syntax trees
@@ -13,16 +13,12 @@
// - Every TYPE question goes through the named queries on `TypedAst` below.
// Checker/emitter code never touches `program.getTypeChecker()` directly.
// The IMPORT deliberately bypasses the `@typescript/typescript6` wrapper
// (which stays the declared dependency — it is the provider named above,
// and its own dependency IS this alias): the wrapper's lib/typescript.js
// re-exports "@typescript/old" resolved from the WRAPPER's location, so a
// consumer tree already carrying a conflicting hoisted @typescript/old
// would win node's nearest-wins walk there while our exactly pinned copy
// sat nested and unused. Importing the alias directly resolves it from
// THIS file — inside our package, where our own nested/hoisted exact pin
// is always the nearest — and the CLI's resolution gate additionally
// verifies the resolved version against the pin.
// The IMPORT resolves the alias from THIS file — inside our package,
// where our own nested/hoisted exact pin is always the nearest in node's
// walk — so a consumer tree carrying a conflicting hoisted
// @typescript/old can never shadow the pinned compiler; the CLI's
// resolution gate additionally verifies the resolved version against the
// pin.
import tsImpl from "@typescript/old";
import path from "node:path";
import { fileURLToPath } from "node:url";
+25 -20
View File
@@ -2,7 +2,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { checkOnly, ruleIds, transpile, transpileFiles } from "./helpers.ts";
import { checkOnly, ruleIds, check, checkFiles } from "./helpers.ts";
const core = `
export interface Model { readonly count: number; }
@@ -267,7 +267,7 @@ test("NS1013 eval and dynamic import", () => {
});
test("NS1035 runtime npm import (module boundary rules live in the graph resolver)", () => {
const result = transpile(`import x from "some-npm-package";\nexport const y = x;`);
const result = check(`import x from "some-npm-package";\nexport const y = x;`);
assert.equal(result.ok, false);
const d = result.diagnostics.find((x) => x.id === "NS1035");
assert.ok(d, `got ${result.diagnostics.map((x) => x.id)}`);
@@ -277,12 +277,12 @@ test("NS1035 runtime npm import (module boundary rules live in the graph resolve
test("type-only npm imports are allowed by the graph resolver", () => {
// The type-only edge erases at the boundary: no NS103x code fires (the
// unresolvable package then surfaces as an ordinary tsc error).
const result = transpile(`import type { X } from "some-npm-package";\nexport const y = 1;`);
const result = check(`import type { X } from "some-npm-package";\nexport const y = 1;`);
assert.equal(result.diagnostics.length, 0, `got ${result.diagnostics.map((x) => x.id)}`);
});
test("NS1037 a relative import must name a real .ts file", () => {
const result = transpile(`import { helper } from "./helper_mod";\nexport const y = helper;`);
const result = check(`import { helper } from "./helper_mod";\nexport const y = helper;`);
assert.equal(result.ok, false);
const d = result.diagnostics.find((x) => x.id === "NS1037");
assert.ok(d, `got ${result.diagnostics.map((x) => x.id)}`);
@@ -1031,7 +1031,7 @@ export function update(model: Model, msg: Msg): Model {
// An unexported reserved const in an imported module is inert
// configuration and refuses like the exported form.
const imported = transpileFiles({
const imported = checkFiles({
"core.ts": `
import { other } from "./lists.ts";
export interface Model { readonly n: number; readonly hidden: number; }
@@ -1044,35 +1044,41 @@ export function update(model: Model, msg: Msg): Model { return model; }
assert.equal(imported.ok, false);
assert.ok(imported.diagnostics.some((d) => d.id === "NS1014"), JSON.stringify(imported.diagnostics));
// The split pair restates viewUnbound's facts: an unresolvable entry
// and a missing one both refuse.
const unresolvable = transpile(`
// An unresolvable viewUnbound entry refuses at check time — the
// opt-out lint stays honest for state only update logic touches.
const unresolvable = check(`
export interface Model { readonly n: number; readonly hidden: number; }
export type Msg = { readonly kind: "a" } | { readonly kind: "b" };
export const viewUnbound = ["hidden"] as const;
export const modelUnbound = ["nope"] as const;
export const viewUnbound = ["hidden", "nope"] as const;
export function initialModel(): Model { return { n: 0, hidden: 1 }; }
export function update(model: Model, msg: Msg): Model { return model; }
`);
assert.equal(unresolvable.ok, false);
assert.ok(unresolvable.diagnostics.some((d) => d.id === "NS1032"), JSON.stringify(unresolvable.diagnostics));
const missing = transpile(`
// Entries resolve by side: a msg-arm spelling lands on the msg list.
const split = check(
`
export interface Model { readonly n: number; readonly hidden: number; }
export type Msg = { readonly kind: "a" } | { readonly kind: "probe"; readonly value: number };
export const viewUnbound = ["hidden", "probe"] as const;
export const msgUnbound = [] as const;
export function initialModel(): Model { return { n: 0, hidden: 1 }; }
export function update(model: Model, msg: Msg): Model { return model; }
`);
assert.equal(missing.ok, false);
assert.ok(missing.diagnostics.some((d) => d.id === "NS1032"), JSON.stringify(missing.diagnostics));
`,
{ contractEntry: "core.ts" },
);
assert.equal(split.ok, true);
assert.ok(split.contract!.includes('"model_unbound": ["hidden"]'), split.contract!);
assert.ok(split.contract!.includes('"unbound": ["probe"]'), split.contract!);
});
test("NS1061: generic and literal-asserted identity stop; enum-kind records stay structs", () => {
// Identity through a generic instantiation stops at emission with the
// same teaching the checker gives directly.
const generic = transpile(`
// Identity through a generic instantiation used to stop at emission
// (the removed TS-to-Zig emitter re-derived NS1061 during
// monomorphization); the frontend accepts the generic form now — the
// external core compiler carries the real JS reference-identity
// semantics — while the direct form below still teaches at check.
const generic = check(`
export type Pos = { readonly x: number };
export interface Model { readonly pos: Pos; readonly n: number; }
export type Msg = { readonly kind: "moved"; readonly pos: Pos } | { readonly kind: "b" };
@@ -1085,8 +1091,7 @@ export function update(model: Model, msg: Msg): Model {
return model;
}
`);
assert.equal(generic.ok, false);
assert.ok(generic.diagnostics.some((d) => d.id === "NS1061"), JSON.stringify(generic.diagnostics));
assert.equal(generic.ok, true);
// An assertion may be what NAMES the record: both views are read, so
// literal-asserted operands refuse at check time.
+136 -192
View File
@@ -1,29 +1,29 @@
// Conformance corpus: the transpiler's output contract is that an author who
// passes tsc and the subset checker NEVER sees a Zig compile error. Each case
// is a small subset-legal module exercising a type-boundary combination
// (literal unions x comparisons x assignments x args/returns x elements x
// integer inference). A case either EMITS — transpile-clean must imply
// zig-build-clean — or is GATED by a named teaching rule at check time.
//
// The zig-build half runs as one `zig test` over every emitted module
// (skipped when no zig toolchain is on PATH; the gating half always runs).
// Conformance corpus: the frontend's contract is that every subset rule
// teaches at check time with a named rule, and everything else checks
// clean. Each case is a small subset-legal module exercising a
// type-boundary combination (literal unions x comparisons x assignments
// x args/returns x elements x integer inference). A case either checks
// CLEAN — the external core compiler carries it from there (compile
// truth lives in the SDK's ts-core e2e batteries over real archives) —
// or is GATED by a named teaching rule at check time. Cases marked
// `formerEmitGate` were refused by the removed TS-to-Zig emitter's own
// re-derivations; they check clean now and ride the external compiler's
// semantics, diagnostics, and run-time traps.
import test from "node:test";
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { transpile, transpileFiles } from "./helpers.ts";
const pkg = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const hasZig = spawnSync("zig", ["version"], { stdio: "ignore" }).status === 0;
import { check, checkFiles } from "./helpers.ts";
interface Case {
readonly name: string;
/// Expected teaching rule; omitted means the case must emit Zig that compiles.
/// Expected teaching rule; omitted means the case must check clean.
readonly gate?: string;
/// The rule the REMOVED TS-to-Zig emitter used to teach at emission
/// (its layer-3 re-derivations and v1 deferrals). The frontend now
/// accepts these cases — the external core compiler carries them with
/// its own semantics, diagnostics, and run-time traps — so the case
/// must check clean; the field keeps the classification readable.
readonly formerEmitGate?: string;
readonly src: string;
}
@@ -1105,7 +1105,7 @@ export function subscriptions(model: Model): Sub<Msg> {
},
{
name: "a payload mixed with extra arguments is taught",
gate: "NS1026",
formerEmitGate: "NS1026",
src: `
import { Cmd } from "@native-sdk/core";
export interface Model { readonly draft: Uint8Array; readonly count: number; }
@@ -1120,7 +1120,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
},
{
name: "a nested record payload field is taught (no wire encoding)",
gate: "NS1026",
formerEmitGate: "NS1026",
src: `
import { Cmd } from "@native-sdk/core";
export interface Inner { readonly a: number; }
@@ -1136,7 +1136,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
},
{
name: "a routing callback is taught, not run (routing is data)",
gate: "NS1027",
formerEmitGate: "NS1027",
src: `
import { Cmd, type BytesKind } from "@native-sdk/core";
export interface Model { readonly data: Uint8Array; }
@@ -1158,7 +1158,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
},
{
name: "a non-literal routing arm is taught (decoders derive at build time)",
gate: "NS1027",
formerEmitGate: "NS1027",
src: `
import { Cmd } from "@native-sdk/core";
export interface Model { readonly data: Uint8Array; readonly alt: boolean; }
@@ -1180,7 +1180,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
},
{
name: "a routing arm without a bytes payload is taught",
gate: "NS1027",
formerEmitGate: "NS1027",
src: `
import { Cmd, type BytesKind } from "@native-sdk/core";
export interface Model { readonly data: Uint8Array; readonly ticks: number; }
@@ -1257,7 +1257,7 @@ function timers(model: Model): Sub<Msg> {
},
{
name: "a timer target without a single number payload is taught",
gate: "NS1027",
formerEmitGate: "NS1027",
src: `
import { Sub, type TimestampKind } from "@native-sdk/core";
export interface Model { readonly data: Uint8Array; }
@@ -1328,7 +1328,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
},
{
name: "a fetch ok arm that is not a {status, body} record is taught",
gate: "NS1027",
formerEmitGate: "NS1027",
src: `
import { Cmd, asciiBytes, type FetchedKind } from "@native-sdk/core";
${namedOpMsg}
@@ -1346,7 +1346,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
},
{
name: "a writeFile ok arm carrying a payload is taught",
gate: "NS1027",
formerEmitGate: "NS1027",
src: `
import { Cmd, asciiBytes, type EmptyKind } from "@native-sdk/core";
${namedOpMsg}
@@ -1364,7 +1364,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
},
{
name: "a delay target without a single number payload is taught",
gate: "NS1027",
formerEmitGate: "NS1027",
src: `
import { Cmd, type TimestampKind } from "@native-sdk/core";
${namedOpMsg}
@@ -1399,7 +1399,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
},
{
name: "a smuggled string fetch header value is taught (values are literals or bytes)",
gate: "NS1029",
formerEmitGate: "NS1029",
src: `
import { Cmd, asciiBytes } from "@native-sdk/core";
${namedOpMsg}
@@ -1417,7 +1417,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
},
{
name: "a non-flat headers record is taught",
gate: "NS1029",
formerEmitGate: "NS1029",
src: `
import { Cmd, asciiBytes } from "@native-sdk/core";
${namedOpMsg}
@@ -1435,7 +1435,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
},
{
name: "a string path smuggled past the bytes rule is taught",
gate: "NS1029",
formerEmitGate: "NS1029",
src: `
import { Cmd } from "@native-sdk/core";
${namedOpMsg}
@@ -1453,7 +1453,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
},
{
name: "an over-bound path literal stops at compile time",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd, asciiBytes } from "@native-sdk/core";
${namedOpMsg}
@@ -1471,7 +1471,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
},
{
name: "more headers than the engine accepts stops at compile time",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd, asciiBytes } from "@native-sdk/core";
${namedOpMsg}
@@ -1489,7 +1489,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
},
{
name: "a literal delay outside the 1ms..one-year bound stops at compile time",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd } from "@native-sdk/core";
${namedOpMsg}
@@ -1634,7 +1634,7 @@ export function next(n: number): number {
},
{
name: "Cmd.host argument smuggled past the types is taught",
gate: "NS1020",
formerEmitGate: "NS1020",
src: `
import { Cmd } from "@native-sdk/core";
export interface Model { readonly count: number; }
@@ -1765,7 +1765,7 @@ export function pick(e: Ev): number {
},
{
name: "stacked labels whose shared body reads a payload are gated",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export type Ev = { readonly kind: "a"; readonly n: number } | { readonly kind: "b"; readonly n: number };
export function pick(e: Ev): number {
@@ -1859,7 +1859,7 @@ export function sum(xs: readonly number[]): number {
},
{
name: "for...of over .entries() beyond the [i, x] pair form is taught",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export function sum(xs: readonly number[]): number {
let total = 0;
@@ -1872,7 +1872,7 @@ export function sum(xs: readonly number[]): number {
},
{
name: "for...of with a let binding is taught, not emitted",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export function last(xs: readonly number[]): number {
let hit = 0;
@@ -1961,7 +1961,7 @@ export function countBig(xs: readonly number[], lim: number): number {
},
{
name: "reduce without an initial value is taught (empty-array throw)",
gate: "NS1007",
formerEmitGate: "NS1007",
src: `
export function total(xs: readonly number[]): number {
return xs.reduce((sum, x) => sum + x);
@@ -2001,7 +2001,7 @@ export function tripled(a: readonly number[], b: readonly number[], c: readonly
},
{
name: "indexOf on a record array is taught (JS reference identity)",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export interface Task { readonly id: number; readonly done: boolean; }
export function has(tasks: readonly Task[], t: Task): number {
@@ -2025,7 +2025,7 @@ export function commas(bytes: Uint8Array): string {
},
{
name: "join on a number array is taught (float elements)",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export function csv(xs: readonly number[]): string {
return xs.join(",");
@@ -2034,7 +2034,7 @@ export function csv(xs: readonly number[]): string {
},
{
name: "the wrong empty test is taught in both directions (R7c)",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export function f(cursor: number | null): boolean {
return cursor === undefined;
@@ -2043,7 +2043,7 @@ export function f(cursor: number | null): boolean {
},
{
name: "=== null on a find result is taught (the miss is JS undefined)",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export function f(xs: readonly number[]): number {
const hit = xs.find((x) => x > 0);
@@ -2271,7 +2271,7 @@ export function toggled(habits: readonly Habit[], id: number): readonly Habit[]
},
{
name: "toSorted without a comparator is taught (JS ToString ordering)",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export function f(xs: readonly number[]): readonly number[] {
return xs.toSorted();
@@ -2324,7 +2324,7 @@ export function f(n: number): readonly number[] {
},
{
name: "push in value position is taught (the JS value is the new length)",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export function f(n: number): number {
const out: number[] = [];
@@ -2353,7 +2353,7 @@ export function update(model: Model, msg: Msg): Model {
},
{
name: "push with a spread argument is taught (one element per call)",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export function f(xs: readonly number[]): readonly number[] {
const out: number[] = [];
@@ -2364,7 +2364,7 @@ export function f(xs: readonly number[]): readonly number[] {
},
{
name: "pushing to the array a for...of iterates is taught, not silently snapshotted",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export function f(n: number): readonly number[] {
const out: number[] = [];
@@ -2378,7 +2378,7 @@ export function f(n: number): readonly number[] {
},
{
name: "a callback path falling off the end is taught (implicit undefined)",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export function f(xs: readonly number[]): readonly number[] {
return xs.filter((x) => {
@@ -2712,7 +2712,7 @@ export function f(xs: number[]): number[] {
},
{
name: "in-place sort without a comparator is taught (JS ToString ordering)",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export function f(xs: readonly number[]): readonly number[] {
const copy = xs.slice();
@@ -2734,7 +2734,7 @@ export function f(xs: readonly number[]): readonly number[] {
},
{
name: "sort/reverse in value position is taught (JS returns the same array)",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export function f(xs: readonly number[]): readonly number[] {
const copy = xs.slice();
@@ -2744,7 +2744,7 @@ export function f(xs: readonly number[]): readonly number[] {
},
{
name: "unshift in value position is taught (the JS value is the new length)",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export function f(xs: readonly number[]): number {
const copy = xs.slice();
@@ -2755,7 +2755,7 @@ export function f(xs: readonly number[]): number {
},
{
name: "splice with a spread argument is taught",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export function f(xs: readonly number[], ys: readonly number[]): readonly number[] {
const copy = xs.slice();
@@ -2776,7 +2776,7 @@ export function f(): readonly number[] {
},
{
name: "a compound appending write is taught (it reads the missing slot first)",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export function f(): readonly number[] {
const out = [1, 2];
@@ -2787,7 +2787,7 @@ export function f(): readonly number[] {
},
{
name: "length-changing mutation of the array a for...of iterates is taught",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export function f(): number {
const out: number[] = [1, 2, 3];
@@ -2802,7 +2802,7 @@ export function f(): number {
},
{
name: "length-changing mutation from inside an iterating callback is taught",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export function f(xs: readonly number[]): number {
const out: number[] = [1, 2, 3];
@@ -2900,7 +2900,7 @@ export function pick(bytes: Uint8Array, x: number): number {
},
{
name: "Math methods outside the v1 set are taught by name",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export function powed(x: number): number { return Math.pow(x, 2); }
`,
@@ -2921,7 +2921,7 @@ export function guard(xs: readonly number[]): readonly number[] {
},
{
name: "Number methods outside the v1 classifiers are taught by name",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export function parsed(): number { return Number.parseFloat("1.5"); }
`,
@@ -2982,7 +2982,7 @@ export function inRange(x: number): boolean { return x >= LIMITS.lo && x <= LIMI
},
{
name: "an unannotated module const record is taught toward the interface annotation",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export const LIMITS = { lo: 1, hi: 9 };
export function f(): number { return 1; }
@@ -2990,7 +2990,7 @@ export function f(): number { return 1; }
},
{
name: "a spread in a module const table is a taught stop (tables are comptime data)",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export interface Limits { readonly lo: number; readonly hi: number; }
export const BASE: Limits = { lo: 1, hi: 9 };
@@ -3000,7 +3000,7 @@ export function f(): number { return 1; }
},
{
name: "a table number that does not fold at compile time is a taught stop",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
function seed(): number { return 3; }
export const TABLE: readonly number[] = [1, seed()];
@@ -3054,7 +3054,7 @@ export function frontLoaded(tasks: readonly Task[]): boolean {
},
{
name: "a callback declaring the third (array) parameter is a taught stop",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export function f(xs: readonly number[]): readonly number[] {
return xs.map((x, i, all) => x + all.length);
@@ -3063,7 +3063,7 @@ export function f(xs: readonly number[]): readonly number[] {
},
{
name: "a reduce callback with an index parameter is a taught stop",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export function f(xs: readonly number[]): number {
return xs.reduce((sum, x, i) => sum + x * i, 0);
@@ -3113,7 +3113,7 @@ export function countIf(xs: readonly number[], lim: number): number {
},
{
name: "a while condition still may not lower statements (re-evaluated per iteration)",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export function f(xs: readonly number[]): number {
let n = 0;
@@ -3174,7 +3174,7 @@ export function bitDefault(b: Bit): number {
},
{
name: "a value-switch default that is not the last clause is a taught stop",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export type Filter = "all" | "active" | "done";
export function f(x: Filter): number {
@@ -3189,7 +3189,7 @@ export function f(x: Filter): number {
},
{
name: "case labels falling through into a value-switch default are a taught stop",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export type Filter = "all" | "active" | "done";
export function f(x: Filter): number {
@@ -3425,7 +3425,7 @@ export function update(model: Model, msg: Msg): Model {
},
{
name: "an array of unions in the model stays a loud stop (not in v1)",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export type Item = { readonly kind: "a" } | { readonly kind: "b"; readonly n: number };
export interface Model { readonly items: readonly Item[]; }
@@ -3438,7 +3438,7 @@ export function update(model: Model, msg: Msg): Model {
},
{
name: "an array of byte-strings in the model stays a loud stop (not in v1)",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export interface Model { readonly rows: readonly Uint8Array[]; }
export type Msg = { readonly kind: "x" } | { readonly kind: "y" };
@@ -3742,7 +3742,7 @@ ${streamTail}
},
{
name: "a dynamic showWindow label is taught (window labels are declarations)",
gate: "NS1027",
formerEmitGate: "NS1027",
src: `
import { Cmd } from "@native-sdk/core";
${streamMsg}
@@ -3760,7 +3760,7 @@ ${streamTail}
// are 100 UTF-16 code units but 300 UTF-8 bytes, so the 255-byte
// teaching must fire on the byte count, not on \`.length\`.
name: "a showWindow label over 255 UTF-8 bytes is taught (100 CJK chars = 300 bytes)",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
import { Cmd } from "@native-sdk/core";
${streamMsg}
@@ -3808,7 +3808,7 @@ ${streamTail}
},
{
name: "a line-mode spawn exit arm without a single number payload is taught",
gate: "NS1027",
formerEmitGate: "NS1027",
src: `
import { Cmd, asciiBytes, type TimestampKind } from "@native-sdk/core";
${streamMsg}
@@ -3820,7 +3820,7 @@ ${streamTail}
},
{
name: "a collect spawn exit arm that is not a { code, output } record is taught",
gate: "NS1027",
formerEmitGate: "NS1027",
src: `
import { Cmd, asciiBytes, type FetchedKind } from "@native-sdk/core";
${streamMsg}
@@ -3832,7 +3832,7 @@ ${streamTail}
},
{
name: "a line arm on a collect spawn is taught (collect has no line framing)",
gate: "NS1027",
formerEmitGate: "NS1027",
src: `
import { Cmd, asciiBytes, type SpawnCollectRoute } from "@native-sdk/core";
${streamMsg}
@@ -3844,7 +3844,7 @@ ${streamTail}
},
{
name: "a dynamic argv value is taught (argv is an inline array literal)",
gate: "NS1029",
formerEmitGate: "NS1029",
src: `
import { Cmd, asciiBytes } from "@native-sdk/core";
${streamMsg}
@@ -3857,7 +3857,7 @@ ${streamTail}
},
{
name: "an empty argv stops at compile time",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd } from "@native-sdk/core";
${streamMsg}
@@ -3869,7 +3869,7 @@ ${streamTail}
},
{
name: "more argv elements than the engine accepts stops at compile time",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd, asciiBytes } from "@native-sdk/core";
${streamMsg}
@@ -3881,7 +3881,7 @@ ${streamTail}
},
{
name: "an argv block over the engine's byte bound stops at compile time",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd, asciiBytes } from "@native-sdk/core";
${streamMsg}
@@ -3893,7 +3893,7 @@ ${streamTail}
},
{
name: "an over-bound stdin literal stops at compile time",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd, asciiBytes } from "@native-sdk/core";
${streamMsg}
@@ -3905,7 +3905,7 @@ ${streamTail}
},
{
name: "an audio event arm whose state union misses a member is taught",
gate: "NS1027",
formerEmitGate: "NS1027",
src: `
import { Cmd, asciiBytes, type AudioEventKind } from "@native-sdk/core";
export type NarrowState = "loaded" | "position" | "completed" | "failed" | "rejected";
@@ -3926,7 +3926,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
},
{
name: "an audio event arm with a wrong field shape is taught",
gate: "NS1027",
formerEmitGate: "NS1027",
src: `
import { Cmd, asciiBytes, type AudioEventKind } from "@native-sdk/core";
${streamMsg}
@@ -3938,7 +3938,7 @@ ${streamTail}
},
{
name: "an audio source without a path or url is taught (nothing could play)",
gate: "NS1029",
formerEmitGate: "NS1029",
src: `
import { Cmd, asciiBytes } from "@native-sdk/core";
${streamMsg}
@@ -3950,7 +3950,7 @@ ${streamTail}
},
{
name: "a dynamic audio key is taught (keys are compile-time routing data)",
gate: "NS1027",
formerEmitGate: "NS1027",
src: `
import { Cmd } from "@native-sdk/core";
${streamMsg}
@@ -3962,7 +3962,7 @@ ${streamTail}
},
{
name: "an audio volume literal outside 0..1 stops at compile time",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd } from "@native-sdk/core";
${streamMsg}
@@ -3974,7 +3974,7 @@ ${streamTail}
},
{
name: "a negative audio seek literal stops at compile time",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd } from "@native-sdk/core";
${streamMsg}
@@ -4000,7 +4000,7 @@ ${imageTail}
},
{
name: "an image result arm whose state union misses a member is taught",
gate: "NS1027",
formerEmitGate: "NS1027",
src: `
import { Cmd, asciiBytes, type ImageEventKind } from "@native-sdk/core";
export type NarrowState = "loaded" | "rejected" | "decode_failed";
@@ -4019,7 +4019,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
},
{
name: "an image result arm with a wrong field shape is taught",
gate: "NS1027",
formerEmitGate: "NS1027",
src: `
import { Cmd, asciiBytes, type ImageEventKind } from "@native-sdk/core";
${imageMsg}
@@ -4031,7 +4031,7 @@ ${imageTail}
},
{
name: "an image source without a path or url is taught (nothing could load)",
gate: "NS1029",
formerEmitGate: "NS1029",
src: `
import { Cmd, asciiBytes } from "@native-sdk/core";
${imageMsg}
@@ -4043,7 +4043,7 @@ ${imageTail}
},
{
name: "an image id literal the registry must refuse stops at compile time",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd, asciiBytes } from "@native-sdk/core";
${imageMsg}
@@ -4070,7 +4070,7 @@ ${imageTail}
// 2^53 aliases 2^53 + 1 in f64 — the first id the wire cannot carry
// exactly, so the literal stops the build.
name: "an image id literal of 2^53 stops at compile time",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd, asciiBytes } from "@native-sdk/core";
${imageMsg}
@@ -4086,7 +4086,7 @@ ${imageTail}
// cache would verify every download against the wrong size and
// re-fetch on every launch. The literal stops the build.
name: "a fractional image expectedBytes literal stops at compile time",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd, asciiBytes } from "@native-sdk/core";
${imageMsg}
@@ -4123,7 +4123,7 @@ ${channelTail}
},
{
name: "a channel event arm whose state union misses a member is taught",
gate: "NS1027",
formerEmitGate: "NS1027",
src: `
import { Cmd, type ChannelEventKind } from "@native-sdk/core";
export type NarrowState = "data" | "closed";
@@ -4142,7 +4142,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
},
{
name: "a channel event arm with a wrong field shape is taught",
gate: "NS1027",
formerEmitGate: "NS1027",
src: `
import { Cmd, type ChannelEventKind } from "@native-sdk/core";
${channelMsg}
@@ -4154,7 +4154,7 @@ ${channelTail}
},
{
name: "a channel key literal the engine must refuse stops at compile time",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd } from "@native-sdk/core";
${channelMsg}
@@ -4167,7 +4167,7 @@ ${channelTail}
{
// 2^53 aliases 2^53 + 1 in f64 — the image id gate's bound, shared.
name: "a channel key literal of 2^53 stops at compile time",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd } from "@native-sdk/core";
${channelMsg}
@@ -4194,7 +4194,7 @@ ${imageTail}
// The same literal gate as imageLoad: an id no load could ever park
// under has nothing to cancel.
name: "an imageCancel id literal the registry must refuse stops at compile time",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd, asciiBytes } from "@native-sdk/core";
${imageMsg}
@@ -4217,7 +4217,7 @@ ${imageTail}
},
{
name: "an imageCancel id literal of 2^53 stops at compile time",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd, asciiBytes } from "@native-sdk/core";
${imageMsg}
@@ -4244,7 +4244,7 @@ ${imageTail}
// The same literal gate as imageLoad/imageCancel: an id no load
// could ever register under has nothing to unregister.
name: "an imageUnregister id literal the registry must refuse stops at compile time",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd, asciiBytes } from "@native-sdk/core";
${imageMsg}
@@ -4267,7 +4267,7 @@ ${imageTail}
},
{
name: "an imageUnregister id literal of 2^53 stops at compile time",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd, asciiBytes } from "@native-sdk/core";
${imageMsg}
@@ -4298,7 +4298,7 @@ ${videoTail}
},
{
name: "a video event arm whose state union misses a member is taught",
gate: "NS1027",
formerEmitGate: "NS1027",
src: `
import { Cmd, asciiBytes, type VideoEventKind } from "@native-sdk/core";
export type NarrowState = "loaded" | "position" | "completed" | "failed";
@@ -4338,7 +4338,7 @@ ${ptyTail}
// bare literal type is not a union), so the drift case is a
// misnamed member — the same NS1027 teaching.
name: "a pty event arm whose state union misnames a member is taught",
gate: "NS1027",
formerEmitGate: "NS1027",
src: `
import { Cmd, asciiBytes, type PtyEventKind } from "@native-sdk/core";
export type NarrowState = "output" | "done";
@@ -4358,7 +4358,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
},
{
name: "a video event arm with a wrong field shape is taught",
gate: "NS1027",
formerEmitGate: "NS1027",
src: `
import { Cmd, asciiBytes, type VideoEventKind } from "@native-sdk/core";
${videoMsg}
@@ -4370,7 +4370,7 @@ ${videoTail}
},
{
name: "a video source without a path or url is taught (nothing could play)",
gate: "NS1029",
formerEmitGate: "NS1029",
src: `
import { Cmd } from "@native-sdk/core";
${videoMsg}
@@ -4382,7 +4382,7 @@ ${videoTail}
},
{
name: "a video source without a surface is taught (the frames need a texture channel)",
gate: "NS1029",
formerEmitGate: "NS1029",
src: `
import { Cmd, asciiBytes } from "@native-sdk/core";
${videoMsg}
@@ -4394,7 +4394,7 @@ ${videoTail}
},
{
name: "a dynamic videoLoad option boolean is taught (the flags byte is build-time data)",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd, asciiBytes } from "@native-sdk/core";
${videoMsg}
@@ -4406,7 +4406,7 @@ ${videoTail}
},
{
name: "a dynamic video key is taught (keys are compile-time routing data)",
gate: "NS1027",
formerEmitGate: "NS1027",
src: `
import { Cmd } from "@native-sdk/core";
${videoMsg}
@@ -4418,7 +4418,7 @@ ${videoTail}
},
{
name: "a video volume literal outside 0..1 stops at compile time",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd } from "@native-sdk/core";
${videoMsg}
@@ -4430,7 +4430,7 @@ ${videoTail}
},
{
name: "a negative video seek literal stops at compile time",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd } from "@native-sdk/core";
${videoMsg}
@@ -4442,7 +4442,7 @@ ${videoTail}
},
{
name: "a pty event arm whose reason union misses a member is taught",
gate: "NS1027",
formerEmitGate: "NS1027",
src: `
import { Cmd, asciiBytes, type PtyEventKind } from "@native-sdk/core";
export type PtyState = "output" | "exit";
@@ -4462,7 +4462,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
},
{
name: "a pty event arm with a wrong field shape is taught",
gate: "NS1027",
formerEmitGate: "NS1027",
src: `
import { Cmd, asciiBytes, type PtyEventKind } from "@native-sdk/core";
${ptyMsg}
@@ -4474,7 +4474,7 @@ ${ptyTail}
},
{
name: "a zero pty grid literal stops at compile time",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd, asciiBytes } from "@native-sdk/core";
${ptyMsg}
@@ -4488,7 +4488,7 @@ ${ptyTail}
// Grids are u16 at the transport: the first unrepresentable
// dimension stops the build the way the zero does.
name: "a pty resize dimension literal past the transport bound stops at compile time",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd, asciiBytes } from "@native-sdk/core";
${ptyMsg}
@@ -4500,7 +4500,7 @@ ${ptyTail}
},
{
name: "an empty pty argv stops at compile time",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd } from "@native-sdk/core";
${ptyMsg}
@@ -4512,7 +4512,7 @@ ${ptyTail}
},
{
name: "a pty TERM literal over the engine bound stops at compile time",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd, asciiBytes } from "@native-sdk/core";
${ptyMsg}
@@ -4526,7 +4526,7 @@ ${ptyTail}
// Keystrokes and pastes, not bulk transfers: a compile-time-known
// payload over the engine's per-write bound stops the build.
name: "a pty write literal over the engine bound stops at compile time",
gate: "NS1030",
formerEmitGate: "NS1030",
src: `
import { Cmd, asciiBytes } from "@native-sdk/core";
${ptyMsg}
@@ -6718,7 +6718,7 @@ export function total(n: number): number {
},
{
name: "an early break out of a switch clause is gated (Zig break binds loops, not switches)",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export type Msg =
| { readonly kind: "a"; readonly v: number }
@@ -8495,7 +8495,7 @@ export function f(q: number | null, flag: boolean, msg: Msg): number {
// but the emitters have no clean arm mapping for that shape and stop
// with the fall-into-default teaching.
name: "an empty case falling into default gates at emission",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export type Mode = "a" | "b" | "c";
export function f(mode: Mode): number {
@@ -8699,7 +8699,7 @@ export function f(q: number | null, flag: boolean, s: string): number {
// lowerings have no boolean-scrutinee mapping, so the shape gates at
// emission before terminality can matter end to end.
name: "a switch on a boolean scrutinee gates at emission",
gate: "NS9001",
formerEmitGate: "NS9001",
src: `
export function f(q: number | null, flag: boolean): number {
let p: number | null = q;
@@ -10068,24 +10068,29 @@ const corpus: Case[] = [
...lexicalBlockFlowCases,
];
test("corpus: gated cases teach at check time, emit cases transpile clean", () => {
test("corpus: gated cases teach at check time, accepted cases check clean", () => {
const mismatches: string[] = [];
for (const c of corpus) {
const result = transpile(c.src);
const result = check(c.src);
assert.equal(result.typeErrors.length, 0, `${c.name}: tsc errors\n${result.typeErrors.join("\n")}`);
if (c.gate) {
assert.equal(result.ok, false, `${c.name}: expected ${c.gate}, but transpile succeeded`);
if (result.ok) {
mismatches.push(`${c.name}: expected ${c.gate}, but the check succeeded`);
continue;
}
const ids = result.diagnostics.map((d) => d.id);
assert.ok(ids.includes(c.gate), `${c.name}: expected ${c.gate}, got ${ids.join(", ") || "none"}`);
} else {
if (!ids.includes(c.gate)) mismatches.push(`${c.name}: expected ${c.gate}, got ${ids.join(", ") || "none"}`);
} else if (!result.ok) {
const details = result.diagnostics.map((d) => `${d.id} ${d.message}`).join("\n");
assert.equal(result.ok, true, `${c.name}: transpile failed\n${details}`);
mismatches.push(`${c.name}: check failed\n${details}`);
}
}
assert.deepEqual(mismatches, [], mismatches.join("\n\n"));
});
test("multi-file corpus: gated cases teach, emit cases transpile clean", () => {
for (const c of multiFileCases) {
const result = transpileFiles(c.files);
const result = checkFiles(c.files);
if (c.gate) {
assert.equal(result.ok, false, `${c.name}: expected ${c.gate}, but transpile succeeded`);
const ids = result.diagnostics.map((d) => d.id);
@@ -10100,68 +10105,8 @@ test("multi-file corpus: gated cases teach, emit cases transpile clean", () => {
}
});
test("private cross-file collisions take a per-module prefix in the emitted Zig", () => {
const c = multiFileCases.find((x) => x.name.includes("PRIVATE helpers"))!;
const result = transpileFiles(c.files);
assert.equal(result.ok, true);
// One `scale` keeps its name (first claim); the other gets `b_scale`.
assert.ok(result.zig!.includes("fn scale("), "first claimer keeps its spelling");
assert.ok(result.zig!.includes("fn b_scale("), `the collider takes the module prefix:\n${result.zig}`);
assert.ok(result.zig!.includes("b_scale(n)"), "references land on the prefixed name");
});
test("corpus: emitted Zig always compiles", { skip: !hasZig, timeout: 600_000 }, () => {
const work = fs.mkdtempSync(path.join(os.tmpdir(), "native-core-conformance-"));
try {
fs.copyFileSync(path.join(pkg, "rt", "rt.zig"), path.join(work, "rt.zig"));
const imports: string[] = [];
corpus.forEach((c, i) => {
if (c.gate) return;
const result = transpile(c.src);
assert.equal(result.ok, true, `${c.name}: transpile failed before the zig step`);
const file = `case_${String(i).padStart(2, "0")}.zig`;
fs.writeFileSync(path.join(work, file), result.zig!);
imports.push(` // ${c.name}\n refAllDecls(@import("${file}"));`);
});
multiFileCases.forEach((c, i) => {
if (c.gate) return;
const result = transpileFiles(c.files);
assert.equal(result.ok, true, `${c.name}: transpile failed before the zig step`);
const file = `multi_${String(i).padStart(2, "0")}.zig`;
fs.writeFileSync(path.join(work, file), result.zig!);
imports.push(` // ${c.name}\n refAllDecls(@import("${file}"));`);
});
const driver = [
`// Generated driver: reference every public decl of every emitted core so`,
`// the compiler semantically analyzes all of them (nothing runs).`,
`const refAllDecls = @import("std").testing.refAllDecls;`,
``,
`test {`,
...imports,
`}`,
``,
].join("\n");
fs.writeFileSync(path.join(work, "driver.zig"), driver);
// Both optimize modes, because they analyze differently: the wave-2
// release-only miscompiles (comptime-only enum literals under runtime
// control flow; @memcpy into a `[]const u8` parameter) surfaced only
// when an app's ReleaseFast build was the first release-mode analysis
// the emitted core ever got.
for (const mode of [[], ["-OReleaseFast"]] as const) {
try {
execFileSync("zig", ["test", ...mode, "driver.zig"], { cwd: work, encoding: "utf8", stdio: "pipe" });
} catch (e) {
const err = e as { stderr?: string; stdout?: string };
assert.fail(`emitted Zig failed to compile (${mode[0] ?? "Debug"}):\n${err.stderr ?? ""}${err.stdout ?? ""}`);
}
}
} finally {
fs.rmSync(work, { recursive: true, force: true });
}
});
test("NS1028: Cmd.persist still compiles but teaches the writeFile path as a warning", () => {
const result = transpile(`
const result = check(`
import { Cmd } from "@native-sdk/core";
export interface Model { readonly count: number; }
export type Msg = { readonly kind: "add" } | { readonly kind: "noop" };
@@ -10180,11 +10125,10 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
const w = result.warnings.find((d) => d.id === "NS1028");
assert.ok(w, "reports NS1028 as a warning");
assert.ok(w.message.includes("writeFile"), "points at the writeFile path");
assert.ok(result.zig!.includes("rt.cmdPersist()"), "wire support stays");
});
test("NS1016 speaks in rule, fix, and why", () => {
const result = transpile(`
const result = check(`
export function read(bytes: Uint8Array): number {
let i = 0;
i = 1.5;
@@ -10206,7 +10150,7 @@ export function read(bytes: Uint8Array): number {
// arm resolves to `never`, refusing the un-cast route at type-check time.
test("a narrower image state union fails ImageEventKind in tsc itself", () => {
const result = transpile(`
const result = check(`
import { Cmd, asciiBytes } from "@native-sdk/core";
export type NarrowState = "loaded" | "rejected" | "decode_failed";
export interface Model { readonly w: number; readonly errs: number; }
@@ -10226,7 +10170,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
});
test("the exact fifteen-member image state union still satisfies ImageEventKind in tsc", () => {
const result = transpile(`
const result = check(`
import { Cmd, asciiBytes } from "@native-sdk/core";
${imageMsg}
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
@@ -10239,7 +10183,7 @@ ${imageTail}
});
test("a narrower audio state union fails AudioEventKind in tsc itself", () => {
const result = transpile(`
const result = check(`
import { Cmd, asciiBytes } from "@native-sdk/core";
export type NarrowState = "loaded" | "position" | "completed" | "failed" | "rejected";
export interface Model { readonly pos: number; readonly errs: number; }
@@ -10261,7 +10205,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
});
test("the exact six-member audio state union still satisfies AudioEventKind in tsc", () => {
const result = transpile(`
const result = check(`
import { Cmd, asciiBytes } from "@native-sdk/core";
${streamMsg}
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
@@ -10274,7 +10218,7 @@ ${streamTail}
});
test("a narrower video state union fails VideoEventKind in tsc itself", () => {
const result = transpile(`
const result = check(`
import { Cmd, asciiBytes } from "@native-sdk/core";
export type NarrowState = "loaded" | "position" | "completed" | "failed";
export interface Model { readonly pos: number; readonly errs: number; }
@@ -10296,7 +10240,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
});
test("the exact five-member video state union still satisfies VideoEventKind in tsc", () => {
const result = transpile(`
const result = check(`
import { Cmd, asciiBytes } from "@native-sdk/core";
${videoMsg}
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
+2 -2
View File
@@ -10,7 +10,7 @@ import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { transpileFile } from "../src/transpile.ts";
import { checkFile } from "../src/frontend.ts";
import { wyhash, wyhashHex } from "../src/wyhash.ts";
/// Transpile a one-module core from a STABLE file name (core.ts in a
@@ -21,7 +21,7 @@ function contractOf(source: string): Record<string, unknown> {
try {
const entry = path.join(dir, "core.ts");
fs.writeFileSync(entry, source);
const result = transpileFile(entry, { contractEntry: "src/core.ts" });
const result = checkFile(entry, { contractEntry: "src/core.ts" });
assert.equal(result.ok, true, result.diagnostics.map((d) => d.message).join("\n") || result.typeErrors.join("\n"));
assert.notEqual(result.contract, null);
return JSON.parse(result.contract!) as Record<string, unknown>;
+7 -7
View File
@@ -1,8 +1,8 @@
// Docs honesty gate: every complete app-core sample in the docs transpiles.
// Docs honesty gate: every complete app-core sample in the docs checks clean.
//
// Scans docs/src/app/docs/**/page.mdx for ```ts fences (with or without a
// :filename info-string suffix) and runs the full
// pipeline (tsc semantics + subset rules + emission) over each block that
// pipeline (tsc semantics + subset rules + contract analysis) over each block that
// is a whole core — the discriminator is `export function update(`, the
// one export every complete core carries. Fragments (case-arm excerpts,
// type-only declarations) are teaching excerpts of the same idioms and are
@@ -13,7 +13,7 @@ import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { transpile } from "./helpers.ts";
import { check } from "./helpers.ts";
const repoRoot = path.dirname(
path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url)))),
@@ -34,21 +34,21 @@ function tsFences(source: string): string[] {
return [...source.matchAll(/^```ts(?::[^\n]*)?\n([\s\S]*?)^```$/gm)].map((match) => match[1]);
}
test("docs samples: every complete core in the docs transpiles clean", () => {
test("docs samples: every complete core in the docs checks clean", () => {
assert.ok(fs.existsSync(docsAppDir), `docs pages not found at ${docsAppDir}`);
let cores = 0;
for (const page of mdxPages(docsAppDir)) {
for (const fence of tsFences(fs.readFileSync(page, "utf8"))) {
if (!fence.includes("export function update(")) continue;
cores += 1;
const result = transpile(fence);
const result = check(fence);
const details = [
...result.typeErrors,
...result.diagnostics.map((d) => `${d.id} ${d.title}: ${d.message}`),
].join("\n");
assert.ok(
result.ok && result.zig !== null,
`${path.relative(docsAppDir, page)} has a core sample that fails the transpiler:\n${details}\n--- sample\n${fence}`,
result.ok,
`${path.relative(docsAppDir, page)} has a core sample that fails the checker:\n${details}\n--- sample\n${fence}`,
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-205
View File
@@ -1,205 +0,0 @@
//! Gate harness: drives the inbox app core through a scalar C ABI and
//! measures correctness, determinism, and keystroke-path latency. The run1k
//! mode writes the snapshot+effect log whose digest is the transpiler gate.
//!
//! Modes:
//! smoke — fixed short sequence, print snapshot hex (cross-impl oracle)
//! run1k <out-file> — deterministic 1k-message sequence; write snapshot+effect log
//! bench10k — 10k keystroke-path dispatches; latency stats (ns)
const std = @import("std");
const impl = @import("impl");
fn snapshotHex(alloc: std.mem.Allocator) ![]u8 {
const len: usize = @intFromFloat(impl.snapshot());
const hex = try alloc.alloc(u8, len * 2);
const digits = "0123456789abcdef";
for (0..len) |i| {
const b: u8 = @intFromFloat(impl.snapshotByte(@floatFromInt(i)));
hex[i * 2] = digits[b >> 4];
hex[i * 2 + 1] = digits[b & 0xf];
}
return hex;
}
fn smoke(alloc: std.mem.Allocator) !void {
impl.reset();
impl.pushText(104);
impl.pushText(105);
_ = impl.dispatch(4, 0, 0, 0, 0, 0);
_ = impl.dispatch(0, 0, 0, 0, 0, 0);
_ = impl.dispatch(1, 4, 0, 0, 0, 0);
_ = impl.dispatch(2, 1, 0, 0, 0, 0);
_ = impl.dispatch(5, 0, 0, 0, 0, 28.5);
const hex = try snapshotHex(alloc);
std.debug.print("smoke snapshot {s}\n", .{hex});
std.debug.print("smoke effects {d}\n", .{@as(u64, @intFromFloat(impl.effectLen()))});
}
const SplitMix = struct {
state: u64,
fn next(self: *SplitMix) u64 {
self.state +%= 0x9e3779b97f4a7c15;
var z = self.state;
z = (z ^ (z >> 30)) *% 0xbf58476d1ce4e5b9;
z = (z ^ (z >> 27)) *% 0x94d049bb133111eb;
return z ^ (z >> 31);
}
fn below(self: *SplitMix, n: u64) u64 {
return self.next() % n;
}
};
// One deterministic pseudo-random message; exercises every Msg arm,
// including multi-byte UTF-8 inserts, compositions, invalid toggle ids,
// and fractional chrome insets.
fn sendRandomMsg(rng: *SplitMix) void {
const roll = rng.below(100);
if (roll < 45) {
// draft edits dominate, like real typing
const edit = rng.below(11);
switch (edit) {
0, 1, 2, 3 => { // insert 1-3 chars, sometimes multi-byte UTF-8
const n = 1 + rng.below(3);
for (0..n) |_| {
if (rng.below(8) == 0) {
// U+00E9 é = 0xC3 0xA9
impl.pushText(0xc3);
impl.pushText(0xa9);
} else {
impl.pushText(@floatFromInt(0x61 + rng.below(26)));
}
}
_ = impl.dispatch(4, 0, 0, 0, 0, 0);
},
4 => _ = impl.dispatch(4, 1, 0, 0, 0, 0), // delete_backward
5 => _ = impl.dispatch(4, 2, 0, 0, 0, 0), // delete_forward
6 => _ = impl.dispatch(4, @floatFromInt(3 + rng.below(2)), 0, 0, 0, 0), // word deletes
7 => _ = impl.dispatch(4, 6, @floatFromInt(rng.below(6)), @floatFromInt(rng.below(2)), 0, 0), // move_caret
8 => _ = impl.dispatch(4, 7, @floatFromInt(rng.below(40)), @floatFromInt(rng.below(40)), 0, 0), // set_selection
9 => { // set_composition then commit or cancel
const n = rng.below(4);
for (0..n) |_| impl.pushText(@floatFromInt(0x61 + rng.below(26)));
const cursor: f64 = if (rng.below(2) == 0) -1 else @floatFromInt(rng.below(5));
_ = impl.dispatch(4, 8, cursor, 0, 0, 0);
_ = impl.dispatch(4, if (rng.below(2) == 0) 9 else 10, 0, 0, 0, 0);
},
else => _ = impl.dispatch(4, 5, 0, 0, 0, 0), // clear
}
} else if (roll < 65) {
_ = impl.dispatch(0, 0, 0, 0, 0, 0); // add
} else if (roll < 80) {
_ = impl.dispatch(1, @floatFromInt(rng.below(80)), 0, 0, 0, 0); // toggle (some ids invalid)
} else if (roll < 88) {
_ = impl.dispatch(2, @floatFromInt(rng.below(3)), 0, 0, 0, 0); // set_filter
} else if (roll < 94) {
_ = impl.dispatch(3, 0, 0, 0, 0, 0); // clear_done
} else {
// chrome_changed with fractional insets
const left: f64 = @as(f64, @floatFromInt(rng.below(120))) * 0.5;
const top: f64 = @as(f64, @floatFromInt(rng.below(160))) * 0.5;
_ = impl.dispatch(5, 0, 0, 0, left, top);
}
}
fn run1k(alloc: std.mem.Allocator, io: std.Io, out_path: []const u8) !void {
impl.reset();
var rng = SplitMix{ .state = 0x5eed_ba5e_0000_1234 };
for (0..1000) |_| sendRandomMsg(&rng);
var out: std.ArrayList(u8) = .empty;
defer out.deinit(alloc);
const hex = try snapshotHex(alloc);
try out.appendSlice(alloc, "snapshot ");
try out.appendSlice(alloc, hex);
try out.appendSlice(alloc, "\neffects ");
const eff_len: usize = @intFromFloat(impl.effectLen());
const digits = "0123456789abcdef";
for (0..eff_len) |i| {
const b: u8 = @intFromFloat(impl.effectByte(@floatFromInt(i)));
try out.append(alloc, digits[b >> 4]);
try out.append(alloc, digits[b & 0xf]);
}
try out.appendSlice(alloc, "\n");
try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = out_path, .data = out.items });
std.debug.print("run1k wrote {s} ({d} bytes, {d} effect bytes)\n", .{ out_path, out.items.len, eff_len });
}
// Raw monotonic nanoseconds (darwin). ~41ns granularity on Apple Silicon.
extern "c" fn clock_gettime_nsec_np(clock_id: c_int) u64;
const CLOCK_UPTIME_RAW: c_int = 8;
inline fn nowNs() u64 {
return clock_gettime_nsec_np(CLOCK_UPTIME_RAW);
}
fn bench10k(alloc: std.mem.Allocator) !void {
impl.reset();
var rng = SplitMix{ .state = 0xdead_beef_cafe_f00d };
const iters = 10_000;
const samples = try alloc.alloc(u64, iters);
const bench_start = nowNs();
for (0..iters) |i| {
// Keystroke-shaped mix: mostly single-char inserts.
const roll = rng.below(1000);
const t0 = nowNs();
if (roll < 700) {
impl.pushText(@floatFromInt(0x61 + rng.below(26)));
_ = impl.dispatch(4, 0, 0, 0, 0, 0);
} else if (roll < 800) {
_ = impl.dispatch(4, 1, 0, 0, 0, 0); // backspace
} else if (roll < 870) {
_ = impl.dispatch(4, 6, @floatFromInt(rng.below(6)), 0, 0, 0); // caret move
} else if (roll < 920) {
_ = impl.dispatch(0, 0, 0, 0, 0, 0); // add (submit)
} else if (roll < 970) {
_ = impl.dispatch(1, @floatFromInt(rng.below(80)), 0, 0, 0, 0); // toggle
} else if (roll < 990) {
_ = impl.dispatch(2, @floatFromInt(rng.below(3)), 0, 0, 0, 0); // filter
} else {
_ = impl.dispatch(3, 0, 0, 0, 0, 0); // clear_done
}
samples[i] = nowNs() - t0;
}
const wall_total = nowNs() - bench_start;
std.mem.sort(u64, samples, {}, std.sort.asc(u64));
var total: u64 = 0;
for (samples) |s| total += s;
std.debug.print("bench10k wall total ns: {d} (mean/dispatch incl. harness: {d})\n", .{ wall_total, wall_total / iters });
std.debug.print(
"bench10k ns: min={d} p50={d} p90={d} p99={d} p999={d} max={d} mean={d}\n",
.{
samples[0],
samples[iters / 2],
samples[(iters * 90) / 100],
samples[(iters * 99) / 100],
samples[(iters * 999) / 1000],
samples[iters - 1],
total / iters,
},
);
// Top 5 outliers for GC-pause inspection.
std.debug.print("bench10k top5: {d} {d} {d} {d} {d}\n", .{
samples[iters - 1], samples[iters - 2], samples[iters - 3],
samples[iters - 4], samples[iters - 5],
});
}
pub fn main(init: std.process.Init) !void {
const alloc = init.arena.allocator();
impl.init();
const argv = init.minimal.args.vector;
const mode: []const u8 = if (argv.len > 1) std.mem.span(argv[1]) else "smoke";
if (std.mem.eql(u8, mode, "smoke")) {
try smoke(alloc);
} else if (std.mem.eql(u8, mode, "run1k")) {
try run1k(alloc, init.io, std.mem.span(argv[2]));
} else if (std.mem.eql(u8, mode, "bench10k")) {
try bench10k(alloc);
} else {
std.debug.print("unknown mode {s}\n", .{mode});
}
}
-43
View File
@@ -1,43 +0,0 @@
//! Harness binding over the emitted core's C-ABI shim (static lib).
extern fn inbox_reset() callconv(.c) void;
extern fn inbox_push_text(byte: f64) callconv(.c) void;
extern fn inbox_dispatch(tag: f64, a: f64, b: f64, c: f64, f0: f64, f1: f64) callconv(.c) f64;
extern fn inbox_snapshot() callconv(.c) f64;
extern fn inbox_snapshot_byte(i: f64) callconv(.c) f64;
extern fn inbox_effect_len() callconv(.c) f64;
extern fn inbox_effect_byte(i: f64) callconv(.c) f64;
pub const name = "zig";
pub fn init() void {
inbox_reset();
}
pub fn reset() void {
inbox_reset();
}
pub fn pushText(byte: f64) void {
inbox_push_text(byte);
}
pub fn dispatch(tag: f64, a: f64, b: f64, c: f64, f0: f64, f1: f64) f64 {
return inbox_dispatch(tag, a, b, c, f0, f1);
}
pub fn snapshot() f64 {
return inbox_snapshot();
}
pub fn snapshotByte(i: f64) f64 {
return inbox_snapshot_byte(i);
}
pub fn effectLen() f64 {
return inbox_effect_len();
}
pub fn effectByte(i: f64) f64 {
return inbox_effect_byte(i);
}
-688
View File
@@ -1,688 +0,0 @@
// The transpiler gate fixture: an idiomatic app core written in the
// TypeScript subset — pure `update(model, msg): Model`, immutable model built
// with spreads/map/filter, discriminated unions, Uint8Array for text bytes.
// Its native run1k digest must match the hand-written oracle's byte for byte.
//
// Subset rules exercised here:
// - interfaces with readonly fields; no classes
// - discriminated unions with a `kind` tag
// - spread updates `{ ...model, field: v }`, array spread, map/filter
// - Uint8Array as the byte-string type (subarray = view, slice = copy)
// - locals may be reassigned; update inputs are never mutated
// - fresh Uint8Array may be written to until it escapes (builder rule)
// - the SDK asciiBytes intrinsic folds literals/templates into bytes
import { asciiBytes } from "@native-sdk/core";
// ------------------------------------------------------------------ tuning
export const MAX_TASKS = 64;
export const MAX_TASK_TITLE = 32;
export const HEADER_NATURAL_HEIGHT = 52;
export type Bytes = Uint8Array;
// ------------------------------------------------------------- text engine
export interface TextRange {
readonly start: number;
readonly end: number;
}
export interface TextSelection {
readonly anchor: number;
readonly focus: number;
}
export type TextCaretDirection =
| "previous"
| "next"
| "previous_word"
| "next_word"
| "start"
| "end";
export interface TextCaretMove {
readonly direction: TextCaretDirection;
readonly extend: boolean;
}
export type TextInputEvent =
| { readonly kind: "insert_text"; readonly text: Bytes }
| { readonly kind: "delete_backward" }
| { readonly kind: "delete_forward" }
| { readonly kind: "delete_word_backward" }
| { readonly kind: "delete_word_forward" }
| { readonly kind: "clear" }
| { readonly kind: "move_caret"; readonly move: TextCaretMove }
| { readonly kind: "set_selection"; readonly selection: TextSelection }
| { readonly kind: "set_composition"; readonly text: Bytes; readonly cursor: number | null }
| { readonly kind: "commit_composition" }
| { readonly kind: "cancel_composition" };
interface TextEditState {
readonly text: Bytes;
readonly selection: TextSelection;
readonly composition: TextRange | null;
}
function rangeNormalized(r: TextRange, textLen: number): TextRange {
const start = Math.min(r.start, textLen);
const end = Math.min(r.end, textLen);
return start <= end ? { start: start, end: end } : { start: end, end: start };
}
function rangeByteLen(r: TextRange, textLen: number): number {
const n = rangeNormalized(r, textLen);
return n.end - n.start;
}
function rangeIsCollapsed(r: TextRange, textLen: number): boolean {
const n = rangeNormalized(r, textLen);
return n.start === n.end;
}
function selectionRange(s: TextSelection, textLen: number): TextRange {
return rangeNormalized({ start: s.anchor, end: s.focus }, textLen);
}
export function isUtf8ContinuationByte(byte: number): boolean {
return (byte & 0xc0) === 0x80;
}
export function utf8SequenceLength(lead: number): number {
if ((lead & 0x80) === 0) return 1;
if ((lead & 0xe0) === 0xc0) return 2;
if ((lead & 0xf0) === 0xe0) return 3;
if ((lead & 0xf8) === 0xf0) return 4;
return 1;
}
export function snapTextOffset(text: Bytes, offset: number): number {
let cursor = Math.min(offset, text.length);
while (cursor > 0 && cursor < text.length && isUtf8ContinuationByte(text[cursor])) {
cursor -= 1;
}
return cursor;
}
export function previousTextOffset(text: Bytes, offset: number): number {
let cursor = snapTextOffset(text, offset);
if (cursor === 0) return 0;
cursor -= 1;
while (cursor > 0 && isUtf8ContinuationByte(text[cursor])) {
cursor -= 1;
}
return cursor;
}
export function nextTextOffset(text: Bytes, offset: number): number {
const cursor = snapTextOffset(text, offset);
if (cursor >= text.length) return text.length;
const next = Math.min(text.length, cursor + utf8SequenceLength(text[cursor]));
// Fallback-scalar rule for invalid UTF-8: never stall or reverse the walk
// on an orphan continuation byte.
if (next <= offset) return Math.min(text.length, offset + 1);
return next;
}
function snapTextCaretOffset(text: Bytes, offset: number): number {
const cursor = snapTextOffset(text, offset);
if (
cursor > 0 &&
cursor < text.length &&
text[cursor] === 0x0a &&
text[cursor - 1] === 0x0d
) {
return cursor - 1;
}
return cursor;
}
function previousTextCaretOffset(text: Bytes, offset: number): number {
const previous = previousTextOffset(text, offset);
if (previous > 0 && text[previous] === 0x0a && text[previous - 1] === 0x0d) {
return previous - 1;
}
return previous;
}
function nextTextCaretOffset(text: Bytes, offset: number): number {
const cursor = snapTextOffset(text, offset);
if (
cursor < text.length &&
text[cursor] === 0x0d &&
cursor + 1 < text.length &&
text[cursor + 1] === 0x0a
) {
return cursor + 2;
}
return nextTextOffset(text, cursor);
}
function isAsciiAlphanumeric(b: number): boolean {
return (
(b >= 0x30 && b <= 0x39) ||
(b >= 0x41 && b <= 0x5a) ||
(b >= 0x61 && b <= 0x7a)
);
}
function isAsciiWhitespace(b: number): boolean {
return b === 0x20 || b === 0x09 || b === 0x0a || b === 0x0d || b === 0x0b || b === 0x0c;
}
type TextRunClass = 0 | 1 | 2; // word, space, other
function textRunClassAt(text: Bytes, offset: number): TextRunClass | null {
const cursor = snapTextOffset(text, offset);
if (cursor >= text.length) return null;
const lead = text[cursor];
if ((lead & 0x80) !== 0) return 0;
if (isAsciiAlphanumeric(lead) || lead === 0x5f) return 0;
if (isAsciiWhitespace(lead)) return 1;
return 2;
}
function textOffsetStartsWord(text: Bytes, offset: number): boolean {
const cls = textRunClassAt(text, offset);
return cls !== null && cls === 0;
}
export function previousTextWordOffset(text: Bytes, offset: number): number {
let cursor = snapTextOffset(text, offset);
while (cursor > 0) {
const previous = previousTextOffset(text, cursor);
if (textOffsetStartsWord(text, previous)) break;
cursor = previous;
}
while (cursor > 0) {
const previous = previousTextOffset(text, cursor);
if (!textOffsetStartsWord(text, previous)) break;
cursor = previous;
}
return cursor;
}
export function nextTextWordOffset(text: Bytes, offset: number): number {
let cursor = snapTextOffset(text, offset);
while (cursor < text.length && !textOffsetStartsWord(text, cursor)) {
cursor = nextTextOffset(text, cursor);
}
while (cursor < text.length && textOffsetStartsWord(text, cursor)) {
cursor = nextTextOffset(text, cursor);
}
return cursor;
}
function snapTextCaretSelection(text: Bytes, selection: TextSelection): TextSelection {
return {
anchor: snapTextCaretOffset(text, selection.anchor),
focus: snapTextCaretOffset(text, selection.focus),
};
}
function snapTextRange(text: Bytes, range: TextRange): TextRange {
const normalized = rangeNormalized(range, text.length);
return rangeNormalized(
{
start: snapTextOffset(text, normalized.start),
end: snapTextOffset(text, normalized.end),
},
text.length,
);
}
interface TextReplaceResult {
readonly text: Bytes;
readonly insertedStart: number;
readonly insertedEnd: number;
}
// Returns null when the result would exceed `capacity` (the oracle's
// error.TextEditBufferTooSmall seam).
function replaceTextRange(
source: Bytes,
range: TextRange,
replacement: Bytes,
capacity: number,
): TextReplaceResult | null {
const snapped = snapTextRange(source, range);
const prefixLen = snapped.start;
const suffixStart = prefixLen + replacement.length;
const nextLen = prefixLen + replacement.length + (source.length - snapped.end);
if (nextLen > capacity) return null;
const out = new Uint8Array(nextLen);
out.set(source.subarray(0, prefixLen), 0);
out.set(replacement, prefixLen);
out.set(source.subarray(snapped.end), suffixStart);
return { text: out, insertedStart: prefixLen, insertedEnd: suffixStart };
}
function normalizeTextEditState(state: TextEditState): TextEditState {
return {
text: state.text,
selection: snapTextCaretSelection(state.text, state.selection),
composition:
state.composition !== null ? snapTextRange(state.text, state.composition) : null,
};
}
function activeTextReplaceRange(state: TextEditState): TextRange {
if (state.composition !== null) return snapTextRange(state.text, state.composition);
return selectionRange(state.selection, state.text.length);
}
function replaceTextEditRange(
state: TextEditState,
range: TextRange,
replacement: Bytes,
capacity: number,
composition: TextRange | null,
cursorOffset: number,
): TextEditState | null {
const result = replaceTextRange(state.text, range, replacement, capacity);
if (result === null) return null;
const cursor = snapTextCaretOffset(
result.text,
result.insertedStart + Math.min(cursorOffset, replacement.length),
);
return {
text: result.text,
selection: { anchor: cursor, focus: cursor },
composition: composition,
};
}
function setTextComposition(
state: TextEditState,
text: Bytes,
cursorIn: number | null,
capacity: number,
): TextEditState | null {
const range = activeTextReplaceRange(state);
const cursor = snapTextCaretOffset(text, cursorIn === null ? text.length : cursorIn);
const result = replaceTextRange(state.text, range, text, capacity);
if (result === null) return null;
const absoluteCursor = snapTextCaretOffset(result.text, result.insertedStart + cursor);
return {
text: result.text,
selection: { anchor: absoluteCursor, focus: absoluteCursor },
composition: { start: result.insertedStart, end: result.insertedEnd },
};
}
function cancelTextComposition(state: TextEditState, capacity: number): TextEditState | null {
if (state.composition === null) return state;
const range = snapTextRange(state.text, state.composition);
const result = replaceTextRange(state.text, range, new Uint8Array(0), capacity);
if (result === null) return null;
return {
text: result.text,
selection: snapTextCaretSelection(result.text, {
anchor: result.insertedStart,
focus: result.insertedStart,
}),
composition: null,
};
}
function deleteBackwardTextEdit(state: TextEditState, capacity: number): TextEditState | null {
const range = activeTextReplaceRange(state);
if (!rangeIsCollapsed(range, state.text.length)) {
return replaceTextEditRange(state, range, new Uint8Array(0), capacity, null, 0);
}
const caret = snapTextCaretOffset(state.text, state.selection.focus);
if (caret === 0) {
return { text: state.text, selection: { anchor: 0, focus: 0 }, composition: null };
}
return replaceTextEditRange(
state,
{ start: previousTextCaretOffset(state.text, caret), end: caret },
new Uint8Array(0),
capacity,
null,
0,
);
}
function deleteForwardTextEdit(state: TextEditState, capacity: number): TextEditState | null {
const range = activeTextReplaceRange(state);
if (!rangeIsCollapsed(range, state.text.length)) {
return replaceTextEditRange(state, range, new Uint8Array(0), capacity, null, 0);
}
const caret = snapTextCaretOffset(state.text, state.selection.focus);
if (caret >= state.text.length) {
const len = state.text.length;
return { text: state.text, selection: { anchor: len, focus: len }, composition: null };
}
return replaceTextEditRange(
state,
{ start: caret, end: nextTextCaretOffset(state.text, caret) },
new Uint8Array(0),
capacity,
null,
0,
);
}
function deleteWordBackwardTextEdit(
state: TextEditState,
capacity: number,
): TextEditState | null {
const range = activeTextReplaceRange(state);
if (!rangeIsCollapsed(range, state.text.length)) {
return replaceTextEditRange(state, range, new Uint8Array(0), capacity, null, 0);
}
const caret = snapTextCaretOffset(state.text, state.selection.focus);
if (caret === 0) {
return { text: state.text, selection: { anchor: 0, focus: 0 }, composition: null };
}
return replaceTextEditRange(
state,
{ start: previousTextWordOffset(state.text, caret), end: caret },
new Uint8Array(0),
capacity,
null,
0,
);
}
function deleteWordForwardTextEdit(
state: TextEditState,
capacity: number,
): TextEditState | null {
const range = activeTextReplaceRange(state);
if (!rangeIsCollapsed(range, state.text.length)) {
return replaceTextEditRange(state, range, new Uint8Array(0), capacity, null, 0);
}
const caret = snapTextCaretOffset(state.text, state.selection.focus);
if (caret >= state.text.length) {
const len = state.text.length;
return { text: state.text, selection: { anchor: len, focus: len }, composition: null };
}
return replaceTextEditRange(
state,
{ start: caret, end: nextTextWordOffset(state.text, caret) },
new Uint8Array(0),
capacity,
null,
0,
);
}
function moveTextCaret(state: TextEditState, move: TextCaretMove): TextEditState {
const range = selectionRange(state.selection, state.text.length);
const focus = snapTextCaretOffset(state.text, state.selection.focus);
const collapsed = rangeIsCollapsed(range, state.text.length);
let target: number;
if (move.direction === "previous") {
target =
!move.extend && !collapsed ? range.start : previousTextCaretOffset(state.text, focus);
} else if (move.direction === "next") {
target = !move.extend && !collapsed ? range.end : nextTextCaretOffset(state.text, focus);
} else if (move.direction === "previous_word") {
target =
!move.extend && !collapsed ? range.start : previousTextWordOffset(state.text, focus);
} else if (move.direction === "next_word") {
target = !move.extend && !collapsed ? range.end : nextTextWordOffset(state.text, focus);
} else if (move.direction === "start") {
target = 0;
} else {
target = state.text.length;
}
const selection: TextSelection = move.extend
? { anchor: state.selection.anchor, focus: target }
: { anchor: target, focus: target };
return {
text: state.text,
selection: snapTextCaretSelection(state.text, selection),
composition: null,
};
}
function applyTextInputEvent(
state: TextEditState,
event: TextInputEvent,
capacity: number,
): TextEditState | null {
const normalized = normalizeTextEditState(state);
switch (event.kind) {
case "insert_text":
return replaceTextEditRange(
normalized,
activeTextReplaceRange(normalized),
event.text,
capacity,
null,
event.text.length,
);
case "delete_backward":
return deleteBackwardTextEdit(normalized, capacity);
case "delete_forward":
return deleteForwardTextEdit(normalized, capacity);
case "delete_word_backward":
return deleteWordBackwardTextEdit(normalized, capacity);
case "delete_word_forward":
return deleteWordForwardTextEdit(normalized, capacity);
case "clear":
return { text: new Uint8Array(0), selection: { anchor: 0, focus: 0 }, composition: null };
case "move_caret":
return moveTextCaret(normalized, event.move);
case "set_selection":
return {
text: normalized.text,
selection: snapTextCaretSelection(normalized.text, event.selection),
composition: null,
};
case "set_composition":
return setTextComposition(normalized, event.text, event.cursor, capacity);
case "commit_composition":
return {
text: normalized.text,
selection: normalized.selection,
composition: null,
};
case "cancel_composition":
return cancelTextComposition(normalized, capacity);
}
}
// For an over-capacity insert_text, the same event with its payload clamped
// (at a UTF-8 boundary) to the bytes that fit. Null when not an insertion or
// nothing fits.
function clampedInsertEvent(
state: TextEditState,
event: TextInputEvent,
capacity: number,
): TextInputEvent | null {
if (event.kind !== "insert_text") return null;
const insertion = event.text;
const normalized = normalizeTextEditState(state);
const replaced = rangeByteLen(activeTextReplaceRange(normalized), normalized.text.length);
const kept = normalized.text.length - replaced;
if (kept >= capacity) return null;
const available = capacity - kept;
if (available >= insertion.length) return null;
const clampedLen = snapTextOffset(insertion, available);
if (clampedLen === 0) return null;
return { kind: "insert_text", text: insertion.subarray(0, clampedLen) };
}
// ------------------------------------------------------------------- draft
// Fixed-capacity editor state, mirroring the runtime's TextBuffer. Immutable:
// draftApply returns a new Draft.
export interface Draft {
readonly bytes: Bytes;
readonly anchor: number;
readonly focus: number;
readonly compStart: number; // -1 when no composition
readonly compEnd: number;
readonly truncated: boolean;
}
export function draftInit(): Draft {
return {
bytes: new Uint8Array(0),
anchor: 0,
focus: 0,
compStart: -1,
compEnd: -1,
truncated: false,
};
}
function draftState(d: Draft): TextEditState {
return {
text: d.bytes,
selection: { anchor: d.anchor, focus: d.focus },
composition: d.compStart >= 0 ? { start: d.compStart, end: d.compEnd } : null,
};
}
function draftCommit(next: TextEditState, truncated: boolean): Draft {
const nextLen = Math.min(next.text.length, MAX_TASK_TITLE);
return {
bytes: next.text.slice(0, nextLen),
anchor: next.selection.anchor,
focus: next.selection.focus,
compStart: next.composition !== null ? next.composition.start : -1,
compEnd: next.composition !== null ? next.composition.end : -1,
truncated: truncated,
};
}
export function draftApply(d: Draft, event: TextInputEvent): Draft {
const state = draftState(d);
const next = applyTextInputEvent(state, event, MAX_TASK_TITLE);
if (next === null) {
const clamped = clampedInsertEvent(state, event, MAX_TASK_TITLE);
if (clamped === null) return { ...d, truncated: true };
const nextClamped = applyTextInputEvent(state, clamped, MAX_TASK_TITLE);
if (nextClamped === null) return { ...d, truncated: true };
return draftCommit(nextClamped, true);
}
return draftCommit(next, false);
}
// Clearing preserves `truncated` — it reports the most recent apply, matching
// the oracle.
export function draftClear(d: Draft): Draft {
return { ...d, bytes: new Uint8Array(0), anchor: 0, focus: 0, compStart: -1, compEnd: -1 };
}
export function draftIsEmpty(d: Draft): boolean {
// std.mem.trim(u8, text, " \t").len == 0
let start = 0;
let end = d.bytes.length;
while (start < end && (d.bytes[start] === 0x20 || d.bytes[start] === 0x09)) start += 1;
while (end > start && (d.bytes[end - 1] === 0x20 || d.bytes[end - 1] === 0x09)) end -= 1;
return end - start === 0;
}
// ------------------------------------------------------------------- model
export type Filter = "all" | "active" | "done";
export interface Task {
readonly id: number;
readonly title: Bytes; // UTF-8, max MAX_TASK_TITLE bytes
readonly done: boolean;
}
export interface Model {
readonly tasks: readonly Task[];
readonly nextId: number;
readonly filter: Filter;
readonly chromeLeading: number;
readonly headerHeight: number;
readonly draft: Draft;
}
function addTask(model: Model, text: Bytes): Model {
if (model.tasks.length >= MAX_TASKS) return model;
const task: Task = {
id: model.nextId,
title: text.slice(0, Math.min(text.length, MAX_TASK_TITLE)),
done: false,
};
return { ...model, tasks: [...model.tasks, task], nextId: model.nextId + 1 };
}
function addGeneratedTask(model: Model): Model {
return addTask(model, asciiBytes(`Task ${model.nextId}`));
}
export function openCount(model: Model): number {
return model.tasks.filter((t) => !t.done).length;
}
export function doneCount(model: Model): number {
return model.tasks.length - openCount(model);
}
// --------------------------------------------------------------------- msg
export type Msg =
| { readonly kind: "add" }
| { readonly kind: "toggle"; readonly id: number }
| { readonly kind: "set_filter"; readonly filter: Filter }
| { readonly kind: "clear_done" }
| { readonly kind: "draft_edit"; readonly edit: TextInputEvent }
| { readonly kind: "chrome_changed"; readonly insetLeft: number; readonly insetTop: number };
// ------------------------------------------------------------------ update
function trimSpaces(bytes: Bytes): Bytes {
// std.mem.trim(u8, draft, " ") — spaces only.
let start = 0;
let end = bytes.length;
while (start < end && bytes[start] === 0x20) start += 1;
while (end > start && bytes[end - 1] === 0x20) end -= 1;
return bytes.subarray(start, end);
}
export function update(model: Model, msg: Msg): Model {
switch (msg.kind) {
case "add": {
if (draftIsEmpty(model.draft)) return addGeneratedTask(model);
const added = addTask(model, trimSpaces(model.draft.bytes));
return { ...added, draft: draftClear(added.draft) };
}
case "toggle":
return {
...model,
tasks: model.tasks.map((t) => (t.id === msg.id ? { ...t, done: !t.done } : t)),
};
case "set_filter":
return { ...model, filter: msg.filter };
case "clear_done":
return { ...model, tasks: model.tasks.filter((t) => !t.done) };
case "draft_edit":
return { ...model, draft: draftApply(model.draft, msg.edit) };
case "chrome_changed":
return {
...model,
chromeLeading: msg.insetLeft,
headerHeight: Math.max(HEADER_NATURAL_HEIGHT, msg.insetTop),
};
}
}
// ------------------------------------------------------------ initial model
export function initialModel(): Model {
let model: Model = {
tasks: [],
nextId: 1,
filter: "all",
chromeLeading: 0,
headerHeight: HEADER_NATURAL_HEIGHT,
draft: draftInit(),
};
model = addTask(model, asciiBytes("Prove the ui builder end to end"));
model = addTask(model, asciiBytes("Rewrite gpu-dashboard with it"));
model = addTask(model, asciiBytes("Record the authoring decisions"));
return model;
}
-189
View File
@@ -1,189 +0,0 @@
//! Scalar C-ABI shim over the emitted core — the wire contract the gate
//! harness speaks, byte-comparable with the hand-written oracle. Host-side
//! buffers (staged text, effect log, snapshot) live outside the core; the
//! app core behind them is the transpiler's output (inbox_core.zig).
const std = @import("std");
const core = @import("inbox_core.zig");
// The core's own kernel instance: capacities are comptime parameters of the
// emitted `core.rt`, so the shim reaches the arenas through the core.
const rt = core.rt;
// The SDK runtime installs a single minimal panic handler; emitted code's
// checked failures (arena overflow, index checks in safe builds) abort with
// a message rather than pulling the full stack-trace/DWARF printer into
// every app binary.
pub const panic = std.debug.simple_panic;
var g_model: *const core.Model = undefined;
var g_staged: [4096]u8 = undefined;
var g_staged_len: usize = 0;
var g_effects: std.ArrayList(u8) = .empty;
var g_snapshot: std.ArrayList(u8) = .empty;
const gpa = std.heap.c_allocator;
export fn inbox_reset() callconv(.c) void {
rt.resetAll();
g_model = core.commitModelRoot(core.initialModel());
rt.frameReset();
g_staged_len = 0;
g_effects.clearRetainingCapacity();
g_snapshot.clearRetainingCapacity();
}
export fn inbox_push_text(byte: f64) callconv(.c) void {
if (g_staged_len < g_staged.len) {
g_staged[g_staged_len] = @intFromFloat(byte);
g_staged_len += 1;
}
}
fn caretDirectionOf(i: i64) core.TextCaretDirection {
return switch (i) {
0 => .previous,
1 => .next,
2 => .previous_word,
3 => .next_word,
4 => .start,
else => .end,
};
}
/// Staged bytes become the event payload; copied into the frame arena so the
/// payload has dispatch lifetime (the JS shim built a fresh array likewise).
fn takeStaged() core.Bytes {
const text = rt.frameAlloc(u8, g_staged_len);
@memcpy(text, g_staged[0..g_staged_len]);
g_staged_len = 0;
return text;
}
fn decodeEdit(a: i64, b: i64, c: i64) core.TextInputEvent {
switch (a) {
0 => return .{ .insert_text = takeStaged() },
1 => return .delete_backward,
2 => return .delete_forward,
3 => return .delete_word_backward,
4 => return .delete_word_forward,
5 => return .clear,
6 => return .{ .move_caret = .{ .direction = caretDirectionOf(b), .extend = c != 0 } },
7 => return .{ .set_selection = .{ .anchor = b, .focus = c } },
8 => return .{ .set_composition = .{ .text = takeStaged(), .cursor = if (b < 0) null else b } },
9 => return .commit_composition,
else => return .cancel_composition,
}
}
export fn inbox_dispatch(tag: f64, a: f64, b: f64, c: f64, f0: f64, f1: f64) callconv(.c) f64 {
const tag_i: i64 = @intFromFloat(tag);
const a_i: i64 = @intFromFloat(a);
const b_i: i64 = @intFromFloat(b);
const c_i: i64 = @intFromFloat(c);
const msg: core.Msg = switch (tag_i) {
0 => .add,
1 => .{ .toggle = a_i },
2 => .{ .set_filter = switch (a_i) {
1 => .active,
2 => .done,
else => .all,
} },
3 => .clear_done,
4 => .{ .draft_edit = decodeEdit(a_i, b_i, c_i) },
else => .{ .chrome_changed = .{ .insetLeft = f0, .insetTop = f1 } },
};
// The dispatch cycle the transpiler emits: pure update in the frame
// arena, commit the returned tree, free the frame wholesale.
const next = core.update(g_model, msg);
g_model = core.commitModelRoot(next);
rt.frameReset();
const m = g_model;
const eff = [_]u8{
@intCast(tag_i & 0xff),
@intCast(@as(i64, @intCast(m.tasks.len)) & 0xff),
@intCast(m.nextId & 0xff),
@intFromEnum(m.filter),
@intCast(@as(i64, @intCast(m.draft.bytes.len)) & 0xff),
@intFromBool(m.draft.truncated),
@intCast(m.draft.anchor & 0xff),
@intCast(m.draft.focus & 0xff),
};
g_effects.appendSlice(gpa, &eff) catch {};
return @floatFromInt(m.tasks.len);
}
fn pushU32(list: *std.ArrayList(u8), v: u32) void {
list.append(gpa, @intCast(v & 0xff)) catch {};
list.append(gpa, @intCast((v >> 8) & 0xff)) catch {};
list.append(gpa, @intCast((v >> 16) & 0xff)) catch {};
list.append(gpa, @intCast((v >> 24) & 0xff)) catch {};
}
export fn inbox_snapshot() callconv(.c) f64 {
const m = g_model;
var out = &g_snapshot;
out.clearRetainingCapacity();
pushU32(out, @intCast(m.tasks.len));
pushU32(out, @intCast(m.nextId));
out.append(gpa, @intFromEnum(m.filter)) catch {};
pushU32(out, @intFromFloat(@round(m.chromeLeading * 256.0)));
pushU32(out, @intFromFloat(@round(m.headerHeight * 256.0)));
for (m.tasks) |task| {
pushU32(out, @intCast(task.id));
out.append(gpa, @intFromBool(task.done)) catch {};
out.append(gpa, @intCast(@as(i64, @intCast(task.title.len)) & 0xff)) catch {};
out.appendSlice(gpa, task.title) catch {};
}
const d = m.draft;
out.append(gpa, @intCast(@as(i64, @intCast(d.bytes.len)) & 0xff)) catch {};
out.appendSlice(gpa, d.bytes) catch {};
pushU32(out, @intCast(d.anchor));
pushU32(out, @intCast(d.focus));
if (d.compStart >= 0) {
out.append(gpa, 1) catch {};
pushU32(out, @intCast(d.compStart));
pushU32(out, @intCast(d.compEnd));
} else {
out.append(gpa, 0) catch {};
pushU32(out, 0);
pushU32(out, 0);
}
out.append(gpa, @intFromBool(d.truncated)) catch {};
return @floatFromInt(out.items.len);
}
export fn inbox_snapshot_byte(i: f64) callconv(.c) f64 {
const idx: usize = @intFromFloat(i);
return @floatFromInt(g_snapshot.items[idx]);
}
export fn inbox_effect_len() callconv(.c) f64 {
return @floatFromInt(g_effects.items.len);
}
export fn inbox_effect_byte(i: f64) callconv(.c) f64 {
return @floatFromInt(g_effects.items[@intFromFloat(i)]);
}
// ------------------------------------------------------------- arena stats
export fn inbox_stat_frame_last() callconv(.c) f64 {
return @floatFromInt(rt.stat_frame_last);
}
export fn inbox_stat_frame_peak() callconv(.c) f64 {
return @floatFromInt(rt.stat_frame_peak);
}
export fn inbox_stat_commit_last() callconv(.c) f64 {
return @floatFromInt(rt.stat_commit_last);
}
export fn inbox_stat_heap_used() callconv(.c) f64 {
return @floatFromInt(rt.heapUsed());
}
export fn inbox_stat_compactions() callconv(.c) f64 {
return @floatFromInt(rt.stat_compactions);
}
-20
View File
@@ -1,20 +0,0 @@
// Integration gate: transpile the fixture core, build against the rt kernel,
// replay the deterministic 1k-message run, require the oracle digest.
// Skipped when no zig toolchain is on PATH (the unit suites still run).
import test from "node:test";
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
const pkg = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const hasZig = spawnSync("zig", ["version"], { stdio: "ignore" }).status === 0;
test("run1k digest matches the hand-written oracle", { skip: !hasZig, timeout: 300_000 }, () => {
const out = execFileSync(process.execPath, [path.join(pkg, "scripts/gate.mjs"), "--skip-bench"], {
encoding: "utf8",
});
assert.match(out, /run1k digest matches oracle/);
assert.match(out, /PASS/);
});
+56 -150
View File
@@ -4,11 +4,10 @@
// every expression/operator family, every declaration form) and pins each
// production to exactly one verdict:
//
// emits SUPPORTED — transpiles clean, and the emitted Zig compiles
// (the zig half runs when a toolchain is on PATH).
// gate BANNED or deferred — stops with EXACTLY the named teaching rule
// (NS9001 marks a genuine roadmap deferral with a tailored
// message, never an accidental hole).
// emits SUPPORTED — checks clean; the external core compiler carries
// the production (compile truth lives in the SDK's ts-core e2e
// batteries over real archives).
// gate BANNED — stops with EXACTLY the named teaching rule.
// tsc rejected by the type system / strict module semantics itself —
// tsc's own diagnostic is the teacher (`with`, `export =`, JSX in
// .ts, `this` under noImplicitThis, ...).
@@ -22,15 +21,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { transpile, transpileFiles, checkOnly, ruleIds } from "./helpers.ts";
const pkg = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const hasZig = spawnSync("zig", ["version"], { stdio: "ignore" }).status === 0;
import { check, checkFiles, checkOnly, ruleIds } from "./helpers.ts";
type Row =
| { readonly verdict: "emits"; readonly src?: string; readonly files?: Record<string, string> }
@@ -1337,13 +1328,15 @@ export function f(): Pair { const out: number[] = [1]; const p: Pair = { xs: out
src: `export function f(xs: readonly number[]): readonly number[] { const w = xs.slice(); w.copyWithin(0, 1); return w; }`,
},
"shape/sort-value-position": {
verdict: "gate",
id: "NS9001",
// A former v1-emitter deferral: the external core compiler carries
// the real JS semantics (sort returns the same array).
verdict: "emits",
src: `export function f(xs: readonly number[]): readonly number[] { const w = xs.slice(); return w.sort((a, b) => a - b); }`,
},
"shape/push-value-position": {
verdict: "gate",
id: "NS9001",
// A former v1-emitter deferral: the external core compiler carries
// the real JS semantics (the push value is the new length).
verdict: "emits",
src: `export function f(n: number): number { const out: number[] = []; const len = out.push(n); return len; }`,
},
"owned/append-write": {
@@ -1382,14 +1375,16 @@ export function f(n: number): number {
src: `export function f(xs: number[]): number { xs[xs.length] = 1; return xs.length; }`,
},
"shape/append-write-compound": {
// The compound forms read the missing slot first (JS undefined).
verdict: "gate",
id: "NS9001",
// A former v1-emitter deferral: the external core compiler carries
// the real JS semantics (the compound form reads the missing slot
// first).
verdict: "emits",
src: `export function f(): readonly number[] { const out = [1]; out[out.length] += 2; return out; }`,
},
"shape/iterating-length-change": {
verdict: "gate",
id: "NS9001",
// A former v1-emitter deferral: the external core compiler carries
// the real JS iteration semantics over a shrinking array.
verdict: "emits",
src: `
export function f(): number {
const out: number[] = [1, 2];
@@ -1411,61 +1406,31 @@ test("mutation matrix: the enumeration and the table cover each other exactly",
});
test("mutation matrix: every row produces exactly its classified outcome", () => {
const mismatches: string[] = [];
for (const name of mutationSurface) {
const row = mutationMatrix[name];
assert.notEqual(row.verdict, "check");
const result = row.verdict !== "tsc" && row.files ? transpileFiles(row.files) : transpile((row as { src: string }).src);
const result = row.verdict !== "tsc" && row.files ? checkFiles(row.files) : check((row as { src: string }).src);
assert.equal(
result.typeErrors.length,
0,
`${name}: fixture must be tsc-clean\n${result.typeErrors.join("\n")}`,
);
if (row.verdict === "gate") {
assert.equal(result.ok, false, `${name}: expected ${row.id}, but it transpiled`);
if (result.ok) {
mismatches.push(`${name}: expected ${row.id}, but the check succeeded`);
continue;
}
const ids = result.diagnostics.map((d) => d.id);
assert.ok(ids.includes(row.id), `${name}: expected ${row.id}, got ${ids.join(", ") || "none"}`);
const d = result.diagnostics.find((x) => x.id === row.id)!;
assert.ok(d.message.length > d.title.length + 20, `${name}: ${row.id} message reads as a bare fallback`);
} else {
} else if (!result.ok) {
const details = result.diagnostics.map((d) => `${d.id} ${d.message}`).join("\n");
assert.equal(result.ok, true, `${name}: expected clean transpile\n${details}`);
mismatches.push(`${name}: expected a clean check\n${details}`);
}
}
});
test("mutation matrix: every SUPPORTED row's Zig compiles", { skip: !hasZig, timeout: 300_000 }, () => {
const work = fs.mkdtempSync(path.join(os.tmpdir(), "native-core-mutation-"));
try {
fs.copyFileSync(path.join(pkg, "rt", "rt.zig"), path.join(work, "rt.zig"));
const imports: string[] = [];
let i = 0;
for (const name of mutationSurface) {
const row = mutationMatrix[name];
if (row.verdict !== "emits") continue;
const result = transpile(row.src!);
assert.equal(result.ok, true, `${name}: transpile failed before the zig step`);
const file = `m_${String(i++).padStart(3, "0")}.zig`;
fs.writeFileSync(path.join(work, file), result.zig!);
imports.push(` // ${name}\n refAllDecls(@import("${file}"));`);
}
const driver = [
`const refAllDecls = @import("std").testing.refAllDecls;`,
``,
`test {`,
...imports,
`}`,
``,
].join("\n");
fs.writeFileSync(path.join(work, "driver.zig"), driver);
try {
execFileSync("zig", ["test", "driver.zig"], { cwd: work, encoding: "utf8", stdio: "pipe" });
} catch (e) {
const err = e as { stderr?: string; stdout?: string };
assert.fail(`emitted Zig failed to compile:\n${err.stderr ?? ""}${err.stdout ?? ""}`);
}
} finally {
fs.rmSync(work, { recursive: true, force: true });
}
assert.deepEqual(mismatches, [], mismatches.join("\n\n"));
});
// ---------------------------------------------------------------------------
@@ -1601,10 +1566,10 @@ export function f(s: Uint8Array): number {
src: `export function f(s: Uint8Array): number { return s.at(-1) ?? -1; }`,
},
"text-edge/repeat-negative-literal": {
// JS throws RangeError; a compile-time-knowable negative stops the
// build instead of shipping the guaranteed panic.
verdict: "gate",
id: "NS9001",
// A former v1-emitter deferral (the emitter stopped the
// guaranteed panic at build time); the external core compiler
// carries the real JS semantics — RangeError at run time.
verdict: "emits",
src: `export function f(s: Uint8Array): Uint8Array { return s.repeat(-1); }`,
},
"text-edge/repeat-fractional-literal": {
@@ -1615,16 +1580,17 @@ export function f(s: Uint8Array): number {
src: `export function f(s: Uint8Array): Uint8Array { return s.repeat(2.5); }`,
},
"text-edge/split-empty-literal-separator": {
// Per-code-point splitting would expose the UTF-16/UTF-8 seam.
verdict: "gate",
id: "NS9001",
// A former v1-emitter deferral; the external core compiler carries
// its own byte-text split semantics.
verdict: "emits",
src: `
import { asciiBytes } from "@native-sdk/core";
export function f(s: Uint8Array): number { return s.split(asciiBytes("")).length; }`,
},
"text-edge/includes-fromIndex": {
verdict: "gate",
id: "NS9001",
// A former v1-emitter deferral: the fromIndex form compiles on the
// external lane.
verdict: "emits",
src: `export function f(s: Uint8Array, b: number): boolean { return s.includes(b, 2); }`,
},
"text-out/charCodeAt": {
@@ -1700,62 +1666,32 @@ test("text matrix: the enumeration and the table cover each other exactly", () =
});
test("text matrix: every row produces exactly its classified outcome", () => {
const mismatches: string[] = [];
for (const name of textSurface) {
const row = textMatrix[name];
assert.notEqual(row.verdict, "check");
assert.notEqual(row.verdict, "tsc");
const result = transpile((row as { src: string }).src);
const result = check((row as { src: string }).src);
assert.equal(
result.typeErrors.length,
0,
`${name}: fixture must be tsc-clean\n${result.typeErrors.join("\n")}`,
);
if (row.verdict === "gate") {
assert.equal(result.ok, false, `${name}: expected ${row.id}, but it transpiled`);
if (result.ok) {
mismatches.push(`${name}: expected ${row.id}, but the check succeeded`);
continue;
}
const ids = result.diagnostics.map((d) => d.id);
assert.ok(ids.includes(row.id), `${name}: expected ${row.id}, got ${ids.join(", ") || "none"}`);
const d = result.diagnostics.find((x) => x.id === row.id)!;
assert.ok(d.message.length > d.title.length + 20, `${name}: ${row.id} message reads as a bare fallback`);
} else {
} else if (!result.ok) {
const details = result.diagnostics.map((d) => `${d.id} ${d.message}`).join("\n");
assert.equal(result.ok, true, `${name}: expected clean transpile\n${details}`);
mismatches.push(`${name}: expected a clean check\n${details}`);
}
}
});
test("text matrix: every SUPPORTED row's Zig compiles", { skip: !hasZig, timeout: 300_000 }, () => {
const work = fs.mkdtempSync(path.join(os.tmpdir(), "native-core-text-"));
try {
fs.copyFileSync(path.join(pkg, "rt", "rt.zig"), path.join(work, "rt.zig"));
const imports: string[] = [];
let i = 0;
for (const name of textSurface) {
const row = textMatrix[name];
if (row.verdict !== "emits") continue;
const result = transpile(row.src!);
assert.equal(result.ok, true, `${name}: transpile failed before the zig step`);
const file = `t_${String(i++).padStart(3, "0")}.zig`;
fs.writeFileSync(path.join(work, file), result.zig!);
imports.push(` // ${name}\n refAllDecls(@import("${file}"));`);
}
const driver = [
`const refAllDecls = @import("std").testing.refAllDecls;`,
``,
`test {`,
...imports,
`}`,
``,
].join("\n");
fs.writeFileSync(path.join(work, "driver.zig"), driver);
try {
execFileSync("zig", ["test", "driver.zig"], { cwd: work, encoding: "utf8", stdio: "pipe" });
} catch (e) {
const err = e as { stderr?: string; stdout?: string };
assert.fail(`emitted Zig failed to compile:\n${err.stderr ?? ""}${err.stdout ?? ""}`);
}
} finally {
fs.rmSync(work, { recursive: true, force: true });
}
assert.deepEqual(mismatches, [], mismatches.join("\n\n"));
});
test("grammar matrix: the enumeration and the table cover each other exactly", () => {
@@ -1769,6 +1705,7 @@ test("grammar matrix: the enumeration and the table cover each other exactly", (
});
test("grammar matrix: every production produces exactly its classified outcome", () => {
const mismatches: string[] = [];
for (const name of productions) {
const row = matrix[name];
if (row.verdict === "check") {
@@ -1776,7 +1713,7 @@ test("grammar matrix: every production produces exactly its classified outcome",
assert.ok(ids.includes(row.id), `${name}: expected ${row.id} from the checker, got ${ids.join(", ") || "none"}`);
continue;
}
const result = row.files ? transpileFiles(row.files) : transpile(row.src!);
const result = row.files ? checkFiles(row.files) : check(row.src!);
if (row.verdict === "tsc") {
assert.ok(result.typeErrors.length > 0, `${name}: expected tsc itself to reject this`);
continue;
@@ -1787,59 +1724,28 @@ test("grammar matrix: every production produces exactly its classified outcome",
`${name}: fixture must be tsc-clean\n${result.typeErrors.join("\n")}`,
);
if (row.verdict === "gate") {
assert.equal(result.ok, false, `${name}: expected ${row.id}, but it transpiled`);
if (result.ok) {
mismatches.push(`${name}: expected ${row.id}, but the check succeeded`);
continue;
}
const ids = result.diagnostics.map((d) => d.id);
assert.ok(ids.includes(row.id), `${name}: expected ${row.id}, got ${ids.join(", ") || "none"}`);
} else {
} else if (!result.ok) {
const details = result.diagnostics.map((d) => `${d.id} ${d.message}`).join("\n");
assert.equal(result.ok, true, `${name}: expected clean transpile\n${details}`);
mismatches.push(`${name}: expected a clean check\n${details}`);
}
}
assert.deepEqual(mismatches, [], mismatches.join("\n\n"));
});
test("grammar matrix: no gated diagnostic is a bare fallback — each names its construct", () => {
for (const name of productions) {
const row = matrix[name];
if (row.verdict !== "gate") continue;
const result = row.files ? transpileFiles(row.files) : transpile(row.src!);
const result = row.files ? checkFiles(row.files) : check(row.src!);
const d = result.diagnostics.find((x) => x.id === row.id)!;
// Teaching contract: rule + fix + why — the message always carries a
// site-specific lead-in longer than the bare rule title.
assert.ok(d.message.length > d.title.length + 20, `${name}: ${row.id} message reads as a bare fallback`);
}
});
test("grammar matrix: every SUPPORTED production's Zig compiles", { skip: !hasZig, timeout: 300_000 }, () => {
const work = fs.mkdtempSync(path.join(os.tmpdir(), "native-core-grammar-"));
try {
fs.copyFileSync(path.join(pkg, "rt", "rt.zig"), path.join(work, "rt.zig"));
const imports: string[] = [];
let i = 0;
for (const name of productions) {
const row = matrix[name];
if (row.verdict !== "emits") continue;
const result = row.files ? transpileFiles(row.files) : transpile(row.src!);
assert.equal(result.ok, true, `${name}: transpile failed before the zig step`);
const file = `g_${String(i++).padStart(3, "0")}.zig`;
fs.writeFileSync(path.join(work, file), result.zig!);
imports.push(` // ${name}\n refAllDecls(@import("${file}"));`);
}
const driver = [
`const refAllDecls = @import("std").testing.refAllDecls;`,
``,
`test {`,
...imports,
`}`,
``,
].join("\n");
fs.writeFileSync(path.join(work, "driver.zig"), driver);
try {
execFileSync("zig", ["test", "driver.zig"], { cwd: work, encoding: "utf8", stdio: "pipe" });
} catch (e) {
const err = e as { stderr?: string; stdout?: string };
assert.fail(`emitted Zig failed to compile:\n${err.stderr ?? ""}${err.stdout ?? ""}`);
}
} finally {
fs.rmSync(work, { recursive: true, force: true });
}
});
+10 -41
View File
@@ -6,9 +6,7 @@ import path from "node:path";
import { ts, TypedAst, createSubsetProgram } from "../src/typed_ast.ts";
import { TypeTable } from "../src/types.ts";
import { SubsetChecker, type CheckResult } from "../src/checker.ts";
import { IntInference } from "../src/infer.ts";
import { Emitter } from "../src/emitter.ts";
import { transpileFile, type TranspileOptions, type TranspileResult } from "../src/transpile.ts";
import { checkFile, type FrontendOptions, type FrontendResult } from "../src/frontend.ts";
export function withTempModule<T>(source: string, run: (entry: string) => T): T {
const tmp = path.join(os.tmpdir(), `tac-test-${process.pid}-${Math.random().toString(36).slice(2)}.ts`);
@@ -40,18 +38,19 @@ export function withTempModules<T>(
}
}
/// Full pipeline over a multi-file core (entry defaults to core.ts).
export function transpileFiles(
/// Full frontend pipeline over a multi-file core (entry defaults to
/// core.ts).
export function checkFiles(
files: Record<string, string>,
options: TranspileOptions = {},
options: FrontendOptions = {},
entry = "core.ts",
): TranspileResult {
return withTempModules(files, entry, (entryPath) => transpileFile(entryPath, options));
): FrontendResult {
return withTempModules(files, entry, (entryPath) => checkFile(entryPath, options));
}
/// Full pipeline (type errors gate first) — what the CLI does.
export function transpile(source: string, options: TranspileOptions = {}): TranspileResult {
return withTempModule(source, (entry) => transpileFile(entry, options));
/// Full frontend pipeline (type errors gate first) — what the CLI does.
export function check(source: string, options: FrontendOptions = {}): FrontendResult {
return withTempModule(source, (entry) => checkFile(entry, options));
}
/// Checker only, without the type-error gate (for rules whose fixtures
@@ -69,33 +68,3 @@ export function checkOnly(source: string): CheckResult {
export function ruleIds(result: CheckResult): string[] {
return [...new Set(result.diagnostics.map((d) => d.id))];
}
/// An Emitter over an in-memory module WITHOUT emitting — for pinning
/// internal flow judgments whose interesting inputs the subset checker's
/// own gates keep out of end-to-end fixtures (nested function
/// declarations, module-level `let`). The subset check still runs (the
/// constructor takes its result) but its diagnostics do not gate here.
export function buildEmitter(source: string): { emitter: Emitter; file: ts.SourceFile } {
return withTempModule(source, (entry) => {
const program = createSubsetProgram(entry);
const tast = new TypedAst(program);
const file = program.getSourceFile(entry)!;
const table = new TypeTable(tast, file);
const checkResult = new SubsetChecker(tast, table, file).check();
const infer = new IntInference(tast, table, [file]);
return { emitter: new Emitter(tast, table, infer, checkResult, file, path.basename(entry)), file };
});
}
/// Emit Zig for a module that must pass the checker.
export function emit(source: string): string {
const result = transpile(source);
if (!result.ok || result.zig === null) {
const details = [
...result.typeErrors,
...result.diagnostics.map((d) => `${d.id} ${d.title}: ${d.message}`),
].join("\n");
throw new Error(`transpile failed:\n${details}`);
}
return result.zig;
}
+10 -11
View File
@@ -13,11 +13,11 @@
// - the exports map resolves ".", "./text", and "./events" to the shipped
// TS sources,
// with a `types` condition, so tsc's bundler resolution types both;
// - exactly one runtime dependency — the external core compiler at the
// exact release the repository pins (tests/compiled-core/
// core_compiler_pin) — and no bin: the compiler resolves from this
// package's own node_modules for the opt-in external lane, and the
// transpiler still runs from the SDK checkout with its dev install.
// - exactly one runtime dependency — the external core compiler,
// exact-pinned (this manifest is the ONE place the pin lives) — and
// no bin: every TypeScript-core build resolves the compiler from
// this package's own node_modules, and the frontend (checker +
// contract) still runs from the SDK checkout with its dev install.
import test from "node:test";
import assert from "node:assert/strict";
@@ -74,14 +74,13 @@ test("exports resolve ., ./text, and ./events to shipped sources, types included
});
test("the one runtime dependency is the exact-pinned external core compiler", () => {
// One dependency, exactly the release the repository's compiled-core
// pin names: the opt-in external lane resolves the compiler from this
// package's own node_modules, and an exact pin is what makes the
// profile's release-pinned fence table trustworthy. No bin joins it —
// One dependency, exact-pinned: every TypeScript-core build resolves
// the compiler from this package's own node_modules, and an exact pin
// is what makes the profile's release-pinned fence table trustworthy
// (this manifest is the one place the pin lives). No bin joins it —
// installing the package must never put a toolchain on a consumer's
// PATH.
const pinPath = path.join(pkg, "..", "..", "tests", "compiled-core", "core_compiler_pin");
const pin = fs.readFileSync(pinPath, "utf8").trim();
const pin = manifest.dependencies?.scriptc;
assert.match(pin, /^\d+\.\d+\.\d+$/);
assert.deepEqual(manifest.dependencies, { scriptc: pin });
});
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -12,7 +12,7 @@ import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { transpileFile } from "../src/transpile.ts";
import { checkFile } from "../src/frontend.ts";
import { sdkLibraryModules } from "../src/typed_ast.ts";
const pkg = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
@@ -32,7 +32,7 @@ test("every shipped SDK library module is registered", () => {
test("every SDK library module transpiles standalone (tsc-clean, subset-clean, emitted)", () => {
for (const [name, file] of sdkLibraryModules) {
const result = transpileFile(file);
const result = checkFile(file);
assert.equal(result.typeErrors.length, 0, `${name}: tsc errors\n${result.typeErrors.join("\n")}`);
const details = result.diagnostics.map((d) => `${d.id} ${d.message}`).join("\n");
assert.equal(result.ok, true, `${name}: transpile failed\n${details}`);
-2
View File
@@ -20,7 +20,6 @@
"packages/core/src",
"packages/core/sdk",
"packages/core/compile-surface",
"packages/core/rt",
"packages/core/scripts",
"packages/core/package.json",
"packages/core/package-lock.json",
@@ -32,7 +31,6 @@
],
"dependencies": {
"@typescript/old": "npm:typescript@6.0.3",
"@typescript/typescript6": "6.0.2",
"scriptc": "0.0.22"
},
"optionalDependencies": {
@@ -23,7 +23,7 @@ const mirrors = [
// build compiles from the dependency.
{ source: 'tools/corewire', target: 'tools/corewire' },
// The @native-sdk/core closure TypeScript app builds resolve from the
// installed package: the core toolchain, the SDK library modules (also the
// installed package: the frontend, the SDK library modules (also the
// editor package), the external-compile staging surface and its
// driver scripts, and the manifest + lockfile that make `npm ci` work
// inside packages/core on the npm-installed layout. Entry-by-entry on
@@ -32,7 +32,6 @@ const mirrors = [
{ source: 'packages/core/src', target: 'packages/core/src' },
{ source: 'packages/core/sdk', target: 'packages/core/sdk' },
{ source: 'packages/core/compile-surface', target: 'packages/core/compile-surface' },
{ source: 'packages/core/rt', target: 'packages/core/rt' },
{ source: 'packages/core/scripts', target: 'packages/core/scripts' },
{ source: 'packages/core/package.json', target: 'packages/core/package.json' },
{ source: 'packages/core/package-lock.json', target: 'packages/core/package-lock.json' },
@@ -93,43 +93,21 @@ if (coreJson.homepage !== packageJson.homepage) {
console.error(`Homepage mismatch: packages/core/package.json homepage is ${coreJson.homepage}, expected ${packageJson.homepage} from package.json`);
errors++;
}
// The CLI carries the transpiler's TypeScript toolchain as a REGULAR
// dependency: npm installs @typescript/typescript6 in the same transaction
// as @native-sdk/cli, so the bundled packages/core resolves it through
// node's ancestor node_modules walk with no install step at verb time
// (offline, read-only-prefix, and production-config safe). Both pins must
// be the SAME EXACT version (no ranges): every transpiler test and the
// hardcoded lib/typescript.js entrypoint run against one toolchain
// version, and a range would let a fresh CLI install resolve a different
// 6.x than the one packages/core develops against toolchain drift must
// be a deliberate event (bump both manifests and the lockfile together).
const coreTsPin = coreJson.devDependencies?.['@typescript/typescript6'];
const cliTsPin = packageJson.dependencies?.['@typescript/typescript6'];
if (!coreTsPin) {
console.error('packages/core/package.json is missing the @typescript/typescript6 devDependency');
errors++;
} else if (!/^\d+\.\d+\.\d+$/.test(coreTsPin)) {
console.error(`packages/core/package.json devDependencies["@typescript/typescript6"]=${coreTsPin} is a range, not an exact version pin`);
errors++;
}
if (cliTsPin !== coreTsPin) {
console.error(`Pin mismatch: package.json dependencies["@typescript/typescript6"]=${cliTsPin}, expected ${coreTsPin} from packages/core devDependencies`);
errors++;
}
// @typescript/typescript6 is only a WRAPPER: its lib/typescript.js
// re-exports "@typescript/old", an npm ALIAS whose own dependency range is
// a CARET (npm:typescript@^6). Pinning the wrapper alone therefore pins
// nothing about the compiler that actually runs — published packages carry
// no lockfile, so a consumer install would resolve whatever typescript ^6
// the registry serves that day. Both manifests must also pin the alias
// directly (a top-level entry npm dedupes the wrapper's transitive
// dependency onto), in the exact `npm:typescript@X.Y.Z` form — a range
// after the @ reopens the same drift the wrapper pin closes.
// The CLI carries the frontend's TypeScript compiler as a REGULAR
// dependency via the @typescript/old ALIAS (npm:typescript@X.Y.Z): npm
// installs it in the same transaction as @native-sdk/cli, so the bundled
// packages/core resolves it through node's ancestor node_modules walk
// with no install step at verb time (offline, read-only-prefix, and
// production-config safe). Both manifests must pin the alias in the
// exact `npm:typescript@X.Y.Z` form — a range after the @ would let a
// fresh CLI install resolve a different version than the one
// packages/core develops against; toolchain drift must be a deliberate
// event (bump both manifests and the lockfile together).
const aliasPinShape = /^npm:typescript@\d+\.\d+\.\d+$/;
const coreAliasPin = coreJson.devDependencies?.['@typescript/old'];
const cliAliasPin = packageJson.dependencies?.['@typescript/old'];
if (!coreAliasPin) {
console.error('packages/core/package.json is missing the @typescript/old devDependency (the exact alias pin for the real compiler behind the @typescript/typescript6 wrapper)');
console.error('packages/core/package.json is missing the @typescript/old devDependency (the exact npm:typescript alias pin for the frontend compiler)');
errors++;
} else if (!aliasPinShape.test(coreAliasPin)) {
console.error(`packages/core/package.json devDependencies["@typescript/old"]=${coreAliasPin} is not an exact npm:typescript@X.Y.Z alias pin (ranges after the @ let consumer installs drift)`);
@@ -15,14 +15,14 @@
// install.
//
// packages/core/ ships too, selectively: TypeScript app cores need the
// @native-sdk/core toolchain (src/, run under node at build time), the
// @native-sdk/core frontend (src/, run under node at build time), the
// SDK library modules cores import (sdk/, also the editor package the CLI
// materializes into apps), the external-compile staging surface
// (compile-surface/ and the scripts/ drivers the build graph runs),
// package.json (the bundled version every scaffold pin follows), and
// package-lock.json (npm only strips the tarball ROOT lockfile; nested
// ones ship). The frontend's TypeScript toolchain and the external core
// compiler do NOT ride in the payload: @typescript/typescript6 and
// compiler do NOT ride in the payload: the @typescript/old alias and
// scriptc are regular dependencies of @native-sdk/cli, installed by npm
// in the same transaction and resolved from packages/core by node's
// ancestor walk. test/ stays out: repo-dev surface, never build inputs.
@@ -64,7 +64,7 @@ for (const dir of ['src', 'build', 'assets', 'skills', 'skill-data']) {
// The @native-sdk/core closure a TS app build needs (see the header note).
{
rmSync(join(projectRoot, 'packages'), { recursive: true, force: true });
for (const dir of ['src', 'sdk', 'compile-surface', 'scripts', 'rt']) {
for (const dir of ['src', 'sdk', 'compile-surface', 'scripts']) {
const source = join(repoRoot, 'packages', 'core', dir);
const target = join(projectRoot, 'packages', 'core', dir);
cpSync(source, target, { recursive: true });
+29 -29
View File
@@ -1,13 +1,13 @@
---
name: ts-core
description: Authoring guide for TypeScript app cores - Model, Msg, update, and the pure functions they call, written in the closed app-core subset and compiled ahead-of-time to arena-backed Zig by the @native-sdk/core transpiler. Use when writing or modifying a src/core.ts app core, fixing subset checker errors (NS1001-NS1060), or deciding how to express state, messages, text (bytes and the byte-text string methods), text input, continuous controls (sliders, scroll), effects (Cmd), subscriptions (Sub), the host-event wiring channels (frameMsg, keyMsg, appearanceMsg, chromeMsg, envMsgs, app.zon assets), derived values, the view_unbound lint opt-out, local mutation of owned arrays, or how to split a core into modules under src/ (relative imports, namespace imports, @native-sdk/core/text, @native-sdk/core/events).
description: Authoring guide for TypeScript app cores - Model, Msg, update, and the pure functions they call, written in the closed app-core subset, checked by the @native-sdk/core frontend, and compiled ahead-of-time to native code by the external core compiler. Use when writing or modifying a src/core.ts app core, fixing subset checker errors (NS1001-NS1060), or deciding how to express state, messages, text (bytes and the byte-text string methods), text input, continuous controls (sliders, scroll), effects (Cmd), subscriptions (Sub), the host-event wiring channels (frameMsg, keyMsg, appearanceMsg, chromeMsg, envMsgs, app.zon assets), derived values, the view_unbound lint opt-out, local mutation of owned arrays, or how to split a core into modules under src/ (relative imports, namespace imports, @native-sdk/core/text, @native-sdk/core/events).
---
# Author app cores in the TypeScript subset
An app core is the logic tier of a Native SDK app: `Model` (the app state), `Msg` (a discriminated union of everything that can happen), `update(model, msg)` (the one pure transition function), and the pure helpers they call. You write it as a TypeScript module rooted at `src/core.ts` - splitting into more modules under `src/` when it grows (see "Splitting a core into modules") - and the `@native-sdk/core` transpiler compiles the whole import graph to Zig at build time. No JS engine ships in the binary — the program either passes the subset checker and compiles to native, or you get a teaching error naming the rule, the fix, and the reason. The same file is executable TypeScript: it typechecks with stock tsc and runs unmodified under node, so you can poke behavior with plain node scripts before the native build.
An app core is the logic tier of a Native SDK app: `Model` (the app state), `Msg` (a discriminated union of everything that can happen), `update(model, msg)` (the one pure transition function), and the pure helpers they call. You write it as a TypeScript module rooted at `src/core.ts` - splitting into more modules under `src/` when it grows (see "Splitting a core into modules") - and the build checks the whole import graph with the `@native-sdk/core` frontend and compiles it to native code with the external core compiler. No JS engine ships in the binary — the program either passes the subset checker and compiles to native, or you get a teaching error naming the rule, the fix, and the reason. The same file is executable TypeScript: it typechecks with stock tsc and runs unmodified under node, so you can poke behavior with plain node scripts before the native build.
A whole TS app is three files of truth and zero Zig: `src/core.ts` (this guide; plus any modules it imports under `src/`), `src/app.native` (the markup view over the core's emitted model), and `app.zon` (windows, identity, permissions). `native init` scaffolds exactly that; the build detects `src/core.ts` in the tree (never a flag or config — a tree with both `src/core.ts` and `src/main.zig` is a teaching error) and generates the wiring outside the app. The loop:
A whole TS app is three files of truth and zero Zig: `src/core.ts` (this guide; plus any modules it imports under `src/`), `src/app.native` (the markup view over the core's model), and `app.zon` (windows, identity, permissions). `native init` scaffolds exactly that; the build detects `src/core.ts` in the tree (never a flag or config — a tree with both `src/core.ts` and `src/main.zig` is a teaching error) and generates the wiring outside the app. The loop:
```sh
native dev --core # the fastest loop: run the core under node's virtual host —
@@ -42,7 +42,7 @@ export function update(model: Model, msg: Msg): Model {
```
- `update` is pure and synchronous: next model out, plus optionally command data describing effects. When a dispatch needs an effect, declare the return type `Model | [Model, Cmd<Msg>]` and return `[nextModel, cmd]` — the runtime interprets the command after the model commits and dispatches any result back to you as a `Msg`. `initialModel` may return the same pair (`[Model, Cmd<Msg>]`) to run a boot effect once at install, and an app that needs recurring timers exports `subscriptions(model): Sub<Msg>`. See "Effects are Cmd data" below.
- Exported helper functions (`export function doneCount(model: Model): number`) compile to public Zig functions — and every exported helper taking exactly ONE Model parameter also becomes a Model declaration markup binds by the helper's own name (`{doneCount}`), so derived values need no model field. One emitted name per member: a helper that collides with a field (or another helper) is a taught NS1031.
- Exported helper functions (`export function doneCount(model: Model): number`) compile to public native functions — and every exported helper taking exactly ONE Model parameter also becomes a Model declaration markup binds by the helper's own name (`{doneCount}`), so derived values need no model field. One binding name per member: a helper that collides with a field (or another helper) is a taught NS1031.
- Update-only state (fields, helpers, or Msg kinds nothing in markup binds or dispatches — host-fired timer arms, persistence bookkeeping) is declared once: `export const viewUnbound = ["nextId", "tick"] as const;`. It emits as the `view_unbound` opt-out `native check`'s unbound-state lint reads; a name outside the model surface is a taught NS1032. Entries are the TypeScript names exactly as declared (`"nextId"`) and Msg kinds as their `kind` tags — the same names markup binds, because there are no other names.
- Names: your names are your names — fields, helpers, and locals emit into Zig with their TS spellings (`doneToday` stays `doneToday`), and markup binds them verbatim. String-literal unions emit as native enums.
@@ -52,11 +52,11 @@ Everything `update` builds lives in a per-dispatch bump arena that is freed whol
That is why the immutable style is not a performance tax: `{ ...model, tasks: model.tasks.map(...) }` copies one small struct and one pointer array, never the world.
Both regions have FIXED, build-time capacities (1 MiB each by default): the frame arena bounds one dispatch's transients, the model heap bounds the committed model per space. They are comptime knobs of the emitted core — `--frame-cap <bytes>` / `--heap-cap <bytes>` on the transpiler CLI (`frameCap`/`heapCap` in the API) — and v1 never grows them at runtime, so binaries stay allocation-free and replay stays trivially deterministic. Overflowing one is a loud runtime panic naming the knob to raise, never silent corruption.
Both regions are the compiled core's own: the frame arena bounds one dispatch's transients, the model heap holds the committed model between dispatches, and the compiler's determinism fences keep every dispatch allocation-shaped and replayable.
## What the subset means
The subset is TypeScript minus the ecosystem minus the purity violations — never minus basic syntax. Concretely: every basic statement, operator, and declaration form of the language compiles (every loop shape including `do...while`, labels with labeled `break`/`continue`, `switch` with `default`, the full assignment-operator family, `**` and the shifts, const record destructuring, namespace imports over your own modules). What does not compile falls into exactly two families, each with a named teaching rule: the ECOSYSTEM the binary cannot carry (npm packages, Node/DOM APIs, regex/JSON/Promise/generator machinery, `eval` — no JS engine ships), and the constructs that would break a core's guarantees (purity and determinism, fixed shapes, one text representation, functions as declarations not values, static types with no runtime tags). Classes and exceptions are NOT in either family: data classes and `throw`/`try`/`catch`/`finally` compile (see below) — only their guarantee-breaking tails (inheritance, unsafe finally, untagged thrown values) teach. A construct that fails with a generic error instead of a teaching rule is a transpiler bug — the grammar matrix test (`grammar_matrix.test.ts`) pins every grammar production to its verdict so no silent gap can appear.
The subset is TypeScript minus the ecosystem minus the purity violations — never minus basic syntax. Concretely: every basic statement, operator, and declaration form of the language compiles (every loop shape including `do...while`, labels with labeled `break`/`continue`, `switch` with `default`, the full assignment-operator family, `**` and the shifts, const record destructuring, namespace imports over your own modules). What does not compile falls into exactly two families, each with a named teaching rule: the ECOSYSTEM the binary cannot carry (npm packages, Node/DOM APIs, regex/JSON/Promise/generator machinery, `eval` — no JS engine ships), and the constructs that would break a core's guarantees (purity and determinism, fixed shapes, one text representation, functions as declarations not values, static types with no runtime tags). Classes and exceptions are NOT in either family: data classes and `throw`/`try`/`catch`/`finally` compile (see below) — only their guarantee-breaking tails (inheritance, unsafe finally, untagged thrown values) teach. A construct that fails with a generic error instead of a teaching rule is a checker bug — the grammar matrix test (`grammar_matrix.test.ts`) pins every grammar production to its verdict so no silent gap can appear.
The banned families at a glance (each diagnostic names the fix and the reason at the site):
@@ -73,13 +73,13 @@ Model and message shapes:
- `interface` with `readonly` fields; nested interfaces; `T | null` for optional data.
- Model field types: `number`, `boolean`, string-literal unions (`"all" | "active" | "done"` → native enum), numeric-literal unions, `Uint8Array` (bytes), a nested interface, `readonly Interface[]` (arrays of object types), primitive arrays (`readonly number[]`, `readonly boolean[]`, arrays of literal-union tags), a tag-discriminated union (`{ kind: "list" } | { kind: "detail"; note: Note }` — arms may carry records, bytes, and primitive arrays), and `T | null` over any of these. Model unions compile to native tagged unions; switching arms in `update` retires the old arm's payload automatically at commit.
- `Msg`: a discriminated union on a `readonly kind` string tag, with primitive / bytes / interface payload fields. It must be a real union — give it at least two arms, or TypeScript collapses the alias to a plain object type and the transpiler rejects it.
- `Msg`: a discriminated union on a `readonly kind` string tag, with primitive / bytes / interface payload fields. It must be a real union — give it at least two arms, or TypeScript collapses the alias to a plain object type and the checker rejects it.
Logic:
- `switch` on any union's `kind` tag — `msg.kind` (the Msg dispatch) and model-field unions (`switch (model.view.kind)`) alike — with member case labels, label stacking (`case "a": case "b": body`), `break`, and a trailing `default` covering the unnamed arms (without a `default` the switch must be exhaustive — NS1015; with every arm named the `default` is JS dead code and emits nothing) — and `switch` on a string-literal-union or numeric-literal-union *value* (`switch (model.filter)`) — an uncovered member skips the switch exactly like JS (a `default` anywhere but last is a taught stop in both forms) — and `switch` on a plain `number` or `string` value, lowered to an if/else chain with exact JS semantics: strict equality per case (NaN matches nothing, `-0` matches `0`, strings compare contents), cases tested in source order, `default` matching only after every case misses wherever it sits (an empty `default:` stacking onto the next body included); `if`/`else`; classic `for (let i = 0; ...)` including countdowns (`i--`, `i -= k`), multi-counter inits (`let lo = 0, hi = n`), and comma incrementors (`lo++, hi--` — the for-header is the one home for comma sequences); `do { ... } while (cond)` (the body runs before the first test; `continue` jumps to the test, exactly node); `for (const x of xs)` over arrays and `Uint8Array`, with `break`/`continue`, plus the indexed pair form `for (const [i, x] of xs.entries())` (exactly the `[index, element]` two-identifier binding — the index is the loop index, integer-classed; other tuple shapes stay taught); `while`; labeled statements on loops and blocks with labeled `break`/`continue` (`outer: for (...) { ... continue outer; }` — a labeled `continue` in a classic for still runs the incrementor, like JS); `let` locals with reassignment (and `let x: number;` declared-then-assigned); ternaries; `&&`/`||`/`!`; the empty statement `;`.
- `const { total, done: doneCount } = stats;` — record-field destructuring into const locals (a compile-time alias per field, renames included). Array patterns, parameter patterns, defaults, rest, and nesting are taught (NS1045 — positions can be silently absent in JS; fields cannot).
- `import * as util from "./util.ts"` — a namespace import over your own modules is pure dot-syntax: `util.helper(x)`, `util.CONST`, and `util.Cfg` in type positions all resolve to the target module's flat emitted names. The alias is not a value (storing or passing `util` itself is taught), and the intrinsic `@native-sdk/core` module is always imported by name (NS1039 — the purity rules recognize `Cmd`/`Sub`/`asciiBytes` by their imported names).
- `import * as util from "./util.ts"` — a namespace import over your own modules is pure dot-syntax: `util.helper(x)`, `util.CONST`, and `util.Cfg` in type positions all resolve to the target module's flat names. The alias is not a value (storing or passing `util` itself is taught), and the intrinsic `@native-sdk/core` module is always imported by name (NS1039 — the purity rules recognize `Cmd`/`Sub`/`asciiBytes` by their imported names).
- Object spread `{ ...model, field: v }`, array spreads in any shape — append `[...xs, x]`, prepend `[x, ...xs]`, multi-spread `[...a, x, ...b]` (each compiles to one exact-size copy) — `.length`, indexing `xs[i]`.
- Array methods, lowered to inlined loops and exact-size arena copies: `.map` / `.filter` / `.find` / `.findIndex` / `.some` / `.every` / `.reduce` / `.toSorted` / `.slice` / `.concat` / `.indexOf` / `.includes`. `.map` is type-changing — `tasks.map((t) => t.id)` produces a number array, `t => t.title` a bytes array, and a callback that can return `null` produces an optional-element array. Callbacks on map/filter/find/findIndex/some/every may take the `(element, index)` pair — the index is the loop index, integer-classed (`.reduce` stays `(acc, x)`: its index parameter is not in v1, and no callback takes the third JS parameter, the array itself — reference the array by name). Array-method calls may sit directly in `if`/`else if` and ternary conditions (`if (xs.some((x) => x > 3))`) — the scan lowers to a loop just before the branch; a `while` condition cannot (it re-evaluates per iteration — hoist into the loop body or restructure). Callbacks are arrows (expression or block body), inline `function` expressions, or a BARE REFERENCE to a module-level function or const helper (`xs.map(encodeTurn)`, `xs.toSorted(byAscending)` — the referenced body inlines exactly like the arrow spelled at the site); in a block body every code path must end in an explicit `return` (falling off the end would be JS `undefined`, which has no mapping — a taught stop). JS semantics hold exactly: `.slice` resolves negative and out-of-range indices the JS way, `.indexOf` never matches `NaN` while `.includes` does, `.some`/`.every` keep their vacuous defaults on empty arrays, and `.reduce` needs its initial value (the no-initial form throws on an empty array in JS, so it is a taught NS1007 — pass the starting accumulator). `.indexOf`/`.includes` work on scalar elements (numbers, tags, booleans); on record arrays JS compares references, which has no native mapping — match a field with `.find`/`.findIndex` instead.
- **Local mutation — your own scratch is yours; shared data is immutable.** An array your function CREATES — an array literal (`const stack: number[] = []`, `const st = [1, 2, 3]`) or a fresh copy (`.slice()` / `.map()` / `.filter()` / `.concat()` / `.toSorted()`) — is locally owned, and the full mutating method set works on it with exact JS semantics: `push(...items)`, `pop()`, `shift()`, `unshift(...items)`, `splice(start, deleteCount?, ...items)` (negative/overshooting indices clamp the JS way; the value is the removed array, also yours), `reverse()`, `fill(v, start?, end?)`, in-place `sort(cmp)`, and indexed writes `xs[i] = v`. A parser stack, a work queue, a copy-then-sort — all legal, deterministic, and byte-identical to node. Ownership ends at the first ESCAPE: once the array is returned from a callback, stored into a record/array/model, aliased by a second binding (`const b = a`), or passed where the callee could keep or mutate it, mutating it afterwards is a taught NS1051 — finish mutating first, then let it escape (an early-exit `return` is fine: execution ends there, so mutations on the other path stay legal). Two loosenings keep real code flowing. BORROWING: passing an owned array into a `readonly T[]` parameter is NOT an escape when the callee only READS it (element/property access, iteration, spreads, further borrowing passes — no return of it, no store, no onward pass into a mutable position; recursion over borrowed slices included), so measure-mutate-measure loops work (`total(out); out.push(x); total(out)`). REASSIGNED-OWNING: a `let` binding whose EVERY assignment installs a fresh owning construction (a literal or a copy — `w = xs.filter(...)`, `acc = []`) stays owned through the reassignments; ONE mixed assignment (an alias, a parameter, a helper result) and the binding never owns (NS1001 names it). Never owned: parameters, model/msg data, module `const` tables, aliases, mixed reassigned bindings, and arrays produced by helper calls (copy with `.slice()` to own one). After the value escapes it is an ordinary immutable value; the commit walkers and sharing discipline are unaffected because ownership ended before the escape.
@@ -154,7 +154,7 @@ Three effect families deliver MANY results from one command — a keyed stream t
- `Cmd.audioPlay(key, { path?, url?, cachePath?, expectedBytes? }, { event })` — open the audio event stream. One player is the whole surface, so a new `audioPlay` always REPLACES the current playback (the one key-reuse exception besides `Cmd.request`). The source cascade is the engine's: the local `path` is tried first, a missing file falls through to `url` (streamed progressively, cached at `cachePath` when given, integrity-gated by `expectedBytes` — omitted/0 means unknown size). At least one of `path`/`url` is required (NS1029); each is bytes, at most 1 KiB (NS1030). Prefer OMITTING `cachePath` for URL sources: when the app wiring configures a caches directory (`TsUiApp`'s `audio_cache_dir`), the host derives the conventional content-addressed cache path from the URL itself — your update never builds filesystem paths, and replay re-derives the same path by construction. Pass `cachePath` only to override that convention.
- The `event` arm is the one SDK-fixed record shape, six fields matched by NAME: `state` (the `AudioState` string-literal union — import it from `@native-sdk/core/events`, or declare an alias with exactly the members `"loaded" | "position" | "completed" | "failed" | "rejected" | "spectrum"` in any order; the runtime matches members by name), `positionMs: number`, `durationMs: number` (milliseconds; the duration is the player's estimate), `playing: boolean`, `buffering: boolean` (true while a streamed url is stalled waiting for bytes), and `bands: Uint8Array` (the 32 spectrum band magnitudes, 0255 each, all zeros outside `"spectrum"` events). Every playback event dispatches this arm — `"failed"` (unplayable source, decode/device failure) and `"rejected"` (an empty or over-long source) included, so failure is never silence — until `Cmd.audioStop` closes the stream. `"completed"` fires once at the natural end and does NOT close the stream: starting the next track from it is the idiom.
- `Cmd.audioPause(key)` / `Cmd.audioResume(key)` / `Cmd.audioStop(key)` / `Cmd.audioSeek(key, ms)` / `Cmd.audioSetVolume(key, volume)` — fire-and-forget control verbs: no result of their own; their consequences arrive on the event stream (`audioResume` on a dead player reports one `"failed"` event, never silence). A verb whose key names no open stream is a no-op. `audioStop` is the audio stream's close — no events for the key after it (`Cmd.cancel` does not apply to audio). Volume is clamped 0..1 and remembered across tracks; a literal outside 0..1 (or a negative seek literal) stops the build (NS1030).
- `Cmd.channelOpen(key, { event })` — open an EXTERNAL-SOURCE channel under the app's numeric key: the host stages a long-lived, thread-safe posting seam its NATIVE side feeds — embedders and platform-services extensions post bytes from their own threads (sockets, watchers, workers), and each accepted post dispatches the `event` arm as one `"data"` event. Posting is deliberately not a TS verb — transpiled cores are single-threaded, so the TS tier opens, closes, and receives while the posting handle lives on the native side (`Effects.channelHandle(key)`). `key` may be any number expression, a positive integer below 2^53 (a certain-to-be-refused literal stops the build, NS1030). The `event` arm is a five-field record matched by NAME: `key` (the channel key echoed verbatim, so concurrent channels sharing one arm stay distinguishable; a key the wire cannot carry exactly echoes 0), `state` (the `ChannelState` union — import it from `@native-sdk/core` or declare an alias with exactly the three members `"data" | "closed" | "rejected"` in any order; checked BOTH directions, since a narrower union would silently drop states the host emits), `bytes` (`Uint8Array` — the post's payload on `"data"` events, empty otherwise), and `droppedPending`/`droppedTotal` (numbers — the honest back-pressure counters: posts the native handle refused since the previous delivered event, and over the channel's whole life; refused posts count, never silence). One channel per key at a time — a duplicate live key dispatches `"rejected"` — and the key shares the engine's effect-key space (a same-key fetch is blocked while the channel lives). No timer polling anywhere: the source wakes the loop itself. Channel events journal at the effect boundary, so recorded sessions replay the whole stream from the journal — the native posting side is never needed at replay (a native producer that consults `ChannelHandle.live()` before launching keeps replay fully offline; one that launches unconditionally is stopped at its first post, which answers `.closed`).
- `Cmd.channelOpen(key, { event })` — open an EXTERNAL-SOURCE channel under the app's numeric key: the host stages a long-lived, thread-safe posting seam its NATIVE side feeds — embedders and platform-services extensions post bytes from their own threads (sockets, watchers, workers), and each accepted post dispatches the `event` arm as one `"data"` event. Posting is deliberately not a TS verb — compiled cores are single-threaded, so the TS tier opens, closes, and receives while the posting handle lives on the native side (`Effects.channelHandle(key)`). `key` may be any number expression, a positive integer below 2^53 (a certain-to-be-refused literal stops the build, NS1030). The `event` arm is a five-field record matched by NAME: `key` (the channel key echoed verbatim, so concurrent channels sharing one arm stay distinguishable; a key the wire cannot carry exactly echoes 0), `state` (the `ChannelState` union — import it from `@native-sdk/core` or declare an alias with exactly the three members `"data" | "closed" | "rejected"` in any order; checked BOTH directions, since a narrower union would silently drop states the host emits), `bytes` (`Uint8Array` — the post's payload on `"data"` events, empty otherwise), and `droppedPending`/`droppedTotal` (numbers — the honest back-pressure counters: posts the native handle refused since the previous delivered event, and over the channel's whole life; refused posts count, never silence). One channel per key at a time — a duplicate live key dispatches `"rejected"` — and the key shares the engine's effect-key space (a same-key fetch is blocked while the channel lives). No timer polling anywhere: the source wakes the loop itself. Channel events journal at the effect boundary, so recorded sessions replay the whole stream from the journal — the native posting side is never needed at replay (a native producer that consults `ChannelHandle.live()` before launching keeps replay fully offline; one that launches unconditionally is stopped at its first post, which answers `.closed`).
- `Cmd.channelClose(key)` — close the open channel under the key, if any: staged posts flush, exactly one `"closed"` event (final drop totals aboard) dispatches the event arm, and the key frees. A key with no open channel no-ops.
- `Cmd.imageLoad(id, { path?, url?, cachePath?, expectedBytes? }, { event })` — load an image at runtime under the model-owned NUMERIC ImageId your markup binds (`<image image="{cover}"/>`, `<avatar image="{avatar}"/>`); `id` may be any number expression (ids are model data), a positive integer below 2^53 (a certain-to-be-refused literal like 0 stops the build, NS1030). The source cascade is `audioPlay`'s exactly: local `path` first, a missing file falls through to `url` (fetched whole, installed at the cache path and integrity-gated by `expectedBytes`); at least one of `path`/`url` (NS1029), and prefer OMITTING `cachePath` — the wiring's caches directory (`TsUiApp`'s `image_cache_dir`) derives the content-addressed path from the URL. Exactly ONE `event` arm dispatches per load — a five-field record matched by NAME: `id` (the requested ImageId echoed verbatim, so concurrent loads sharing one arm stay distinguishable; an id the wire cannot carry exactly echoes 0), `state` (the `ImageState` union — exactly the fifteen members `"loaded" | "rejected" | "not_found" | "io_failed" | "connect_failed" | "tls_failed" | "protocol_failed" | "timed_out" | "http_status" | "cancelled" | "too_large" | "unsupported" | "decode_failed" | "registry_full" | "alloc_failed"`, any order; `"alloc_failed"` is resource exhaustion at registration — the host refused the memory, the bytes may be fine, retry when memory frees), `width`/`height` (the decoded dimensions on `"loaded"`, 0 otherwise), and `status` (the HTTP status for url loads that performed an exchange; 0 when none occurred — local paths, cache hits — so a cached `"loaded"` is distinguishable from a network one). On `"loaded"` the pixels are already registered under the id — store the id in the model then (the store-on-success discipline keeps a fallback rendering until the load lands). One load per id at a time: a duplicate live id dispatches `"rejected"` (the spawn discipline — a load in flight is never replaced implicitly), and image loads are not the string-keyed `Cmd.cancel`'s to end — `Cmd.imageCancel(id)` is their cancel, LOUD like spawn's: the one terminal still arrives as the event arm's `"cancelled"`, and the id frees for a fresh load once it lands (an id with no live load no-ops; the same NS1030 literal gate as `imageLoad`). Decode limits are the registered-image limits (16 slots, 1 MiB decoded pixels — avatar/cover scale, not photo scale); the encoded source bound is 1.25 MiB, and over-bound sources fail whole with `"too_large"`, never cut. `Cmd.imageUnregister(id)` releases a loaded image's registry slot — the gallery eviction move when the 17th distinct image would answer `"registry_full"`: views bound to the id fall back, and the slot accepts the next load. Unregister is synchronous registry surgery, NOT an effect — no result Msg, an unregistered id no-ops (the same NS1030 literal gate) — and it frees only the CURRENT registration: a load in flight under the id still registers at its terminal, so cancel the load first (`Cmd.imageCancel`) to keep the slot free.
@@ -206,18 +206,18 @@ Sub values follow the Cmd purity rule with their own home (NS1025): built inline
Keep the Sub-vs-stream line straight: a Sub is DECLARATIVE — derived from the model, started and stopped by reconciliation, and the app never opens or closes one. The multi-result streams (`Cmd.spawn`'s lines, `Cmd.audioPlay`'s events) are Cmd-INITIATED — imperative opens with a keyed lifecycle the app drives (`Cmd.cancel` for spawn, `Cmd.audioStop` for audio). If the effect should exist exactly while some model state holds, it wants a Sub shape; if the app decides when it starts and ends, it is a stream.
One caveat for node-side pokes: the transpiler resolves the `@native-sdk/core*` specifiers for you, but plain `node` does not know them, so quick behavioral checks under node work directly on cores with no SDK import, and on cores importing `Cmd`, `Sub`, `asciiBytes`, or the text engine only with a module mapping (or by copying the SDK module files next to the core and rewriting the specifiers). `native dev --core` already maps them.
One caveat for node-side pokes: the build resolves the `@native-sdk/core*` specifiers for you, but plain `node` does not know them, so quick behavioral checks under node work directly on cores with no SDK import, and on cores importing `Cmd`, `Sub`, `asciiBytes`, or the text engine only with a module mapping (or by copying the SDK module files next to the core and rewriting the specifiers). `native dev --core` already maps them.
## Splitting a core into modules
`src/core.ts` is the ENTRY module; a core that outgrows it splits into more `.ts` files under `src/` (subdirectories included). The whole import graph still emits as ONE native module - one rt kernel, one flat namespace, a section per source file - and runs unchanged under node.
`src/core.ts` is the ENTRY module; a core that outgrows it splits into more `.ts` files under `src/` (subdirectories included). The whole import graph still compiles as ONE native module - one flat namespace - and runs unchanged under node.
- **Spell relative imports with the real filename**: `import { parsePs } from "./parsers.ts"` (node's loader resolves real files, not bare stems - a missing extension or a missing file is a taught NS1037).
- **`src/` is the boundary**: `../` escapes and absolute paths are taught (NS1034); bare npm specifiers are taught (NS1035 - vendor the code under `src/` or make the import `import type`). Only `@native-sdk/core` (the intrinsic Cmd/Sub/asciiBytes surface) and the SDK library modules below carry runtime meaning from outside.
- **Everything module-level is importable**: interfaces, literal-union aliases, discriminated unions, module `const` numbers and tables, and helper functions all cross files (renamed imports and `import * as ns` namespace aliases both work — the alias is dot-syntax over the same flat namespace, never a value of its own). Export lists and value re-exports work too: `export { helper, doneCount as remaining }` binds names over existing declarations, and `export { parsePs } from "./parsers.ts"` forwards another module's export by name (a renamed binding emits as a flat-namespace alias). Type names and EXPORTED value names must be unique across the core's files (NS1038 - declare once, import where used; renamed exports claim their new names in the same namespace); colliding PRIVATE helpers are fine (the emitter uniques them with a per-module prefix).
- **Everything module-level is importable**: interfaces, literal-union aliases, discriminated unions, module `const` numbers and tables, and helper functions all cross files (renamed imports and `import * as ns` namespace aliases both work — the alias is dot-syntax over the same flat namespace, never a value of its own). Export lists and value re-exports work too: `export { helper, doneCount as remaining }` binds names over existing declarations, and `export { parsePs } from "./parsers.ts"` forwards another module's export by name (a renamed binding emits as a flat-namespace alias). Type names and EXPORTED value names must be unique across the core's files (NS1038 - declare once, import where used; renamed exports claim their new names in the same namespace); colliding PRIVATE helpers are fine (the compile uniques them per module).
- **No runtime import cycles** (NS1036). `import type` back-edges are legal and idiomatic: a helper module type-imports `Model` from `./core.ts` while `core.ts` runtime-imports the helpers - that is the expected shape, not a smell.
- **The entry contract (NS1014)**: `update`, `initialModel`, `subscriptions`, the wiring channels (`commandMsg`/`keyMsg`/`frameMsg`/`appearanceMsg`/`chromeMsg`/`envMsgs`), and `viewUnbound` are DECLARED in `core.ts` and exported under their own names (`export` on the declaration or an un-renamed `export { update }` list entry — a rename or a re-export from an imported module cannot bind an entry point) - imports may FEED them, never replace them. The markup binding surface is also entry-only: an exported single-Model-parameter helper binds (`{doneCount}`) only when it is DECLARED in `core.ts` — export lists participate under their exported names (`export { taskTotal as taskCount }` binds `{taskCount}`), but a re-export of an imported helper does not bind (under node the app's module object is the entry's exports, so it would bind natively but not exist under node). Imported modules export cross-module API for update and the entry helpers to call.
- **SDK library modules**: `@native-sdk/core/text` ships the byte-splice text engine - `applyTextInputEvent(state, event, capacity)` / `clampedInsertEvent` over `TextEditState` (the full caret/word/selection/IME reducer for markup text controls), plus `containsIgnoreCase`, `orderIgnoreCase`, and `trimAsciiSpaces`. `@native-sdk/core/events` ships the canonical event record types (`TextInputEvent` re-exported, `ScrollState`, `FrameEvent`, `KeyEvent`, `ColorScheme`/`AppearanceEvent`, `ChromeInsets`/`ChromeButtons`/`ChromeEvent`, `AudioState`/`AudioEvent`) so no core re-types the vocabulary. Unlike `@native-sdk/core` (intrinsic, never emitted) these are ordinary subset TypeScript, transpiled INTO your core when imported and absent when not. Under node they resolve like the core module itself. One namespace rule to know (NS1038): module-scope names are unique across the whole import graph, so a core that imports an SDK event type deletes its own in-file mirror of that name.
- **SDK library modules**: `@native-sdk/core/text` ships the byte-splice text engine - `applyTextInputEvent(state, event, capacity)` / `clampedInsertEvent` over `TextEditState` (the full caret/word/selection/IME reducer for markup text controls), plus `containsIgnoreCase`, `orderIgnoreCase`, and `trimAsciiSpaces`. `@native-sdk/core/events` ships the canonical event record types (`TextInputEvent` re-exported, `ScrollState`, `FrameEvent`, `KeyEvent`, `ColorScheme`/`AppearanceEvent`, `ChromeInsets`/`ChromeButtons`/`ChromeEvent`, `AudioState`/`AudioEvent`) so no core re-types the vocabulary. Unlike `@native-sdk/core` (intrinsic, never compiled into the core) these are ordinary subset TypeScript, compiled INTO your core when imported and absent when not. Under node they resolve like the core module itself. One namespace rule to know (NS1038): module-scope names are unique across the whole import graph, so a core that imports an SDK event type deletes its own in-file mirror of that name.
The reference splits are `examples/soundboard-ts` (core.ts + library.ts + player.ts + the SDK text engine), `examples/system-monitor-ts` (core.ts + parsers.ts + table.ts + the SDK text engine), and `examples/ai-chat-ts` (core.ts + api.ts — the JSON-over-bytes wire-format reference: request encoding and a targeted parse walk that returns `null` on anything malformed) in the SDK repo.
@@ -225,7 +225,7 @@ The reference splits are `examples/soundboard-ts` (core.ts + library.ts + player
`string` in a core is for literals, string-literal-union tags, and `===` comparisons — content equality, on tags and plain `string` values alike (`name === "app.add"` in a command mapper works and behaves identically under node and native). Dynamic, user-visible text lives in the Model as `Uint8Array` — indexing yields byte values, `.length` is byte length, `subarray` is a view and `slice` is a copy, and both resolve their bounds the JS way (negatives count from the end, out-of-range clamps, a crossed range is empty), identical under node and native. Observing a `string`'s code units (`.length`, `s[i]`, `.charCodeAt`) is banned (NS1004) because UTF-16 and UTF-8 would disagree, and `+` concatenation is banned (NS1018) because runtime string building needs a JS string heap the binary does not carry — build text with template literals into bytes instead.
Turn literals and templates into bytes with the `asciiBytes` intrinsic from the SDK. The transpiler recognizes the import by identity and folds every call at compile time — a literal argument becomes rodata, a template becomes per-dispatch arena bytes — and under node the same import runs as a plain function with the same result:
Turn literals and templates into bytes with the `asciiBytes` intrinsic from the SDK. The compiler recognizes the import by identity and folds every call at compile time — a literal argument becomes rodata, a template becomes per-dispatch arena bytes — and under node the same import runs as a plain function with the same result:
```ts
import { asciiBytes } from "@native-sdk/core";
@@ -239,7 +239,7 @@ Arguments must be string literals or templates (the fold happens at compile time
### The byte-text string methods
Bytes read like text: the everyday string methods work directly on `Uint8Array` values, with **byte-honest semantics** — every length, offset, and index is a BYTE length/offset (never a character count: `é` measures 2 and `padStart` pads by bytes), search is byte-wise, and case mapping is Unicode SIMPLE case mapping (code point → code point from the Unicode tables; locale-free, no special casing — `ß` stays `ß`, `σ` uppercases to `Σ`; bytes that are not well-formed UTF-8 pass through case mapping unchanged). Natively each call lowers onto an rt kernel helper; under node the devhost installs the same methods from the same generated tables, so both runtimes produce identical bytes by construction.
Bytes read like text: the everyday string methods work directly on `Uint8Array` values, with **byte-honest semantics** — every length, offset, and index is a BYTE length/offset (never a character count: `é` measures 2 and `padStart` pads by bytes), search is byte-wise, and case mapping is Unicode SIMPLE case mapping (code point → code point from the Unicode tables; locale-free, no special casing — `ß` stays `ß`, `σ` uppercases to `Σ`; bytes that are not well-formed UTF-8 pass through case mapping unchanged). Natively each call lowers onto the compiled core's runtime; under node the devhost installs the same methods from the same Unicode tables, so both runtimes produce identical bytes.
```ts
const query = model.query.trim().toLowerCase(); // JS whitespace set; simple case map
@@ -353,15 +353,15 @@ Every diagnostic carries one of these IDs plus the fix and the why. Write to the
- **NS1027 effect results route to Msg arms by name.** Routing (`{ key?, ok, err }`) and timer targets are string-literal arm names with the payload shape the effect produces — one `Uint8Array` field for host results/errors, one number field for timer and delay fires, no fields for `writeFile`'s ok, one number plus one `Uint8Array` field for `fetch`'s ok. Callbacks and computed names cannot work: the runtime builds the result Msg from the arm's declared shape at build time.
- **NS1028 Cmd.persist is not yet host-backed (warning).** The op compiles and stays on the wire, but no shipping host performs it — persist with `Cmd.writeFile` and boot-load with `Cmd.readFile` instead. The only non-fatal notice in the set.
- **NS1029 effect op arguments have a fixed shape.** Paths/URLs/bodies are bytes, `Cmd.fetch`'s spec is an inline object with a closed verb literal, a number-literal timeout, and an inline flat record of headers whose NAMES are compile-time ASCII and whose VALUES are string literals or runtime bytes (`Uint8Array`). The record's shape encodes at build time; a runtime header value rides its length-prefixed wire field at dispatch time exactly like `url`/`body` — but a smuggled string (a ternary of literals, a template) has no encoding: make it bytes.
- **NS1031 exported model helpers join the model's binding surface.** An exported single-Model-parameter helper emits as a Model declaration markup binds (`doneCount``{doneCount}`); two members with one emitted name would be ambiguous — rename one.
- **NS1032 viewUnbound names update-only model state.** `export const viewUnbound = [...] as const` entries must be string literals naming Model fields, exported model helpers, or Msg kinds — by their TypeScript spellings (`"nextId"`, not the emitted `"next_id"`); anything else would silence nothing and hide a typo.
- **NS1031 exported model helpers join the model's binding surface.** An exported single-Model-parameter helper becomes a Model declaration markup binds (`doneCount``{doneCount}`); two members with one binding name would be ambiguous — rename one.
- **NS1032 viewUnbound names update-only model state.** `export const viewUnbound = [...] as const` entries must be string literals naming Model fields, exported model helpers, or Msg kinds — by their TypeScript spellings (`"nextId"`); anything else would silence nothing and hide a typo.
- **NS1030 effect arguments respect the engine's limits.** A compile-time-knowable value outside an engine bound (a path literal over 1024 bytes, a URL literal over 2 KiB, more than 8 headers, a header block over 1 KiB, a delay literal outside 1ms..one year) stops the build instead of shipping a guaranteed runtime rejection. Dynamic values stay the engine's to validate — they surface through the `err` arm.
- **NS1033 wiring channel exports match their host event shapes.** `frameMsg`/`keyMsg` take their exact event records and return `Msg | null`; `appearanceMsg`/`chromeMsg` are string literals naming arms with those channels' record shapes; `envMsgs` entries carry `env` and a one-`Uint8Array`-field `msg` arm. The generated wiring builds these host events structurally from your declarations, so a wrong shape is taught here instead of surfacing as a Zig error inside generated code.
- **NS1034 core imports stay inside src/.** `../` escapes and absolute paths are rejected: the entry module's directory is the core's whole world - the build ships exactly that tree.
- **NS1035 npm packages do not run inside a core.** No JS engine ships in the binary; vendor the logic under `src/` or make the import type-only.
- **NS1036 core modules do not import in a cycle.** Runtime cycles only work through JS live-binding indirection; hoist shared declarations, or make the back-edge `import type` (which is exempt and idiomatic).
- **NS1037 an import names a real module file.** Spell relative specifiers with the `.ts` extension and point them at existing files; `@native-sdk/...` specifiers must name a shipped SDK module.
- **NS1038 module-scope names are unique across a core's files.** Type names and exported value names share the emitted module's one namespace - declare the shared thing once and import it (private helper collisions are auto-prefixed instead). Same-file homonyms count too: a type and an exported value cannot share a name, and interfaces never merge.
- **NS1038 module-scope names are unique across a core's files.** Type names and exported value names share the compiled module's one namespace - declare the shared thing once and import it (private helper collisions are auto-prefixed instead). Same-file homonyms count too: a type and an exported value cannot share a name, and interfaces never merge.
- **NS1039 a namespace import is a compile-time alias.** `import * as ns from "./util.ts"` works as dot-syntax (`ns.helper(x)`, `ns.Cfg`); `ns` itself is not a value (never stored or passed), and the intrinsic `@native-sdk/core` module is imported by name so the purity rules can see `Cmd`/`Sub`/`asciiBytes`.
- **NS1040 no regular expressions.** A regex is a runtime engine the binary does not carry; scan bytes with loops or the SDK text helpers (`containsIgnoreCase`, `trimAsciiSpaces`).
- **NS1041 types are static: no runtime type or shape tests.** `typeof` values, `in`, `instanceof`, and the `Object`/`Reflect`/`JSON`/`Array` statics read runtime tags fixed native layouts do not have; model alternatives as a discriminated union and switch on its `kind`.
@@ -370,15 +370,15 @@ Every diagnostic carries one of these IDs plus the fix and the why. Write to the
- **NS1044 no BigInt or Symbol.** A core's numbers are IEEE f64 slots (integer-classed slots emit i64); model identities as number ids.
- **NS1045 destructuring binds record fields into const locals.** `const { total, done: doneCount } = stats;` is a compile-time alias; array positions, parameter patterns, defaults, rest, and nesting can be silently absent in JS and are taught toward explicit reads.
- **NS1046 functions live at module level.** Nested function declarations, non-const function values, and `?.()` treat functions as runtime values closing over the frame; move the function to module scope, bind it as a const local helper, or pass what it used (inline arrows as call arguments stay).
- **NS1047 modules export their declarations by name.** Named exports all compile: `export` on the declaration, export lists (`export { a, b as c }` — a rename binds a new flat-namespace name), and named value re-exports (`export { x } from "./m.ts"`). What stays out is the unnamed tail: `export default`, `export =`, `export * from`, plus bindings with no single emitted value (renamed generics/classes, wiring config, re-exports of the SDK surface).
- **NS1047 modules export their declarations by name.** Named exports all compile: `export` on the declaration, export lists (`export { a, b as c }` — a rename binds a new flat-namespace name), and named value re-exports (`export { x } from "./m.ts"`). What stays out is the unnamed tail: `export default`, `export =`, `export * from`, plus bindings with no single compiled value (renamed generics/classes, wiring config, re-exports of the SDK surface).
- **NS1048 equality is strict.** `==`/`!=` apply JS's coercion table; use `===`/`!==`.
- **NS1049 locals declare with const and let.** `var` hoists to function scope and reads `undefined` before its line — behavior the emitted locals cannot have.
- **NS1049 locals declare with const and let.** `var` hoists to function scope and reads `undefined` before its line — behavior the compiled locals cannot have.
- **NS1050 generics live on module-level declarations.** Module-level generic functions/interfaces/aliases monomorphize per concrete use; entry points and function values stay concrete.
- **NS1051 a local array is yours until it escapes.** Mutating an owned array AFTER it was returned from a callback, stored into a structure, aliased, or passed where the callee could keep or mutate it is taught with the escape named (kind and line): JS would show the holder your later mutations through the shared reference, while the native value was shared structurally at the escape. Finish mutating first, pass the array after the last mutation, or mutate inside the callee — a pass into a `readonly T[]` reader parameter is a borrow, not an escape. An escape inside a loop gates the whole loop body (the second iteration would mutate after the first iteration's escape).
- **NS1052 spread array locals declare their array type.** `const turns = [...model.turns, next];` has no slice target to lower against — annotate the local: `const turns: readonly Turn[] = [...model.turns, next];`.
- **NS1053 generics instantiate per concrete call site.** A generic call whose resolved type argument has no concrete emitted type (`never` from `pick([])`, `any`/`unknown`, an unnamed literal union) is taught — annotate the call site or name the alias.
- **NS1053 generics instantiate per concrete call site.** A generic call whose resolved type argument has no concrete compiled type (`never` from `pick([])`, `any`/`unknown`, an unnamed literal union) is taught — annotate the call site or name the alias.
- **NS1054 function values stay local helpers.** A const-bound, capture-free, fully-annotated function value hoists to a module-level fn; captures, missing annotations, reassignment, storing/returning the value, and record-field calls are taught toward the helper shape.
- **NS1055 classes hold data, not hierarchies.** No `extends`/`super`/`abstract`: compose (a field holding the other record or class), or model the variants as a `kind`-discriminated union and switch on it — emitted classes are flat structs with static dispatch.
- **NS1055 classes hold data, not hierarchies.** No `extends`/`super`/`abstract`: compose (a field holding the other record or class), or model the variants as a `kind`-discriminated union and switch on it — compiled classes are flat structs with static dispatch.
- **NS1056 class members are annotated fields, one constructor, and plain methods.** `static` methods, `static readonly` consts, and erased `private`/`protected` compile; getters/setters, `#`-privates, `accessor`, unannotated or optional (`?`) instance fields, `this` escaping as a value (or used inside a static member — statics go by the class name), and class instances stored in the Model tree are each taught toward the data-class shape.
- **NS1057 thrown values are kind-tagged subset shapes.** Several distinct shapes may throw — the checker collects them into the core's thrown union, and `catch (e)` narrows it with kind tests (`if (e.kind === "parse")`), no `as` needed (single-shape cores may still narrow once with `const err = e as YourError;`; bare rethrow always works). What teaches: untagged thrown values in a heterogeneous set, two shapes sharing a `kind` with different payloads, asserting one member of a multi-shape core, the binding escaping untyped, and `throw new Error(...)`.
- **NS1058 finally never redirects control flow.** `return`/`throw`/`break`-out/`continue`-out inside `finally` would override the pending return or exception (JS's no-unsafe-finally); keep `finally` to cleanup statements.
@@ -463,10 +463,10 @@ export function update(model: Model, msg: Msg): Model {
A `native init` app needs NONE of this section: the build detects `src/core.ts` and stages the wiring itself (a core exporting `commandMsg(name: string): Msg | null` automatically receives menu/shortcut command events as Msgs). The section below is for hand-Zig wiring — embedding a core in an existing Zig app or customizing the UiApp surface.
The emitted core runs as a full desktop app through `native_sdk.TsUiApp(core)` — the committed TS model IS the app model, no shim, no glue:
The compiled core runs as a full desktop app through `native_sdk.TsUiApp(core)` — the committed TS model IS the app model, no shim, no glue:
```zig
const core = @import("core.zig"); // the emitted module (+ its rt.zig)
const core = @import("core.zig"); // the generated mirror over the compiled core
const Adapter = native_sdk.TsUiApp(core);
const App = Adapter.App; // a native_sdk.UiApp(core.Model, core.Msg)
@@ -482,9 +482,9 @@ var app = Adapter.init(allocator, .{ .audio_cache_dir = resolved_cache_dir }, .{
```
- `update`/`update_fx`/`init_fx` belong to the adapter (it runs your `initialModel`, `update`, and `subscriptions` through the effect bridge), and the adapter wires `on_command`/`on_key`/`on_appearance`/`on_chrome`/`on_frame` from the core's channel exports automatically (a wiring that also sets one of those seams is a teaching panic). Everything else is ordinary wiring: `tokens_fn`/`windows_fn` derive from `*const core.Model`, and `CoreOptions` carries the adapter-owned knobs (`audio_cache_dir`, `boot_images`, `env_values`).
- Markup binds your model's field names EXACTLY as you wrote them: `nextId` binds as `{nextId}` (the emitted Zig keeps the TS spellings). Record arrays iterate with `<for each="tasks" as="t" key="id">` and items bind their fields (`{t.title}`); optional scalars gate with `<if test="{selected}">` (null is falsy); string-literal unions bind as their member name (`{filter}` renders `all` — compare against a quoted literal, `selected="{sortKey == 'cpu'}"`); exported single-model helpers bind as derived values (`{doneCount}`) and slice-returning ones drive `for each`. Markup `<chart>` series bind number arrays too — `<series kind="bar" values="{cpuSpark}" />` over a field or helper returning `readonly number[]` (emitted f64, narrowed per sample into the chart pipeline); pad a filling window's leading gap with `NaN` samples, which draw nothing.
- Markup binds your model's field names EXACTLY as you wrote them: `nextId` binds as `{nextId}` (the core's model keeps the TS spellings). Record arrays iterate with `<for each="tasks" as="t" key="id">` and items bind their fields (`{t.title}`); optional scalars gate with `<if test="{selected}">` (null is falsy); string-literal unions bind as their member name (`{filter}` renders `all` — compare against a quoted literal, `selected="{sortKey == 'cpu'}"`); exported single-model helpers bind as derived values (`{doneCount}`) and slice-returning ones drive `for each`. Markup `<chart>` series bind number arrays too — `<series kind="bar" values="{cpuSpark}" />` over a field or helper returning `readonly number[]` (f64, narrowed per sample into the chart pipeline); pad a filling window's leading gap with `NaN` samples, which draw nothing.
- `Options.sync` does not exist for TS apps (a committed model cannot be mutated in place): keep continuous controls model-driven — bind the widget's value and echo `on-change`/`on-scroll` Msgs back into the model.
- One live app per core module per process: two apps over the SAME emitted core would share one committed root. Different cores coexist (each emitted core stages its own `rt.zig`).
- One live app per core module per process: two apps over the SAME core would share one committed root — and a process carries ONE compiled core (the archive owns a fixed-prefix C ABI symbol set).
- Record/replay, the automation verbs, and screenshot fingerprints work unchanged — the adapter rides the standard UiApp dispatch path.
## Checking your work
@@ -493,11 +493,11 @@ Scaffolded apps carry an editor surface (`package.json`, `tsconfig.json`, and a
**Refresh the model contract after shape changes.** `native check`'s typed markup pass reads `zig-out/model-contract.zon`, an artifact of the LAST build/test — after changing Model fields or Msg kinds, run `native test` (or `zig build model-contract` in an app that owns its build.zig) before trusting `native check`. A stale contract reports phantom hard errors (`unknown message tag`, `binding does not name a model field`) that name your NEW state — the state is fine; the artifact is old. And never delete `src/app.native` in response to a note about nothing embedding it: on the TypeScript track the generated wiring embeds the view from outside the app tree, so that file is always wired.
The transpiler is the checker: run it after every meaningful edit and read the diagnostics — they always name the rule, the idiomatic rewrite, and the reason. Inside an app, `native check` runs it for you (plus markup and app.zon validation); in a core-only workspace, invoke it directly:
The frontend is the checker: run it after every meaningful edit and read the diagnostics — they always name the rule, the idiomatic rewrite, and the reason. Inside an app, `native check` runs it for you (plus markup and app.zon validation); in a core-only workspace, invoke it directly:
```sh
native check # inside an app
node <sdk-repo>/packages/core/src/cli.ts src/core.ts -o /tmp/core.zig
node <sdk-repo>/packages/core/src/cli.ts src/core.ts
```
Exit 0 means the module typechecked (real tsc semantics), passed every subset rule, and emitted Zig. Your workspace README shows the exact command paths for your project, plus how to build the emitted core against the runtime kernel. Because the subset is erasable TypeScript, `node` can import your core directly for quick behavioral checks (`node --input-type=module -e "..."` or a small `node --test` file) — the native build has the same semantics. If the core imports `@native-sdk/core` (for `Cmd` or `asciiBytes`), map that one specifier for node first: copy the SDK module file next to the core and rewrite the import, or run through a loader that resolves it.
Exit 0 means the module typechecked (real tsc semantics) and passed every subset rule — the exact pass every build runs before the external core compiler takes the graph. Because the subset is erasable TypeScript, `node` can import your core directly for quick behavioral checks (`node --input-type=module -e "..."` or a small `node --test` file) — the native build has the same semantics. If the core imports `@native-sdk/core` (for `Cmd` or `asciiBytes`), map that one specifier for node first: copy the SDK module file next to the core and rewrite the import, or run through a loader that resolves it.
+38 -38
View File
@@ -1,17 +1,18 @@
//! The native host consumer for transpiled app cores: bridges the
//! versioned command/subscription wire format a transpiled core emits
//! (`packages/core/rt/rt.zig`, `cmd_format_version` 3) onto the
//! real effect engine (`effects.zig`). The transpiler's output is a
//! pure Model/Msg/update core whose effects are INERT BYTES — this
//! module is the one place those bytes become engine calls, so the
//! entire existing effects machinery (executors, keyed slots, the
//! completion queue, the session journal, replay) carries transpiled
//! cores without a parallel engine.
//! The native host consumer for compiled TypeScript app cores: bridges
//! the versioned command/subscription wire format a compiled core
//! emits (`cmd_format_version` 3) onto the real effect engine
//! (`effects.zig`). The TypeScript tier's core module is a pure
//! Model/Msg/update core whose effects are INERT BYTES — this module is
//! the one place those bytes become engine calls, so the entire
//! existing effects machinery (executors, keyed slots, the completion
//! queue, the session journal, replay) carries TypeScript cores without
//! a parallel engine.
//!
//! `TsCoreHost(core)` is comptime-generic over the emitted core module
//! and expects the emitted ABI:
//! `TsCoreHost(core)` is comptime-generic over the core module (the
//! corewire-generated mirror over a compiled archive, or any
//! hand-written module of the same shape) and expects:
//!
//! core.rt the core's rt kernel: `frameAlloc`,
//! core.rt the core's runtime: `frameAlloc`,
//! `frameReset`, `resetAll`
//! core.Model the committed model struct
//! core.Msg the app Msg `union(enum)` (wire tags are the
@@ -22,14 +23,13 @@
//! core.commitModelRoot the frame-end commit walker
//! core.subscriptions optional: `fn (*const Model) []const u8`
//!
//! Like the rt kernel it drives, a host instance is container-level
//! state — the v1 contract is ONE LIVE APP PER CORE MODULE: two apps
//! over the same emitted core in one process would share a committed
//! root and one set of bridge tables. Two DIFFERENT core modules
//! coexist fine (each staged core carries its own rt.zig module
//! instance, so kernels never alias; distinct core types get distinct
//! hosts) — the e2e suite drives two live cores side by side to pin
//! exactly that.
//! Like the runtime it drives, a host instance is container-level
//! state — the contract is ONE LIVE APP PER CORE MODULE: two apps
//! over the same core in one process would share a committed root and
//! one set of bridge tables. A compiled archive additionally owns the
//! process's fixed-prefix C ABI symbol set, so a process carries ONE
//! compiled core (each e2e battery is its own binary for exactly that
//! reason).
//!
//! THE DISPATCH CYCLE — every Msg runs update → commit → command walk →
//! subscription reconcile → frame reset, in that order, because the
@@ -182,7 +182,7 @@
//! are matched by member name, exactly the
//! data/closed/rejected set) — until the one `closed`
//! (or a refused open's `rejected`) terminal retires
//! it. POSTING is not a TS-tier verb: transpiled cores
//! it. POSTING is not a TS-tier verb: TypeScript cores
//! are single-threaded, so the thread-safe posting
//! handle is native-side API (`Effects.channelHandle`)
//! for embedders and platform-services extensions —
@@ -349,7 +349,7 @@
//! frame-resident and copy whatever the model keeps into the heap.
//!
//! Malformed wire bytes are teaching panics, not error codes: the only
//! producer is the transpiler's own rt builders, so a bad record is a
//! producer is the compiled core's own runtime builders, so a bad record is a
//! build-pipeline bug the app author must see immediately.
const std = @import("std");
@@ -410,8 +410,8 @@ pub fn videoKeyForTag(event_tag: u8) u64 {
/// families' one engine key space without ever colliding on a key.
pub const pty_key_base: u64 = 0x5453_5054_0000_0000;
/// The spawn wire record's "no line routing" tag sentinel (mirrors
/// rt.zig's `spawn_no_line_tag`).
/// The spawn wire record's "no line routing" tag sentinel (the wire
/// format's shared constant).
pub const spawn_no_line_tag: u8 = 0xFF;
/// Longest wire key (request or timer) the format can carry: the key
@@ -713,7 +713,7 @@ pub fn TsCoreHost(comptime core: type) type {
/// boot model. The command bytes are re-derived by re-running
/// the PURE `initialModel` (they were frame-resident and did not
/// survive `boot`'s frame reset; the duplicate model value is
/// frame-transient garbage) — purity is the transpiled subset's
/// frame-transient garbage) — purity is the app-core subset's
/// own guarantee, so the bytes are identical by construction.
pub fn performBoot(fx: *Fx) void {
if (comptime init_returns_cmd) {
@@ -789,7 +789,7 @@ pub fn TsCoreHost(comptime core: type) type {
/// One full dispatch cycle for `msg`. The `TsUiApp` adapter
/// wires this to `UiApp.Options.update_fx` (refreshing the
/// app-held root from `model()` afterwards) so host events and
/// drained effect results run the transpiled core through the
/// drained effect results run the TypeScript core through the
/// same path Zig cores use. A Msg flagged by its own result
/// callback as a dropped entry's terminal is swallowed here —
/// the silent drop the keyed-effect discipline promises.
@@ -937,7 +937,7 @@ pub fn TsCoreHost(comptime core: type) type {
const url = takeLongBytes(cmd, &at);
const header_count: usize = takeByte(cmd, &at);
if (header_count > runtime_effects.max_effect_fetch_headers) {
@panic("ts core host: a fetch wire record carries more headers than the engine accepts - the transpiler's own bound should have stopped this build");
@panic("ts core host: a fetch wire record carries more headers than the engine accepts - the frontend's own bound should have stopped this build");
}
var headers: [runtime_effects.max_effect_fetch_headers]std.http.Header = undefined;
for (0..header_count) |i| {
@@ -1006,7 +1006,7 @@ pub fn TsCoreHost(comptime core: type) type {
}
const argc: usize = takeByte(cmd, &at);
if (argc == 0 or argc > runtime_effects.max_effect_argv) {
@panic("ts core host: a spawn wire record carries more argv elements than the engine accepts - the transpiler's own bound should have stopped this build");
@panic("ts core host: a spawn wire record carries more argv elements than the engine accepts - the frontend's own bound should have stopped this build");
}
var argv: [runtime_effects.max_effect_argv][]const u8 = undefined;
for (0..argc) |i| argv[i] = takeLongBytes(cmd, &at);
@@ -1185,7 +1185,7 @@ pub fn TsCoreHost(comptime core: type) type {
const term = takeShortBytes(cmd, &at);
const argc: usize = takeByte(cmd, &at);
if (argc == 0 or argc > runtime_effects.max_effect_argv) {
@panic("ts core host: a pty_spawn wire record carries more argv elements than the engine accepts - the transpiler's own bound should have stopped this build");
@panic("ts core host: a pty_spawn wire record carries more argv elements than the engine accepts - the frontend's own bound should have stopped this build");
}
var argv: [runtime_effects.max_effect_argv][]const u8 = undefined;
for (0..argc) |i| argv[i] = takeLongBytes(cmd, &at);
@@ -2110,7 +2110,7 @@ pub fn TsCoreHost(comptime core: type) type {
// ------------------------------------------ subscription timers
/// Reconcile the declarative subscription set against the fixed
/// timer table — the same algorithm as the transpiler package's
/// timer table — the same algorithm as the @native-sdk/core package's
/// run-fidelity drivers, engine-backed: match by key, arm new
/// keys into the first free slot, re-arm on interval change,
/// re-route on tag change, cancel the missing. Slot order
@@ -2270,7 +2270,7 @@ pub fn TsCoreHost(comptime core: type) type {
/// (fetch's `{ status, body }` and a collect spawn's
/// `{ code, output }`): the arm must be a struct of exactly one
/// number field and one bytes field, matched BY TYPE (the
/// transpiler validates the shape, so field names stay the
/// frontend validates the shape, so field names stay the
/// app's). The bytes copy into the core's frame arena like
/// every routed payload; the number widens into its field the
/// way the subset's number model classes it (i64, u64, or f64).
@@ -2351,14 +2351,14 @@ pub fn TsCoreHost(comptime core: type) type {
}
/// The arm's `state` member for an engine event kind, matched
/// by member NAME (the transpiler pins the member set, so the
/// by member NAME (the frontend pins the member set, so the
/// app's declaration order never matters to the wire).
fn audioStateValue(comptime E: type, kind: runtime_effects.EffectAudioEventKind) E {
const name = @tagName(kind);
inline for (@typeInfo(E).@"enum".fields) |f| {
if (std.mem.eql(u8, f.name, name)) return @enumFromInt(f.value);
}
@panic("ts core host: an audio event kind has no member in the event arm's state union - the transpiler's own shape check should have stopped this build");
@panic("ts core host: an audio event kind has no member in the event arm's state union - the frontend's own shape check should have stopped this build");
}
/// Build the six-field audio event arm at index `tag` from an
@@ -2431,7 +2431,7 @@ pub fn TsCoreHost(comptime core: type) type {
inline for (@typeInfo(E).@"enum".fields) |f| {
if (std.mem.eql(u8, f.name, name)) return @enumFromInt(f.value);
}
@panic("ts core host: a video event kind has no member in the event arm's state union - the transpiler's own shape check should have stopped this build");
@panic("ts core host: a video event kind has no member in the event arm's state union - the frontend's own shape check should have stopped this build");
}
/// Build the seven-field video event arm at index `tag` from an
@@ -2496,7 +2496,7 @@ pub fn TsCoreHost(comptime core: type) type {
inline for (@typeInfo(E).@"enum".fields) |f| {
if (std.mem.eql(u8, f.name, name)) return @enumFromInt(f.value);
}
@panic("ts core host: an image outcome has no member in the result arm's state union - the transpiler's own shape check should have stopped this build");
@panic("ts core host: an image outcome has no member in the result arm's state union - the frontend's own shape check should have stopped this build");
}
/// Build the five-field image result arm at index `tag` from an
@@ -2561,7 +2561,7 @@ pub fn TsCoreHost(comptime core: type) type {
inline for (@typeInfo(E).@"enum".fields) |f| {
if (std.mem.eql(u8, f.name, name)) return @enumFromInt(f.value);
}
@panic("ts core host: a channel event kind has no member in the event arm's state union - the transpiler's own shape check should have stopped this build");
@panic("ts core host: a channel event kind has no member in the event arm's state union - the frontend's own shape check should have stopped this build");
}
/// Build the five-field channel event arm at index `tag` from
@@ -2646,7 +2646,7 @@ pub fn TsCoreHost(comptime core: type) type {
inline for (@typeInfo(E).@"enum".fields) |f| {
if (std.mem.eql(u8, f.name, name)) return @enumFromInt(f.value);
}
@panic("ts core host: a pty event kind has no member in the event arm's state union - the transpiler's own shape check should have stopped this build");
@panic("ts core host: a pty event kind has no member in the event arm's state union - the frontend's own shape check should have stopped this build");
}
/// The arm's `reason` member for an engine exit reason, matched
@@ -2656,7 +2656,7 @@ pub fn TsCoreHost(comptime core: type) type {
inline for (@typeInfo(E).@"enum".fields) |f| {
if (std.mem.eql(u8, f.name, name)) return @enumFromInt(f.value);
}
@panic("ts core host: a pty exit reason has no member in the event arm's reason union - the transpiler's own shape check should have stopped this build");
@panic("ts core host: a pty exit reason has no member in the event arm's reason union - the frontend's own shape check should have stopped this build");
}
/// Build the six-field pty event arm at index `tag` from an
+12 -12
View File
@@ -1,6 +1,6 @@
//! `TsUiApp(core)` — the first-class UiApp adapter for transpiled app
//! cores: the committed TS model IS the app model. Where a Zig core
//! hands `UiApp` a mutable model plus `update`, a transpiled core is an
//! `TsUiApp(core)` — the first-class UiApp adapter for compiled
//! TypeScript app cores: the committed TS model IS the app model. Where a Zig core
//! hands `UiApp` a mutable model plus `update`, a compiled TypeScript core is an
//! immutable committed graph plus a pure `update` returning the next
//! root — this adapter closes that gap with no per-app glue:
//!
@@ -32,7 +32,7 @@
//! `chromeMsg` -> `on_appearance`/`on_chrome`, each host event built
//! structurally by field name from the core's declared records (the
//! effects-routing rule applied to the app shell; every shape mismatch
//! is a teaching compile error re-deriving the transpiler's NS1033).
//! is a teaching compile error re-deriving the frontend's NS1033).
//! `CoreOptions` carries the launch-boundary channels the wiring
//! resolves: `boot_images` (app.zon assets, registered on the
//! installing frame) and `env_values` (the core's `envMsgs` variables,
@@ -52,10 +52,10 @@
//! Record/replay, automation, and pixel fingerprints need nothing
//! extra: the adapter rides the ordinary UiApp dispatch path, so the
//! session journal, the automation verbs, and the screenshot marks see
//! a transpiled core exactly as they see a Zig one. The v1 process
//! contract is the bridge's: one live app per core module (two apps
//! over one emitted core would share a committed root; distinct core
//! modules coexist).
//! a compiled TypeScript core exactly as they see a Zig one. The
//! process contract is the bridge's: one live app per core module (two
//! apps over one core would share a committed root), and one compiled
//! archive per process (the fixed-prefix C ABI symbol set).
const std = @import("std");
const canvas = @import("canvas");
@@ -100,7 +100,7 @@ pub fn TsUiApp(comptime core: type) type {
};
/// Adapter-owned configuration — the knobs that exist because
/// the core is transpiled, kept separate from `App.Options` so
/// the core is TypeScript, kept separate from `App.Options` so
/// the wiring surface reads as ordinary UiApp wiring.
pub const CoreOptions = struct {
/// Platform caches directory for URL audio playback: when a
@@ -172,7 +172,7 @@ pub fn TsUiApp(comptime core: type) type {
fn stampOptions(options: Options) Options {
if (options.update != null or options.update_fx != null) {
@panic("TsUiApp owns update: the transpiled core is the update loop - remove the wiring's update/update_fx");
@panic("TsUiApp owns update: the TypeScript core is the update loop - remove the wiring's update/update_fx");
}
if (options.init_fx != null) {
@panic("TsUiApp owns init_fx: the core's initialModel boots the app - remove the wiring's init_fx");
@@ -282,7 +282,7 @@ pub fn TsUiApp(comptime core: type) type {
}
}
/// Teaching re-derivation of the transpiler's NS1033 for
/// Teaching re-derivation of the frontend's NS1033 for
/// hand-assembled cores: every `envMsgs` entry must name a Msg
/// arm carrying exactly one bytes payload.
fn validateEnvMsgs() void {
@@ -472,7 +472,7 @@ pub fn TsUiApp(comptime core: type) type {
}
/// The Msg arm index a channel export names, with the teaching
/// error the transpiler's NS1033 re-derives for hand-written
/// error the frontend's NS1033 re-derives for hand-written
/// cores.
fn channelArmIndex(comptime tag: []const u8, comptime channel: []const u8) usize {
for (@typeInfo(Msg).@"union".fields, 0..) |arm, index| {
+12 -10
View File
@@ -28,10 +28,10 @@ pub const Metadata = struct {
/// inferred from the manifest's web declarations), "include", or
/// "exclude". See `webLayer` for the inference.
webview_layer: []const u8 = "auto",
/// How a TypeScript core compiles: "transpiler" (default) or
/// "external" (the opt-in external core compiler lane). The build
/// graph reads this; `-Dcore-compiler` overrides per invocation.
core_compiler: []const u8 = "transpiler",
/// How a TypeScript core compiles: "external" (the default and only
/// lane — the external core compiler). The removed transpiled
/// lane's spelling is refused with a teaching at validation.
core_compiler: []const u8 = "external",
/// The built-in theme pack the app selects (`theme = "geist"`).
/// Optional — absent keeps the house register. Validated against
/// the known pack names so a typo is a check error, never a silent
@@ -435,8 +435,11 @@ pub fn validateFile(allocator: std.mem.Allocator, io: std.Io, path: []const u8)
defer allocator.free(url_schemes);
const manifest_web_engine = parseWebEngine(metadata.web_engine) catch return .{ .ok = false, .message = "app.zon web engine is invalid" };
const manifest_webview_layer = parseWebViewLayer(metadata.webview_layer) catch return .{ .ok = false, .message = "app.zon webview_layer is invalid - expected \"auto\", \"include\", or \"exclude\"" };
if (!std.mem.eql(u8, metadata.core_compiler, "transpiler") and !std.mem.eql(u8, metadata.core_compiler, "external")) {
return .{ .ok = false, .message = "app.zon core_compiler is invalid - expected \"transpiler\" or \"external\"" };
if (!std.mem.eql(u8, metadata.core_compiler, "external")) {
if (std.mem.eql(u8, metadata.core_compiler, "transpiler")) {
return .{ .ok = false, .message = "app.zon core_compiler = \"transpiler\" names the removed TS-to-Zig transpiled lane (v0.7.0 removed it) - TypeScript cores compile through the external core compiler now; delete the setting (or spell it \"external\")" };
}
return .{ .ok = false, .message = "app.zon core_compiler is invalid - expected \"external\" (the default and only lane)" };
}
const platform_settings = parsePlatformSettings(allocator, metadata.platforms) catch return .{ .ok = false, .message = "app.zon platforms are invalid" };
defer allocator.free(platform_settings);
@@ -1955,7 +1958,7 @@ test "manifest parser reads window close policies" {
try std.testing.expectEqual(app_manifest.WindowClosePolicy.hide, shell.windows[0].close_policy);
}
test "manifest parser reads the core-compiler opt-in and keeps its default" {
test "manifest parser reads the core-compiler setting and defaults it to external" {
const metadata = try parseText(std.testing.allocator,
\\.{
\\ .id = "com.example.app",
@@ -1967,13 +1970,12 @@ test "manifest parser reads the core-compiler opt-in and keeps its default" {
defer metadata.deinit(std.testing.allocator);
try std.testing.expectEqualStrings("external", metadata.core_compiler);
// Undeclared stays the transpiler lane — behavior unchanged for
// every existing app.
// Undeclared is the external lane — the one lane there is.
const defaulted = try parseText(std.testing.allocator,
\\.{ .id = "com.example.app", .name = "example", .version = "1.2.3" }
);
defer defaulted.deinit(std.testing.allocator);
try std.testing.expectEqualStrings("transpiler", defaulted.core_compiler);
try std.testing.expectEqualStrings("external", defaulted.core_compiler);
}
test "manifest parser rejects unknown window close policy" {
+4 -4
View File
@@ -13,10 +13,10 @@ pub const RawManifest = struct {
bridge: RawBridge = .{},
web_engine: []const u8 = @tagName(web_engine.default_engine),
webview_layer: []const u8 = "auto",
/// How a TypeScript core compiles: "transpiler" (default — the
/// emitted-Zig lane) or "external" (the opt-in external core
/// compiler lane; `-Dcore-compiler` overrides per invocation).
core_compiler: []const u8 = "transpiler",
/// How a TypeScript core compiles: "external" (the default and only
/// lane — the external core compiler). The removed transpiled
/// lane's spelling is refused with a teaching at validation.
core_compiler: []const u8 = "external",
theme: ?[]const u8 = null,
theme_accent: ?[]const u8 = null,
cef: RawCef = .{},
+25 -20
View File
@@ -184,8 +184,9 @@ fn slimGitignore() []const u8 {
/// The TypeScript-core zero-config scaffold - the `native init` default:
/// core.ts (logic), app.native (view), app.zon (manifest). ZERO Zig in the
/// tree; the build graph detects src/core.ts, transpiles it, and stages the
/// generated wiring outside the app on every build.
/// tree; the build graph detects src/core.ts, compiles it through the
/// external core compiler, and stages the generated wiring outside the app
/// on every build.
///
/// The tree also carries the EDITOR surface: package.json + tsconfig.json,
/// so stock editor TypeScript resolves `@native-sdk/core` with full
@@ -260,7 +261,7 @@ fn writeTsEditorSurface(allocator: std.mem.Allocator, io: std.Io, app_dir: std.I
/// The app's package.json: name + the pinned `@native-sdk/core` dependency,
/// nothing else. It exists for editors and versioning only — the `native`
/// verbs never read it (tree detection keys on src/core.ts; the build
/// transpiles against the SDK checkout) — and the pin is exact so the
/// checks and compiles against the SDK checkout) — and the pin is exact so the
/// post-publish `npm install` resolves the same content the CLI
/// materialized.
fn tsPackageJson(allocator: std.mem.Allocator, names: TemplateNames, sdk_version: []const u8) ![]const u8 {
@@ -339,7 +340,7 @@ fn tsGitignore() []const u8 {
fn tsCoreStarter() []const u8 {
return
\\// The app core: Model, Msg, update, and the pure helpers they call -
\\// plain TypeScript in the app-core subset, compiled to native Zig at
\\// plain TypeScript in the app-core subset, compiled to native code at
\\// build time (no JS runtime ships in the binary). The view lives in
\\// app.native and binds this model by its own field names exactly as
\\// written here (`tickCount` binds as `{tickCount}`).
@@ -384,9 +385,12 @@ fn tsCoreStarter() []const u8 {
\\export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
\\ switch (msg.kind) {
\\ case "increment":
\\ return { ...model, count: model.count + 1 };
\\ // Bounded on purpose: integer model fields carry a compile-time
\\ // range proof, and the literal comparison is what makes `+ 1`
\\ // provable.
\\ return { ...model, count: model.count < 1000000 ? model.count + 1 : model.count };
\\ case "decrement":
\\ return { ...model, count: model.count - 1 };
\\ return { ...model, count: model.count > -1000000 ? model.count - 1 : model.count };
\\ case "reset":
\\ return { ...model, count: 0, tickCount: 0 };
\\ case "toggle_ticking":
@@ -398,7 +402,7 @@ fn tsCoreStarter() []const u8 {
\\ case "stamped":
\\ return { ...model, stampedMs: msg.at };
\\ case "tick":
\\ return { ...model, tickCount: model.tickCount + 1 };
\\ return { ...model, tickCount: model.tickCount < 1000000 ? model.tickCount + 1 : model.tickCount };
\\ }
\\}
\\
@@ -499,8 +503,9 @@ fn tsSlimReadme(allocator: std.mem.Allocator, names: TemplateNames) ![]const u8
\\
\\## Requirements
\\
\\Node.js 22.15+ (on the 23 line: 23.5+) on PATH (the TypeScript-to-native
\\transpiler runs at build time; your shipped binary carries none of it).
\\Node.js 22.15+ (on the 23 line: 23.5+) on PATH (the TypeScript frontend
\\and the core compiler run at build time; your shipped binary carries
\\none of it).
\\
);
return out.toOwnedSlice(allocator);
@@ -1150,9 +1155,9 @@ fn nativeReadme(allocator: std.mem.Allocator, names: TemplateNames, framework_pa
/// real binary and asserts on the accessibility snapshot. The generated
/// file belongs to the user, like everything init writes. A TypeScript
/// core adds the node tier to both jobs: setup-node plus one `npm ci` in
/// the fetched SDK's packages/core, because the @native-sdk/core
/// transpiler runs under node at build time and needs its own installed
/// dependency there — the same install `native build`'s teaching names.
/// the fetched SDK's packages/core, because the @native-sdk/core frontend
/// and the external core compiler run at build time and arrive with that
/// one install — the same install `native build`'s teaching names.
fn nativeCiYaml(allocator: std.mem.Allocator, names: TemplateNames, framework_path: []const u8, core: CoreTemplate) ![]const u8 {
const node_setup =
\\ - uses: actions/setup-node@v4
@@ -1160,11 +1165,11 @@ fn nativeCiYaml(allocator: std.mem.Allocator, names: TemplateNames, framework_pa
\\ node-version: 22
\\
;
const transpiler_install =
\\ - name: Install the core transpiler dependency
const compiler_install =
\\ - name: Install the core compiler dependency
\\ # src/core.ts compiles to native code at build time: the
\\ # @native-sdk/core transpiler runs under node from the SDK
\\ # dependency and needs its dependency installed there once.
\\ # @native-sdk/core frontend and the external core compiler
\\ # run from the SDK dependency and arrive with one install.
\\ run: npm ci --prefix "$NATIVE_SDK_PATH/packages/core"
\\
;
@@ -1213,7 +1218,7 @@ fn nativeCiYaml(allocator: std.mem.Allocator, names: TemplateNames, framework_pa
\\ fi
\\
);
if (core == .ts) try out.appendSlice(allocator, transpiler_install);
if (core == .ts) try out.appendSlice(allocator, compiler_install);
try out.appendSlice(allocator,
\\ - run: zig build test -Dplatform=null
\\
@@ -1242,7 +1247,7 @@ fn nativeCiYaml(allocator: std.mem.Allocator, names: TemplateNames, framework_pa
\\ fi
\\
);
if (core == .ts) try out.appendSlice(allocator, transpiler_install);
if (core == .ts) try out.appendSlice(allocator, compiler_install);
try out.appendSlice(allocator,
\\ - name: Build the Native SDK CLI
\\ run: cd "$NATIVE_SDK_PATH" && zig build
@@ -4173,8 +4178,8 @@ test "writeDefaultApp --full ts-core emits a CI workflow with the node tier" {
try std.testing.expect(std.mem.indexOf(u8, ci_yaml_text, "xvfb-run -a ./zig-out/bin/my-app &") != null);
try std.testing.expect(std.mem.indexOf(u8, ci_yaml_text, "git clone --depth 1 https://github.com/vercel-labs/native.git \"$NATIVE_SDK_PATH\"") != null);
// Plus the node tier the TS build needs: node on PATH and the
// transpiler's own install inside the fetched SDK's packages/core,
// in BOTH jobs (each builds the app, so each transpiles the core).
// frontend + compiler install inside the fetched SDK's packages/core,
// in BOTH jobs (each builds the app, so each compiles the core).
try std.testing.expect(std.mem.indexOf(u8, ci_yaml_text, "actions/setup-node@v4") != null);
const npm_ci = "npm ci --prefix \"$NATIVE_SDK_PATH/packages/core\"";
const first = std.mem.indexOf(u8, ci_yaml_text, npm_ci).?;
+26 -34
View File
@@ -1,5 +1,5 @@
//! TypeScript-core plumbing for the `native` CLI: tree detection (which
//! core does this app carry?), the transpiler-checker pass `native check`
//! core does this app carry?), the frontend check pass `native check`
//! runs over src/core.ts, and the node dev-harness `native dev --core`
//! launches. The build graph re-derives the same detection in
//! build/app.zig; the CLI checks first so a both-cores tree fails with one
@@ -8,7 +8,7 @@
//! Multi-file cores: src/core.ts stays the detection root AND the entry
//! module, but a core may split into modules under src/ (relative imports
//! with real .ts filenames) plus SDK library modules
//! ("@native-sdk/core/text"). The transpiler walks that import graph
//! ("@native-sdk/core/text"). The frontend walks that import graph
//! itself, so `native check` reports diagnostics with each module's own
//! path, and `native dev --core` runs the same graph under node (relative
//! imports are real files; the resolver hook maps only the SDK names).
@@ -68,7 +68,7 @@ pub fn failBothCores() Error {
fn nodeMissing() Error {
std.debug.print(
\\TypeScript app cores need node on PATH (the @native-sdk/core transpiler and the
\\TypeScript app cores need node on PATH (the @native-sdk/core frontend and the
\\core dev-harness run under it; the binary you ship carries no JS runtime).
\\Install Node.js 22.15+ (on the 23 line: 23.5+) - https://nodejs.org or
\\`brew install node` - and re-run.
@@ -87,7 +87,7 @@ fn transpilerPath(allocator: std.mem.Allocator, io: std.Io, framework_root: []co
return path;
}
/// The layout-neutral runner for the transpiler tier's .ts modules
/// The layout-neutral runner for the frontend tier's .ts modules
/// (build/ts_run.mjs): a pass-through on a repo checkout, and the type
/// stripper for the npm-installed layout, where the same modules sit
/// inside node_modules and node refuses its builtin stripping. Every
@@ -103,7 +103,7 @@ fn tsRunnerPath(allocator: std.mem.Allocator, io: std.Io, framework_root: []cons
}
/// The install command the repo-checkout teaching names. `--include=dev`
/// is correctness, not style: @typescript/typescript6 is packages/core's
/// is correctness, not style: @typescript/old is packages/core's
/// devDependency, and a plain `npm ci` under ambient production npm config
/// (NODE_ENV=production, `omit=dev` in an npmrc) skips devDependencies
/// while exiting 0 — the named command would "succeed" and install
@@ -131,19 +131,13 @@ pub const npm_ci_teaching_command = "npm ci --include=dev";
/// (nested under the CLI on global prefixes, hoisted to the project
/// root on local ones, pnpm's sibling node_modules)
///
/// The @typescript/typescript6 wrapper is deliberately NOT probed:
/// nothing imports it at run time (typed_ast.ts bypasses its one-line
/// re-export on purpose — see the comment there), so holding the
/// wrapper's resolution — or the alias's version as seen FROM the
/// wrapper's origin — against the pin can only FALSE-REJECT healthy
/// trees. npm's own conflict shape hoists a consumer's conflicting
/// `@typescript/old` at the project root (where it wins the walk from a
/// hoisted wrapper) while our exact pin lands nested under the CLI — and
/// that nested copy is precisely what runtime loads from packages/core;
/// a consumer's own shadowing wrapper install must not sway the verdict
/// either. The wrapper stays a DECLARED dependency in both manifests
/// (continuity semantics, and it keeps npm shipping the package) — it is
/// just not what validation vouches for.
/// A stray `@typescript/typescript6` compat wrapper in a consumer tree
/// (a former dependency of this package, or the consumer's own) is
/// deliberately NOT probed: nothing imports it at run time, and holding
/// the alias's version as seen FROM a wrapper's origin against the pin
/// can only FALSE-REJECT healthy trees — a consumer's hoisted conflicting
/// `@typescript/old` wins the walk from there while the copy runtime
/// actually loads sits correctly pinned under packages/core.
///
/// Resolvable means the alias's manifest AND its entrypoint are present
/// and its installed version equals the pin — see
@@ -313,7 +307,7 @@ fn pinnedCompilerVersion(allocator: std.mem.Allocator, io: std.Io, framework_roo
/// `npm ci` there would teach mutating an npm-owned tree.
fn transpilerDepsMissing(framework_root: []const u8) Error {
std.debug.print(
\\the @native-sdk/core transpiler's dependencies are not installed
\\the @native-sdk/core frontend's dependencies are not installed
\\(its TypeScript compiler, @typescript/old, resolves nowhere). Fix with:
\\ cd {s}/packages/core && {s}
\\
@@ -351,7 +345,7 @@ fn toolchainInstallBroken(framework_root: []const u8) Error {
/// conflict instead.
fn compilerVersionMismatch(resolved: []const u8, pinned: []const u8) Error {
std.debug.print(
\\the transpiler's TypeScript compiler resolves at the wrong version:
\\the frontend's TypeScript compiler resolves at the wrong version:
\\@typescript/old resolves to typescript {s}, but the SDK pins npm:typescript@{s}.
\\Another package in this tree pins a conflicting @typescript/old - align it with
\\the SDK's pin (or remove it) and reinstall, so the SDK's exact pin is the copy
@@ -409,23 +403,21 @@ pub fn ensureResolvedTranspiler(allocator: std.mem.Allocator, io: std.Io, framew
return transpilerDepsMissing(resolved);
}
/// `native check` over a TypeScript core: run the transpiler (checker +
/// emitter) on src/core.ts — and, through it, the core's whole import
/// graph under src/ — and surface its NS diagnostics verbatim — they
/// are the teaching layer, nothing wraps them (each diagnostic carries
/// the owning module's path). The emitted Zig lands in .native/check/ (a
/// scratch product, gitignored with the rest of .native/). Exit 0 =
/// typechecked, subset-clean, emitted.
/// `native check` over a TypeScript core: run the frontend in
/// check-only mode on src/core.ts — and, through it, the core's whole
/// import graph under src/ — and surface its NS diagnostics verbatim —
/// they are the teaching layer, nothing wraps them (each diagnostic
/// carries the owning module's path). Exit 0 = typechecked,
/// subset-clean.
pub fn checkCore(allocator: std.mem.Allocator, io: std.Io, base_env: *std.process.Environ.Map, framework_root: []const u8) !void {
const cli_path = try transpilerPath(allocator, io, framework_root, "src/cli.ts");
defer allocator.free(cli_path);
const runner_path = try tsRunnerPath(allocator, io, framework_root);
defer allocator.free(runner_path);
try ensureResolvedTranspiler(allocator, io, framework_root);
try std.Io.Dir.cwd().createDirPath(io, ".native/check");
var child = std.process.spawn(io, .{
.argv = &.{ "node", runner_path, cli_path, "src/core.ts", "-o", ".native/check/core.zig" },
.argv = &.{ "node", runner_path, cli_path, "src/core.ts" },
.stdin = .ignore,
.stdout = .inherit,
.stderr = .inherit,
@@ -436,7 +428,7 @@ pub fn checkCore(allocator: std.mem.Allocator, io: std.Io, base_env: *std.proces
.exited => |code| if (code == 0) return,
else => {},
}
// The transpiler's own diagnostics are already on screen; name the
// The frontend's own diagnostics are already on screen; name the
// failing pass without burying them.
std.debug.print("native check: src/core.ts failed the @native-sdk/core checker (diagnostics above)\n", .{});
return error.CoreCheckFailed;
@@ -463,7 +455,7 @@ pub fn runDevHost(allocator: std.mem.Allocator, io: std.Io, framework_root: []co
defer allocator.free(devhost_path);
const runner_path = try tsRunnerPath(allocator, io, framework_root);
defer allocator.free(runner_path);
// The harness runs the transpiler tier under node, so it needs the
// The harness runs the frontend tier under node, so it needs the
// TypeScript toolchain to resolve exactly like check/build do.
try ensureResolvedTranspiler(allocator, io, framework_root);
@@ -515,8 +507,8 @@ pub fn runDevHost(allocator: std.mem.Allocator, io: std.Io, framework_root: []co
// the `files: ["sdk"]` allowlist: sdk/core.ts, sdk/text.ts, sdk/events.ts,
// and the ambient bytes-text method surface core.ts references).
// The copy is
// EDITOR-AND-VERSIONING SURFACE ONLY: builds transpile against the SDK
// checkout's own sources and never read node_modules — delete it and
// EDITOR-AND-VERSIONING SURFACE ONLY: builds check and compile against the
// SDK checkout's own sources and never read node_modules — delete it and
// `native build|dev|check|test` still work; the next check/dev/build puts
// it back. Once the real package is published, a user-run `npm install`
// overwrites the copy with identical content; the refresh below compares
@@ -776,7 +768,7 @@ test "the checkout teaching's npm command survives production npm config" {
try std.testing.expect(std.mem.indexOf(u8, npm_ci_teaching_command, "--include=dev") != null);
}
/// The minimal manifest of the @typescript/typescript6 WRAPPER a fake
/// The minimal manifest of a stray @typescript/typescript6 wrapper a fake
/// COMPLETED install also lands (npm keeps installing it as a declared
/// dependency). The gate never probes it — validation tracks only the
/// aliased real compiler runtime loads — so tests land it exactly where
+1 -1
View File
@@ -64,7 +64,7 @@ pub fn run(allocator: std.mem.Allocator, io: std.Io, verb: Verb, options: Option
if (buildgraph.resolveFrameworkRoot(allocator, io, options.base_env) catch null) |framework_root| {
defer allocator.free(framework_root);
ts_core.selfHealEditorPackage(allocator, io, framework_root);
// The build graph runs the transpiler inside `zig build`; gate
// The build graph runs the frontend inside `zig build`; gate
// its toolchain resolution before any zig spawns — but ONLY
// for graphs the CLI itself generates (see the preflight's own
// doc for why ejected apps must flow past it).
+26 -12
View File
@@ -1,21 +1,25 @@
#!/bin/sh
# Build one ts-core fixture's compiled-core archive + contract sidecar
# with an external core toolchain (library mode), staging everything the
# with the external core compiler (library mode), staging everything the
# compile needs into a scratch tree:
#
# NATIVE_SDK_CORE_COMPILER="<toolchain command>" \
# tests/compiled-core/build_core.sh <fixture> <workdir>
# tests/compiled-core/build_core.sh <fixture> <workdir>
#
# <fixture>: ai-chat | soundboard | system-monitor | host-fixture | markup
# <workdir>: scratch directory (created; contents replaced)
#
# The compiler resolves from the SDK's own exact-pinned dependency
# (packages/core/node_modules — `npm ci` there installs it);
# NATIVE_SDK_CORE_COMPILER overrides with any toolchain command, still
# held to the pin.
#
# The stage carries: the AUTHOR'S core sources verbatim except for
# import-specifier resolution (the "@native-sdk/core*" bare specifiers
# rewrite to the staged ./sdk/ copies of the same files — the toolchain
# compiles its module graph from files, not package resolution), and the
# GENERATED compile entry + compiler profile from the staged contract
# artifacts (`zig build stage-core-contracts` emits both from the
# fixture's extracted contract sidecar — corewire's --facade and
# fixture's frontend-emitted contract sidecar — corewire's --facade and
# --profile projections).
#
# Outputs in <workdir>: lib<name>.a, core.contract.json, and a
@@ -25,14 +29,26 @@ set -eu
fixture="${1:?usage: build_core.sh <fixture> <workdir>}"
work="${2:?usage: build_core.sh <fixture> <workdir>}"
compiler="${NATIVE_SDK_CORE_COMPILER:?set NATIVE_SDK_CORE_COMPILER to the external core toolchain command}"
repo="$(cd "$(dirname "$0")/../.." && pwd)"
# The profile's determinism-fence table is RELEASE-PINNED DATA (see tools/corewire/emit_profile.zig): its ids resolve against one toolchain release's surface manifest, so the supplied command must BE that release. tests/compiled-core/core_compiler_pin is the one place the pin lives — bump it there and everything downstream follows.
pin="$(cat "$repo/tests/compiled-core/core_compiler_pin")"
if [ -n "${NATIVE_SDK_CORE_COMPILER:-}" ]; then
compiler="$NATIVE_SDK_CORE_COMPILER"
elif [ -f "$repo/packages/core/node_modules/scriptc/dist/main.js" ]; then
compiler="node $repo/packages/core/node_modules/scriptc/dist/main.js"
else
echo "the external core compiler is not installed — run \`npm ci --prefix $repo/packages/core\` (or point NATIVE_SDK_CORE_COMPILER at the pinned release's command)" >&2
exit 2
fi
# The profile's determinism-fence table is RELEASE-PINNED DATA (see tools/corewire/emit_profile.zig): its ids resolve against one toolchain release's surface manifest, so the supplied command must BE that release. packages/core/package.json's dependencies.scriptc is the ONE place the pin lives — bump it there and everything downstream follows.
pin="$(sed -n 's/.*"scriptc": *"\([0-9][0-9.]*\)".*/\1/p' "$repo/packages/core/package.json")"
if [ -z "$pin" ]; then
echo "packages/core/package.json carries no exact scriptc pin — the SDK tree is broken; reinstall or re-clone it" >&2
exit 2
fi
reported="$($compiler -v)"
if [ "$reported" != "$pin" ]; then
echo "external core toolchain reports version $reported, but the profile's fence table is pinned to $pin (tests/compiled-core/core_compiler_pin) — supply that release, or bump the pin when the fence table has been re-verified against the new release's surface manifest" >&2
echo "external core toolchain reports version $reported, but the profile's fence table is pinned to $pin (packages/core/package.json) — supply that release, or bump the pin when the fence table has been re-verified against the new release's surface manifest" >&2
exit 2
fi
@@ -84,8 +100,7 @@ mkdir -p "$work/sdk"
# 2. readonly-array erasure — `readonly T[]` (ReadonlyArray) sits
# outside the toolchain's sidecar type vocabulary today, so the
# TYPE-LEVEL readonly is erased on staged copies (`T[]` projects);
# values and behavior are untouched, and the paired battery holds
# the result byte-identical to the transpiler lane;
# values and behavior are untouched;
# 3. Bytes-alias folding — the corpus's `type Bytes = Uint8Array`
# alias is tabled (not folded) by the toolchain's sidecar emitter
# and a tabled scalar alias refuses, so staged copies spell
@@ -122,8 +137,7 @@ for sdk_file in text.ts events.ts; do
done
# The stage's @native-sdk/core is the static restatement: the reference
# module's factory VALUES verbatim, inside the toolchain's static
# surface (no overloads, no generic value instantiation). The paired
# battery holds every produced byte to the transpiler lane. The
# surface (no overloads, no generic value instantiation). The
# reference module's byte-text ambient surface also stays out: no
# fixture calls it, and its Uint8Array augmentation collides with the
# toolchain's own node ambient typings.
-1
View File
@@ -1 +0,0 @@
0.0.22
+9 -6
View File
@@ -1,8 +1,7 @@
#!/bin/sh
# Determinism-fence negative control: prove the profile's fences FIRE, not merely that clean cores pass under them.
#
# NATIVE_SDK_CORE_COMPILER="<toolchain command>" \
# tests/compiled-core/fence_check.sh <workdir>
# tests/compiled-core/fence_check.sh <workdir>
#
# <workdir>: scratch directory (created; contents replaced)
#
@@ -11,18 +10,22 @@
# 1. positive control — build_core.sh compiles the pristine fixture and the co-emitted contract sidecar must attest `deterministic: true`;
# 2. negative control — the staged fixture gets one injected fenced ambient read (Date.now() at the top of update), and the SAME compile invocation must refuse it, naming the fenced surface id (stdlib.date.now), with no archive and no attesting sidecar emitted.
#
# Skip-clean like the parity battery: no external toolchain supplied means the check reports the skip and exits 0, so unconditional callers (local dev boxes without the toolchain) stay green.
# The compiler resolves from the SDK's own exact-pinned dependency (packages/core/node_modules); NATIVE_SDK_CORE_COMPILER overrides. Skip-clean: neither supplied means the check reports the skip and exits 0, so unconditional callers (a checkout that never ran `npm ci` in packages/core) stay green.
set -u
work="${1:?usage: fence_check.sh <workdir>}"
repo="$(cd "$(dirname "$0")/../.." && pwd)"
if [ -z "${NATIVE_SDK_CORE_COMPILER:-}" ]; then
echo "fence-check: skipped — set NATIVE_SDK_CORE_COMPILER to the external core toolchain command to run the determinism-fence negative control"
if [ -n "${NATIVE_SDK_CORE_COMPILER:-}" ]; then
compiler="$NATIVE_SDK_CORE_COMPILER"
elif [ -f "$repo/packages/core/node_modules/scriptc/dist/main.js" ]; then
compiler="node $repo/packages/core/node_modules/scriptc/dist/main.js"
else
echo "fence-check: skipped — run \`npm ci --prefix $repo/packages/core\` (or set NATIVE_SDK_CORE_COMPILER) to run the determinism-fence negative control"
exit 0
fi
compiler="$NATIVE_SDK_CORE_COMPILER"
export NATIVE_SDK_CORE_COMPILER="$compiler"
# Half 1: the pristine compile succeeds and attests deterministic.
if ! "$repo/tests/compiled-core/build_core.sh" markup "$work"; then
-112
View File
@@ -1,112 +0,0 @@
//! Emit a fixture's paired-core root module (paired.zig) from the
//! transpiled module's OWN export surface: the lockstep entry points
//! (`initialModel`/`update`/`commitModelRoot`, the subscriptions and
//! host-event channels the fixture actually exports, `rt`) bind the
//! PairedCore lanes, and every other public declaration — types, model
//! helpers, catalog constants, channel consts — re-exports from the
//! transpiled lane verbatim. Generated at build time (the
//! sidecar-extractor pattern), so the paired surface can never go stale
//! against the fixture: a fixture gaining or dropping a channel
//! regenerates the root, and the host adapter's export-presence
//! detection keeps seeing the fixture's true surface.
const std = @import("std");
/// The decls the paired implementation owns; everything else forwards
/// to the transpiled lane.
const lockstep_decls = [_][]const u8{
"rt",
"initialModel",
"update",
"commitModelRoot",
"subscriptions",
"frameMsg",
"keyMsg",
"pinchMsg",
"commandMsg",
};
fn isLockstep(comptime name: []const u8) bool {
for (lockstep_decls) |decl| {
if (std.mem.eql(u8, decl, name)) return true;
}
return false;
}
pub fn pairedSource(comptime core: type) []const u8 {
comptime {
@setEvalBranchQuota(1_000_000);
var out: []const u8 =
\\//! Generated by the build (tests/compiled-core/gen_paired.zig):
\\//! the fixture's paired-core root — lockstep entry points over
\\//! both lanes, everything else re-exported from the transpiled
\\//! module. Do not edit.
\\const ts_lane = @import("ts_lane");
\\const impl = @import("paired_core.zig").PairedCore(ts_lane, @import("shim_lane"));
\\
\\pub const rt = impl.rt;
\\pub const paired_lanes = impl.paired_lanes;
\\pub const initialModel = impl.initialModel;
\\pub const update = impl.update;
\\pub const commitModelRoot = impl.commitModelRoot;
\\
;
for ([_][]const u8{ "subscriptions", "frameMsg", "keyMsg", "pinchMsg", "commandMsg" }) |name| {
if (@hasDecl(core, name)) {
out = out ++ "pub const " ++ name ++ " = impl." ++ name ++ ";\n";
}
}
for (@typeInfo(core).@"struct".decls) |decl| {
if (isLockstep(decl.name)) continue;
out = out ++ "pub const @\"" ++ decl.name ++ "\" = ts_lane.@\"" ++ decl.name ++ "\";\n";
}
return out;
}
}
pub fn emitMain(comptime core: type, init: std.process.Init) !void {
const source = comptime pairedSource(core);
const arena = init.arena.allocator();
const args = try init.minimal.args.toSlice(arena);
if (args.len < 2) {
var stderr_buffer: [256]u8 = undefined;
var stderr_writer = std.Io.File.stderr().writerStreaming(init.io, &stderr_buffer);
try stderr_writer.interface.print("usage: <gen-paired> <out path>\n", .{});
try stderr_writer.interface.flush();
std.process.exit(2);
}
if (std.fs.path.dirname(args[1])) |dir| {
std.Io.Dir.cwd().createDirPath(init.io, dir) catch {};
}
try std.Io.Dir.cwd().writeFile(init.io, .{ .sub_path = args[1], .data = source });
}
test "the paired root binds lockstep entries only for exported channels" {
const FixtureModel = struct { n: i64 };
const fixture = struct {
pub const Model = FixtureModel;
pub const Msg = union(enum) { ping, pong };
pub fn initialModel() *const Model {
unreachable;
}
pub fn update(model: *const Model, msg: Msg) *const Model {
_ = msg;
return model;
}
pub fn commitModelRoot(next: *const Model) *const Model {
return next;
}
pub fn keyMsg(key: void) ?Msg {
_ = key;
return null;
}
pub const chromeMsg = "pong";
};
const source = comptime pairedSource(fixture);
try std.testing.expect(std.mem.indexOf(u8, source, "pub const keyMsg = impl.keyMsg;") != null);
try std.testing.expect(std.mem.indexOf(u8, source, "impl.frameMsg") == null);
try std.testing.expect(std.mem.indexOf(u8, source, "impl.subscriptions") == null);
try std.testing.expect(std.mem.indexOf(u8, source, "pub const @\"chromeMsg\" = ts_lane.@\"chromeMsg\";") != null);
try std.testing.expect(std.mem.indexOf(u8, source, "pub const @\"Model\" = ts_lane.@\"Model\";") != null);
try std.testing.expect(std.mem.indexOf(u8, source, "pub const @\"Msg\" = ts_lane.@\"Msg\";") != null);
}
-289
View File
@@ -1,289 +0,0 @@
//! The lockstep pair of a transpiled core and a compiled-core mirror:
//! one module surface, two lanes under it. `PairedCore(ts_lane,
//! shim_lane)` exposes the transpiled-core ABI (`Model`/`Msg`/
//! `initialModel`/`update`/`commitModelRoot`/`subscriptions`/the
//! host-event channels/`rt`), forwards every call to BOTH lanes — the
//! transpiled module and the corewire mirror dispatching into a linked
//! compiled-core archive — and panics on the first observable-byte
//! divergence:
//!
//! - command bytes per dispatch (and the boot command),
//! - the committed-model snapshot: the archive's raw snapshot bytes
//! against the canonical encoding of the transpiled lane's
//! committed model,
//! - subscription bytes per reconcile,
//! - channel results (produced/gated agreement, then canonical
//! message bytes),
//! - every exported model helper's result bytes per commit.
//!
//! The transpiled lane's values are what the caller sees, so a fixture
//! app's whole e2e battery runs UNCHANGED over the pair: every
//! behavioral assertion passes through the compiled core with byte
//! parity checked at each seam. The staged `paired.zig` root (emitted
//! by gen_paired.zig from the transpiled module's own export surface)
//! re-exports exactly the decls the fixture declares, so the host
//! adapter's export-presence channel detection sees the fixture's true
//! surface.
//!
//! Process contract: like the mirror it wraps, a pair is one live core
//! per process — the archive owns one committed state.
const std = @import("std");
const core_abi = @import("core_abi");
const corewire_rt = @import("corewire_rt");
const convertValue = @import("mirror_value.zig").convertValue;
pub fn PairedCore(comptime ts_lane: type, comptime shim_lane: type) type {
return struct {
const abi = core_abi.Bindings("nsc_core_");
/// Every dispatch through this module runs BOTH lanes and
/// byte-compares them at each seam; perf pins that budget a single
/// core's dispatch cost can read this to scale their expectations.
pub const paired_lanes = true;
pub const Model = ts_lane.Model;
pub const Msg = ts_lane.Msg;
const ts_update_returns_cmd =
@typeInfo(@TypeOf(ts_lane.update)).@"fn".return_type.? != *const Model;
const ts_init_returns_cmd =
@typeInfo(@TypeOf(ts_lane.initialModel)).@"fn".return_type.? != *const Model;
// The two lanes must declare one contract: every comptime
// channel const the transpiled module exports, the mirror must
// export equal (and vice versa — the mirror's surface is the
// sidecar's, so a mismatch means the archive was compiled from
// a different fixture generation).
comptime {
for ([_][]const u8{ "appearanceMsg", "chromeMsg" }) |name| {
if (@hasDecl(ts_lane, name) != @hasDecl(shim_lane, name)) {
@compileError("paired core: the two lanes disagree about the " ++ name ++ " channel — rebuild the archive from the current fixture");
}
if (@hasDecl(ts_lane, name)) {
if (!std.mem.eql(u8, @field(ts_lane, name), @field(shim_lane, name))) {
@compileError("paired core: the two lanes name different " ++ name ++ " arms — rebuild the archive from the current fixture");
}
}
}
if (@hasDecl(ts_lane, "envMsgs") != @hasDecl(shim_lane, "envMsgs")) {
@compileError("paired core: the two lanes disagree about the envMsgs channel — rebuild the archive from the current fixture");
}
if (@hasDecl(ts_lane, "envMsgs")) {
if (ts_lane.envMsgs.len != shim_lane.envMsgs.len) {
@compileError("paired core: the two lanes declare different envMsgs entries — rebuild the archive from the current fixture");
}
for (ts_lane.envMsgs, shim_lane.envMsgs) |ts_entry, shim_entry| {
if (!std.mem.eql(u8, ts_entry.env, shim_entry.env) or !std.mem.eql(u8, ts_entry.msg, shim_entry.msg)) {
@compileError("paired core: the two lanes declare different envMsgs entries — rebuild the archive from the current fixture");
}
}
}
}
/// The mirror's decoded committed root — a valid model pointer
/// for the mirror entry points whose signatures carry one (the
/// archive derives everything from its own committed state).
var shim_root: ?*const shim_lane.Model = null;
/// Conversion/encoding scratch, reset with every frame reset.
var arena_state: ?std.heap.ArenaAllocator = null;
fn arena() std.mem.Allocator {
if (arena_state == null) {
arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator);
}
return arena_state.?.allocator();
}
fn resetArena() void {
if (arena_state) |*state| _ = state.reset(.retain_capacity);
}
fn shimRoot() *const shim_lane.Model {
return shim_root orelse @panic("paired core: a lane entry ran before initialModel — the host adapter boots the core first");
}
/// The archive's raw committed-model snapshot bytes (result-
/// arena resident: consumed before the next reset).
fn rawSnapshot() []const u8 {
var ptr: [*]const u8 = undefined;
var len: usize = 0;
abi.model_snapshot(&ptr, &len);
return ptr[0..len];
}
/// A transpiled-lane value re-expressed in the mirror's
/// sidecar-classed layout and canonically encoded.
fn referenceBytes(comptime T: type, value: anytype) []const u8 {
const converted = convertValue(T, value, arena()) catch @panic("paired core: out of conversion memory");
return corewire_rt.encodeAlloc(T, converted, arena());
}
fn checkBytes(expected: []const u8, actual: []const u8, comptime what: []const u8) void {
if (std.mem.eql(u8, expected, actual)) return;
std.debug.print(
"paired core: {s} diverges between the transpiled lane and the compiled core\n transpiled ({d} bytes): {x}\n compiled ({d} bytes): {x}\n",
.{ what, expected.len, expected, actual.len, actual },
);
@panic("paired core: " ++ what ++ " diverges between the transpiled lane and the compiled core");
}
pub const rt = struct {
pub const Cmd = []const u8;
pub const Sub = []const u8;
pub const cmd_none: Cmd = &.{};
pub const sub_none: Sub = &.{};
pub fn frameAlloc(comptime T: type, n: usize) []T {
return ts_lane.rt.frameAlloc(T, n);
}
pub fn frameCreate(comptime T: type, value: T) *T {
return ts_lane.rt.frameCreate(T, value);
}
pub fn frameReset() void {
ts_lane.rt.frameReset();
shim_lane.rt.frameReset();
resetArena();
}
pub fn resetAll() void {
ts_lane.rt.resetAll();
shim_lane.rt.resetAll();
resetArena();
}
};
pub fn initialModel() @typeInfo(@TypeOf(ts_lane.initialModel)).@"fn".return_type.? {
const ts_init = ts_lane.initialModel();
const shim_init = shim_lane.initialModel();
if (comptime ts_init_returns_cmd) {
shim_root = shim_init.model;
checkBytes(ts_init.cmd, shim_init.cmd, "the boot command");
} else {
shim_root = shim_init;
}
return ts_init;
}
pub fn update(model: *const Model, msg: Msg) @typeInfo(@TypeOf(ts_lane.update)).@"fn".return_type.? {
const shim_msg = convertValue(shim_lane.Msg, msg, arena()) catch @panic("paired core: out of conversion memory");
const ts_out = ts_lane.update(model, msg);
const shim_out = shim_lane.update(shimRoot(), shim_msg);
shim_root = shim_out.model;
if (comptime ts_update_returns_cmd) {
checkBytes(ts_out.cmd, shim_out.cmd, "a dispatch's command bytes");
} else {
checkBytes(&.{}, shim_out.cmd, "a dispatch's command bytes");
}
return ts_out;
}
pub fn commitModelRoot(next: *const Model) *const Model {
const committed = ts_lane.commitModelRoot(next);
checkBytes(referenceBytes(shim_lane.Model, committed.*), rawSnapshot(), "the committed-model snapshot");
helperParity(committed);
return committed;
}
/// Every exported model helper, both lanes, compared by the
/// canonical encoding of the mirror-classed result. The mirror's
/// Model methods route helper_call into the archive; the
/// transpiled Model carries the same names as direct methods.
/// Each lane's call shape follows ITS OWN declaration (a
/// compiled contract may class an allocation-needing helper
/// arena-taking where the transpiled lane returns frame-arena
/// slices without one).
fn helperParity(committed: *const Model) void {
inline for (@typeInfo(shim_lane.Model).@"struct".decls) |decl| {
const DeclType = @TypeOf(@field(shim_lane.Model, decl.name));
if (@typeInfo(DeclType) == .@"fn") {
const fn_info = @typeInfo(DeclType).@"fn";
const Ret = fn_info.return_type.?;
if (fn_info.params.len >= 1 and fn_info.params[0].type == *const shim_lane.Model and
(fn_info.params.len == 1 or (fn_info.params.len == 2 and fn_info.params[1].type == std.mem.Allocator)))
{
const shim_result = if (fn_info.params.len == 2)
@field(shim_lane.Model, decl.name)(shimRoot(), arena())
else
@field(shim_lane.Model, decl.name)(shimRoot());
const ts_fn_info = @typeInfo(@TypeOf(@field(Model, decl.name))).@"fn";
const ts_result = if (ts_fn_info.params.len == 2)
@field(Model, decl.name)(committed, arena())
else
@field(Model, decl.name)(committed);
checkBytes(
referenceBytes(Ret, ts_result),
corewire_rt.encodeAlloc(Ret, shim_result, arena()),
"the model helper " ++ decl.name,
);
}
}
}
}
pub fn subscriptions(model: *const Model) []const u8 {
const ts_subs = ts_lane.subscriptions(model);
const shim_subs = shim_lane.subscriptions(shimRoot());
checkBytes(ts_subs, shim_subs, "the subscription bytes");
return ts_subs;
}
/// Both lanes must gate or produce together, and a produced
/// message must carry one value (compared as canonical bytes in
/// the mirror's layout).
fn checkChannel(ts_msg: ?Msg, shim_msg: ?shim_lane.Msg, comptime what: []const u8) void {
if ((ts_msg == null) != (shim_msg == null)) {
@panic("paired core: " ++ what ++ " gates in one lane and produces in the other — the two lanes disagree");
}
if (ts_msg) |produced| {
checkBytes(
referenceBytes(shim_lane.Msg, produced),
corewire_rt.encodeAlloc(shim_lane.Msg, shim_msg.?, arena()),
what ++ "'s produced message",
);
}
}
pub fn frameMsg(model: *const Model, frame: FrameEventOf(ts_lane)) ?Msg {
const shim_frame = convertValue(FrameEventOf(shim_lane), frame, arena()) catch @panic("paired core: out of conversion memory");
const ts_msg = ts_lane.frameMsg(model, frame);
const shim_msg = shim_lane.frameMsg(shimRoot(), shim_frame);
checkChannel(ts_msg, shim_msg, "the frame channel");
return ts_msg;
}
pub fn keyMsg(key: KeyEventOf(ts_lane)) ?Msg {
const shim_key = convertValue(KeyEventOf(shim_lane), key, arena()) catch @panic("paired core: out of conversion memory");
const ts_msg = ts_lane.keyMsg(key);
const shim_msg = shim_lane.keyMsg(shim_key);
checkChannel(ts_msg, shim_msg, "the key channel");
return ts_msg;
}
pub fn pinchMsg(pinch: PinchEventOf(ts_lane)) ?Msg {
const shim_pinch = convertValue(PinchEventOf(shim_lane), pinch, arena()) catch @panic("paired core: out of conversion memory");
const ts_msg = ts_lane.pinchMsg(pinch);
const shim_msg = shim_lane.pinchMsg(shim_pinch);
checkChannel(ts_msg, shim_msg, "the pinch channel");
return ts_msg;
}
pub fn commandMsg(name: []const u8) ?Msg {
const ts_msg = ts_lane.commandMsg(name);
const shim_msg = shim_lane.commandMsg(name);
checkChannel(ts_msg, shim_msg, "the command channel");
return ts_msg;
}
fn FrameEventOf(comptime lane: type) type {
return @typeInfo(@TypeOf(lane.frameMsg)).@"fn".params[1].type.?;
}
fn KeyEventOf(comptime lane: type) type {
return @typeInfo(@TypeOf(lane.keyMsg)).@"fn".params[0].type.?;
}
fn PinchEventOf(comptime lane: type) type {
return @typeInfo(@TypeOf(lane.pinchMsg)).@"fn".params[0].type.?;
}
};
}
+78 -191
View File
@@ -1,147 +1,93 @@
//! Sidecar-shim conformance: for every core in the ts-core corpus, the
//! build runs BOTH lanes — today's transpiler emitting core.zig, and
//! corewire generating the mirror from that core's contract sidecar —
//! and this suite holds their reflection surfaces byte-identical:
//! Sidecar-shim conformance: for every core in the ts-core corpus,
//! corewire generates the mirror from that core's contract sidecar —
//! the frontend-emitted document for the compiled fixtures, plus two
//! hand-written ground truths for the schema itself — and this suite
//! validates the generated surface:
//!
//! 1. `layout_fingerprint.describe` of Model and Msg (field names,
//! order, types, enum values, union tags — everything the journal
//! and wire identities hash) must match exactly.
//! 2. The model-contract artifact (the serialized Contract `native
//! check` verifies markup against: scalars, nested groups,
//! iterables, msg payload classes, unbound lists) must match
//! byte-for-byte, after one principled normalization: Zig names
//! anonymous payload records with a compiler-internal instance
//! counter (`Msg__struct_<N>`), which differs across modules even
//! for identical declarations — in both lanes alike — so the digits
//! are masked before comparison. No checker keys on those digits;
//! every load-bearing spelling ("f64", "[]const u8", named types)
//! is compared exactly.
//! 1. The markup fixture's mirror (over the COMMITTED hand-written
//! sidecar, tests/sidecar/markup_fixture.contract.json) keeps its
//! pinned layout fingerprints — the describe hashes the journal and
//! wire identities ride — and the sidecar's declared channel/export
//! surface and declaration-order wire tags.
//! 2. The integer fixture's mirror decodes every slot per its attested
//! class over hand-computed wire vectors.
//! 3. Every generated shim (dispatch stubs, snapshot decoder, channel
//! forwarders, helper methods) fully analyzes and links against the
//! stub core's exported symbol set — the executable surface is
//! compile- and link-proven for the whole corpus without driving
//! the fixtures' real archives.
//!
//! A fixture passing both is proof the reflecting seams (markup
//! engines, adapter, bridge, model-contract emit) cannot tell the
//! generated mirror from transpiler output. The suite also forces full
//! semantic analysis of every generated shim (dispatch stubs, snapshot
//! decoder, channel forwarders, helper methods) against the stub core's
//! exported symbol set, so the executable surface compiles and links
//! even though no compiled core exists to drive it yet.
//!
//! The markup fixture's sidecar is hand-written
//! (tests/sidecar/markup_fixture.contract.json) — independent ground
//! truth for the schema. The other fixtures' sidecars are extracted
//! from the transpiled modules at build time (tools/corewire/
//! extract.zig); the comparison stays honest because the reference side
//! is always the real transpiled module, so an extraction infidelity
//! surfaces here exactly like a generator one.
//! Behavioral truth over the REAL compiled cores lives in the e2e
//! batteries (tests/ts-core, one archive per binary) and the ABI-law
//! suite (external_core_abi_tests.zig); this suite is the generator's
//! reflection fence.
const std = @import("std");
const native_sdk = @import("native_sdk");
const lf = native_sdk.automation.layout_fingerprint;
const canvas = native_sdk.canvas;
const contract = canvas.ui_markup.contract;
const stub_core = @import("stub_core.zig");
const corewire_rt = @import("corewire_rt");
const ts_markup = @import("ts_markup_core");
const shim_markup = @import("shim_markup_core");
const shim_integer = @import("shim_integer_core");
const ts_host = @import("ts_host_core");
const shim_host = @import("shim_host_core");
const ts_soundboard = @import("ts_soundboard_core");
const shim_soundboard = @import("shim_soundboard_core");
const ts_monitor = @import("ts_monitor_core");
const shim_monitor = @import("shim_monitor_core");
const ts_ai_chat = @import("ts_ai_chat_core");
const shim_ai_chat = @import("shim_ai_chat_core");
const testing = std.testing;
fn expectDescribeIdentical(comptime ts: type, comptime shim: type) !void {
try testing.expectEqualStrings(comptime lf.describe(ts.Model), comptime lf.describe(shim.Model));
try testing.expectEqualStrings(comptime lf.describe(ts.Msg), comptime lf.describe(shim.Msg));
// Same description, same hash — the fingerprint idiom the journal
// and protocol identities ride.
try testing.expectEqual(lf.hash(comptime lf.describe(ts.Model)), lf.hash(comptime lf.describe(shim.Model)));
try testing.expectEqual(lf.hash(comptime lf.describe(ts.Msg)), lf.hash(comptime lf.describe(shim.Msg)));
}
/// Serialize a reflected contract the way the model-contract build step
/// does (source_hash stays 0: both sides reflect the same app sources,
/// and the hash is an emit-time input, not a reflection fact).
fn artifactBytes(comptime Model: type, comptime Msg: type, allocator: std.mem.Allocator) ![]const u8 {
const described = comptime canvas.describeModelContract(Model, Msg);
var out: std.Io.Writer.Allocating = .init(allocator);
defer out.deinit();
try contract.writeArtifact(described, &out.writer);
return allocator.dupe(u8, out.written());
}
/// Mask the compiler's anonymous-type instance counters
/// (`__struct_<digits>` -> `__struct_#`): the one spelling that differs
/// between two modules declaring identical anonymous records.
fn maskAnonCounters(allocator: std.mem.Allocator, text: []const u8) ![]const u8 {
var out: std.ArrayListUnmanaged(u8) = .empty;
const marker = "__struct_";
var index: usize = 0;
while (std.mem.indexOfPos(u8, text, index, marker)) |found| {
const digits_start = found + marker.len;
var digits_end = digits_start;
while (digits_end < text.len and std.ascii.isDigit(text[digits_end])) digits_end += 1;
if (digits_end == digits_start) {
try out.appendSlice(allocator, text[index .. found + marker.len]);
index = found + marker.len;
continue;
}
try out.appendSlice(allocator, text[index..found]);
try out.appendSlice(allocator, marker);
try out.append(allocator, '#');
index = digits_end;
}
try out.appendSlice(allocator, text[index..]);
return out.items;
}
fn expectContractIdentical(comptime ts: type, comptime shim: type) !void {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const ts_artifact = try maskAnonCounters(arena, try artifactBytes(ts.Model, ts.Msg, arena));
const shim_artifact = try maskAnonCounters(arena, try artifactBytes(shim.Model, shim.Msg, arena));
try testing.expectEqualStrings(ts_artifact, shim_artifact);
}
// ------------------------------------------------------ markup fixture
// The bootstrap pair: hand-written sidecar, the full channel surface
// (frame/key/pinch/appearance/chrome/env), text-input and inline-record
// payloads, an optional scalar, a node-pointer iterable.
// The bootstrap mirror: hand-written sidecar (independent ground truth
// for the schema), the full channel surface (frame/key/pinch/
// appearance/chrome/env), text-input and inline-record payloads, an
// optional scalar, a node-pointer iterable.
test "markup fixture: layout fingerprints are identical" {
try expectDescribeIdentical(ts_markup, shim_markup);
test "markup fixture: mirror layout fingerprints stay pinned" {
// Golden fingerprint hashes of the mirror's Model and Msg describe
// strings over the COMMITTED sidecar — the hashes the journal and
// protocol identities ride. They move only when the sidecar or the
// generator's projection rules change; review the printed describe
// string before re-pinning.
const model_desc = comptime lf.describe(shim_markup.Model);
const msg_desc = comptime lf.describe(shim_markup.Msg);
testing.expectEqual(@as(u64, 0x26243886fcbe6b9f), lf.hash(model_desc)) catch |err| {
std.debug.print("mirror Model describe:\n{s}\n", .{model_desc});
return err;
};
testing.expectEqual(@as(u64, 0x643e34ab6a9bcb8a), lf.hash(msg_desc)) catch |err| {
std.debug.print("mirror Msg describe:\n{s}\n", .{msg_desc});
return err;
};
}
test "markup fixture: model-contract artifacts are byte-identical" {
try expectContractIdentical(ts_markup, shim_markup);
test "markup fixture: the mirror declares the sidecar's channel surface" {
// The adapter wires channels from export presence; pin the export
// set and arm-name constants the committed sidecar declares.
try testing.expect(@hasDecl(shim_markup, "frameMsg"));
try testing.expect(@hasDecl(shim_markup, "keyMsg"));
try testing.expect(@hasDecl(shim_markup, "pinchMsg"));
try testing.expect(!@hasDecl(shim_markup, "commandMsg"));
try testing.expectEqualStrings("appearance_changed", shim_markup.appearanceMsg);
try testing.expectEqualStrings("chrome_changed", shim_markup.chromeMsg);
try testing.expectEqual(1, shim_markup.envMsgs.len);
try testing.expectEqualStrings("TS_BOARD_BANNER", shim_markup.envMsgs[0].env);
try testing.expectEqualStrings("banner_set", shim_markup.envMsgs[0].msg);
}
test "markup fixture: channel exports mirror the transpiled surface" {
// The adapter wires channels from export presence; hold the two
// lanes' export sets and arm-name constants equal.
try testing.expectEqual(@hasDecl(ts_markup, "frameMsg"), @hasDecl(shim_markup, "frameMsg"));
try testing.expectEqual(@hasDecl(ts_markup, "keyMsg"), @hasDecl(shim_markup, "keyMsg"));
try testing.expectEqual(@hasDecl(ts_markup, "pinchMsg"), @hasDecl(shim_markup, "pinchMsg"));
try testing.expectEqual(@hasDecl(ts_markup, "commandMsg"), @hasDecl(shim_markup, "commandMsg"));
try testing.expectEqualStrings(ts_markup.appearanceMsg, shim_markup.appearanceMsg);
try testing.expectEqualStrings(ts_markup.chromeMsg, shim_markup.chromeMsg);
try testing.expectEqual(ts_markup.envMsgs.len, shim_markup.envMsgs.len);
inline for (ts_markup.envMsgs, shim_markup.envMsgs) |expected, actual| {
try testing.expectEqualStrings(expected.env, actual.env);
try testing.expectEqualStrings(expected.msg, actual.msg);
}
}
test "markup fixture: wire tags ride declaration order" {
inline for (@typeInfo(ts_markup.Msg).@"union".fields, 0..) |field, tag| {
try testing.expectEqualStrings(field.name, shim_markup.msg_tags[tag]);
test "markup fixture: wire tags ride the sidecar's declaration order" {
// The committed sidecar's msg section, in order — the tag authority
// every dispatch entry indexes into.
const expected_tags = [_][]const u8{
"add", "toggle", "pick", "cycle", "clear",
"stamp", "stamped", "hover_row", "hover_off", "draft_edit",
"canvas_resized", "zoomed", "appearance_changed", "chrome_changed", "banner_set",
};
try testing.expectEqual(expected_tags.len, shim_markup.msg_tags.len);
inline for (expected_tags, 0..) |expected, tag| {
try testing.expectEqualStrings(expected, shim_markup.msg_tags[tag]);
try testing.expectEqualStrings(expected, @typeInfo(shim_markup.Msg).@"union".fields[tag].name);
}
}
@@ -150,93 +96,34 @@ test "markup fixture: wire tags ride declaration order" {
// (void, bytes, number f64/i64, number_bytes, and the audio/image/
// channel event records), enums in the model, an InitResult boot.
test "host fixture: layout fingerprints are identical" {
try expectDescribeIdentical(ts_host, shim_host);
}
test "host fixture: model-contract artifacts are byte-identical" {
try expectContractIdentical(ts_host, shim_host);
}
test "host fixture: the boot shape mirrors the transpiled surface" {
// fixture.ts returns [model, cmd] from initialModel: both lanes
// must expose the InitResult shape, not the bare pointer.
try testing.expect(@typeInfo(@typeInfo(@TypeOf(ts_host.initialModel)).@"fn".return_type.?) == .@"struct");
test "host fixture: the mirror declares the boot and subscription shape" {
// fixture.ts returns [model, cmd] from initialModel and exports
// subscriptions: the mirror must expose the InitResult shape (not
// the bare pointer) and the subscription entry.
try testing.expect(@typeInfo(@typeInfo(@TypeOf(shim_host.initialModel)).@"fn".return_type.?) == .@"struct");
try testing.expectEqual(@hasDecl(ts_host, "subscriptions"), @hasDecl(shim_host, "subscriptions"));
}
// -------------------------------------------------------- soundboard
// The helper-heavy real app: dozens of exported Model helpers
// (fn-backed scalars and iterables), optional model fields, chrome and
// env channels.
test "soundboard: layout fingerprints are identical" {
try expectDescribeIdentical(ts_soundboard, shim_soundboard);
}
test "soundboard: model-contract artifacts are byte-identical" {
try expectContractIdentical(ts_soundboard, shim_soundboard);
}
// ---------------------------------------------------- system monitor
test "system monitor: layout fingerprints are identical" {
try expectDescribeIdentical(ts_monitor, shim_monitor);
}
test "system monitor: model-contract artifacts are byte-identical" {
try expectContractIdentical(ts_monitor, shim_monitor);
try testing.expect(@hasDecl(shim_host, "subscriptions"));
}
// ------------------------------------------------------------ ai-chat
// The worked-example app: 13 helpers, a node-pointer draft record, a
// The worked-example app: helper-heavy, a node-pointer draft record, a
// controlled scroll, text input, number_bytes fetch completion, three
// env channels.
test "ai-chat: layout fingerprints are identical" {
try expectDescribeIdentical(ts_ai_chat, shim_ai_chat);
}
test "ai-chat: model-contract artifacts are byte-identical" {
try expectContractIdentical(ts_ai_chat, shim_ai_chat);
}
test "ai-chat: helper methods keep the exported call surface" {
// The markup engines bind helpers as Model methods; hold the two
// lanes' method sets equal by name and shape (the contract
// comparison already proves kinds — this pins presence).
const ts_decls = @typeInfo(ts_ai_chat.Model).@"struct".decls;
const shim_decls = @typeInfo(shim_ai_chat.Model).@"struct".decls;
comptime var ts_fn_count = 0;
comptime var shim_fn_count = 0;
inline for (ts_decls) |decl| {
if (@typeInfo(@TypeOf(@field(ts_ai_chat.Model, decl.name))) == .@"fn") ts_fn_count += 1;
}
inline for (shim_decls) |decl| {
if (@typeInfo(@TypeOf(@field(shim_ai_chat.Model, decl.name))) == .@"fn") shim_fn_count += 1;
}
try testing.expectEqual(ts_fn_count, shim_fn_count);
inline for (ts_decls) |decl| {
if (@typeInfo(@TypeOf(@field(ts_ai_chat.Model, decl.name))) != .@"fn") continue;
try testing.expect(@hasDecl(shim_ai_chat.Model, decl.name));
}
// The markup engines bind helpers as Model methods; pin the two
// helpers the shipping markup and the e2e battery lean on (the
// battery executes them against the real archive).
try testing.expect(@hasDecl(shim_ai_chat.Model, "draftText"));
try testing.expect(@hasDecl(shim_ai_chat.Model, "unconfigured"));
}
// ---------------------------------------------- executable surface
// Force full semantic analysis and codegen of every generated shim —
// dispatch stubs, snapshot decoders, channel forwarders, helper
// methods — linked against the stub core's exported symbol set. No
// compiled core exists yet (the ABI is a draft), so these paths are
// compile- and link-proven here, not executed.
// ------------------------------------------- channel envelope axis
//
// The channel bytes envelope ([produced u8][tag u8][payload…]): a
// channel entry's whole result rides one bytes return, the compiled
// core packs it, the generated shim unpacks it. The packing side is
// proven at full behavioral depth by the compiled-core parity
// batteries (the generated facade IS the compiled core's entry); the
// proven at full behavioral depth by the e2e batteries and the ABI-law
// suite (the generated facade IS the compiled core's entry); the
// unpacking side is executable here, driven against the stub core's
// test-settable envelope: the shim's channel entries gate on the
// produced flag and decode the payload back into the mirror value.
+317
View File
@@ -0,0 +1,317 @@
//! ABI laws over a REAL compiled core: the build compiles the markup
//! fixture through the external core compiler and links the archive
//! into this binary beside a mirror generated from the archive's OWN
//! co-emitted sidecar (the boot identity fence pairs them), plus the
//! raw C ABI bindings. The suite drives one scripted message sequence
//! and pins the laws every compiled core must hold:
//!
//! - boot: the identity fence passes, and a fixture whose init
//! returns a bare model produces an EMPTY boot-command buffer;
//! - snapshot fidelity: the committed-model snapshot decodes through
//! the mirror's declared types and re-encodes to the same bytes,
//! every cycle;
//! - command bytes: the one command-producing arm (`stamp`) returns
//! the `now` op's pinned wire bytes; every other cycle returns an
//! empty command buffer;
//! - collect invariant: a collect between dispatches leaves the
//! observable snapshot byte-identical;
//! - deterministic re-init: a second init lands the core back on the
//! boot snapshot bytes;
//! - channel envelopes ([produced u8][tag u8][payload…]): gating
//! events return the two-byte nothing-produced envelope, produced
//! arms decode and dispatch as full cycles, and the raw frame entry
//! truncates fractional presentation widths before comparing;
//! - integer classes: attested slots carry the compiler-provable
//! extremes (+-(2^53 - 1)) through a real dispatch exactly.
//!
//! The e2e batteries (tests/ts-core) drive the same archives through
//! the full runtime; this suite is the ABI's own contract, held with
//! nothing above the C boundary.
const std = @import("std");
const corewire_rt = @import("corewire_rt");
const core_abi = @import("core_abi");
const shim_core = @import("shim_core");
const abi = core_abi.Bindings("nsc_core_");
const testing = std.testing;
/// The archive's raw committed-model snapshot bytes (result-arena
/// resident: read and compare before any frame reset or collect).
fn rawSnapshot() []const u8 {
var ptr: [*]const u8 = undefined;
var len: usize = 0;
abi.model_snapshot(&ptr, &len);
return ptr[0..len];
}
fn rawSubscriptions() []const u8 {
var ptr: [*]const u8 = undefined;
var len: usize = 0;
abi.subscriptions(&ptr, &len);
return ptr[0..len];
}
fn rawFrameMsg(width: f64, height: f64, timestamp_ms: f64, interval_ms: f64) []const u8 {
var ptr: [*]const u8 = undefined;
var len: usize = 0;
abi.frame_msg(width, height, timestamp_ms, interval_ms, &ptr, &len);
return ptr[0..len];
}
/// Snapshot fidelity: the archive's raw snapshot bytes must equal the
/// canonical re-encoding of the mirror's decoded committed root — the
/// decoder and the archive's encoder agree on every byte.
fn expectSnapshotFidelity(model: *const shim_core.Model, arena: std.mem.Allocator) !void {
try testing.expectEqualSlices(u8, rawSnapshot(), corewire_rt.encodeAlloc(shim_core.Model, model.*, arena));
}
/// The selection sample follows the sidecar's classes: a signed focus
/// keeps the negative-value coverage, an unsigned one exercises a
/// backward selection (anchor past focus) instead.
const selection_sample = blk: {
const Selection = @FieldType(@FieldType(shim_core.Msg, "draft_edit"), "set_selection");
if (carriesNegatives(@FieldType(Selection, "focus"))) {
break :blk Selection{ .anchor = 1, .focus = -2 };
}
break :blk Selection{ .anchor = 3, .focus = 1 };
};
/// The scripted sequence: every dispatch entry class the fixture's
/// contract declares — bare arms, i64- and f64-classed numbers, bytes,
/// the text-input union (each payload family), and the three record
/// arms — plus the one command-producing arm (`stamp`).
const script = [_]shim_core.Msg{
.add,
.add,
.{ .toggle = 2 },
.{ .pick = 2.5 },
.add,
.cycle,
.{ .banner_set = "parity" },
.{ .draft_edit = .{ .insert_text = "hi" } },
.{ .draft_edit = .delete_backward },
.{ .draft_edit = .{ .move_caret = .{ .direction = .next_word, .extend = true } } },
.{ .draft_edit = .{ .set_selection = selection_sample } },
.{ .draft_edit = .{ .set_composition = .{ .text = "ab", .cursor = 1 } } },
.{ .draft_edit = .{ .set_composition = .{ .text = "", .cursor = null } } },
.{ .draft_edit = .clear },
.{ .canvas_resized = 800 },
.{ .zoomed = .{ .factor = 1.25, .windowId = 7, .fromBoard = true } },
.{ .appearance_changed = .{ .colorScheme = .dark, .reduceMotion = false, .highContrast = true } },
.{ .chrome_changed = .{ .insets = .{ .top = 28, .right = 0, .bottom = 0, .left = 0 }, .buttons = .{ .x = 8, .y = 6, .width = 52, .height = 16 }, .tabsProjected = false } },
.stamp,
.{ .stamped = 42.5 },
.{ .toggle = 1 },
.cycle,
.clear,
};
/// The `now` command's wire bytes for the fixture's `stamp` cycle,
/// pinned: cmd format 3's `now` op carrying the `stamped` arm's
/// declaration-order wire tag. Captured from the compiled core and
/// reviewed against the wire format; a change here is a wire-format
/// or fixture change, never noise.
const now_cmd_bytes = [_]u8{ 2, 6 };
test "a compiled core holds the ABI laws over the scripted cycle" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// Boot. The mirror's initialModel runs the full boot fence against
// the archive (identity getters, sink, init); the fixture's
// contract declares no boot command, so the archive's boot_cmd
// buffer must be empty.
var shim_model = shim_core.commitModelRoot(shim_core.initialModel());
{
var boot_ptr: [*]const u8 = undefined;
var boot_len: usize = 0;
abi.boot_cmd(&boot_ptr, &boot_len);
try testing.expectEqualSlices(u8, "", boot_ptr[0..boot_len]);
}
try expectSnapshotFidelity(shim_model, arena);
// The boot snapshot bytes, kept for the deterministic re-init pin.
const boot_snapshot = try arena.dupe(u8, rawSnapshot());
shim_core.rt.frameReset();
for (script, 0..) |msg, step| {
// One cycle, the host adapter's ordering: update, commit,
// consume the command bytes, then frame reset.
const shim_out = shim_core.update(shim_model, msg);
shim_model = shim_core.commitModelRoot(shim_out.model);
// The command surface: `stamp` is the fixture's one
// command-producing arm; every other cycle returns nothing.
if (msg == .stamp) {
testing.expectEqualSlices(u8, &now_cmd_bytes, shim_out.cmd) catch |err| {
std.debug.print("stamp command bytes at step {d}: {x}\n", .{ step, shim_out.cmd });
return err;
};
} else {
testing.expectEqualSlices(u8, "", shim_out.cmd) catch |err| {
std.debug.print("unexpected command bytes at script step {d} ({s}): {x}\n", .{ step, @tagName(msg), shim_out.cmd });
return err;
};
}
expectSnapshotFidelity(shim_model, arena) catch |err| {
std.debug.print("snapshot fidelity diverges at script step {d} ({s})\n", .{ step, @tagName(msg) });
return err;
};
// Between dispatches, a collect must leave the observable model
// untouched (the ABI's collect invariant), and the fixture's
// contract declares no subscriptions, so the buffer stays
// empty.
if (step % 3 == 2) {
const reference = try arena.dupe(u8, rawSnapshot());
abi.collect();
testing.expectEqualSlices(u8, reference, rawSnapshot()) catch |err| {
std.debug.print("collect changed the observable snapshot at script step {d} ({s})\n", .{ step, @tagName(msg) });
return err;
};
}
try testing.expectEqualSlices(u8, "", rawSubscriptions());
shim_core.rt.frameReset();
}
// Deterministic re-init: a second boot lands the core back on the
// boot bytes.
shim_core.rt.resetAll();
shim_model = shim_core.commitModelRoot(shim_core.initialModel());
try testing.expectEqualSlices(u8, boot_snapshot, rawSnapshot());
shim_core.rt.frameReset();
}
/// One dispatch cycle over one message, holding snapshot fidelity.
fn singleCycle(
arena: std.mem.Allocator,
shim_model: *const shim_core.Model,
msg: shim_core.Msg,
) !*const shim_core.Model {
const shim_out = shim_core.update(shim_model, msg);
const next = shim_core.commitModelRoot(shim_out.model);
try expectSnapshotFidelity(next, arena);
shim_core.rt.frameReset();
return next;
}
test "integer-classed slots cross the compiler-provable extremes exactly" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// A fresh boot (init is the deterministic re-init seam, so this
// test stands alone). The archive's sidecar attests its integer
// classes; the classed arms carry the +-(2^53 - 1) extremes through
// a real dispatch, the snapshot bytes round-trip through the
// mirror's decoder, and the decoded slots hold the exact integers.
shim_core.rt.resetAll();
var shim_model = shim_core.commitModelRoot(shim_core.initialModel());
shim_core.rt.frameReset();
// The extremes follow each slot's class in the sidecar: a signed
// slot also crosses the negative extreme; a u64-attested one stays
// within its unsigned range. Every crossing value sits within
// +-(2^53 - 1), so each class carries it exactly.
const max_exact = 9007199254740991; // 2^53 - 1
const boundary_script = comptime blk: {
var msgs: []const shim_core.Msg = &.{
.{ .canvas_resized = max_exact },
.{ .toggle = max_exact },
};
if (carriesNegatives(@FieldType(shim_core.Msg, "toggle"))) {
msgs = msgs ++ [_]shim_core.Msg{.{ .toggle = -max_exact }};
}
if (carriesNegatives(@FieldType(shim_core.Msg, "canvas_resized"))) {
msgs = msgs ++ [_]shim_core.Msg{.{ .canvas_resized = -max_exact }};
}
msgs = msgs ++ [_]shim_core.Msg{.{ .canvas_resized = 0 }};
break :blk msgs;
};
inline for (boundary_script, 0..) |msg, step| {
shim_model = singleCycle(arena, shim_model, msg) catch |err| {
std.debug.print("integer boundary fidelity diverges at step {d} ({s})\n", .{ step, @tagName(msg) });
return err;
};
// The mirror decoded the committed snapshot: its numeric slot
// holds the exact crossing value (compared class-agnostically —
// the arm and the field each follow their own class, and every
// crossing value is f64-exact).
switch (msg) {
.canvas_resized => |value| try testing.expectEqual(exactValue(value), exactValue(shim_model.canvasWidth)),
else => {},
}
}
}
/// Whether a mirror slot's class carries negative values: signed
/// integers and f64 do; the unsigned class does not.
fn carriesNegatives(comptime T: type) bool {
return switch (@typeInfo(T)) {
.int => |info| info.signedness == .signed,
.float => true,
else => @compileError("not a numeric mirror slot: " ++ @typeName(T)),
};
}
/// A class-agnostic exact comparison value: every crossing this suite
/// drives sits within +-(2^53 - 1), where f64 carries integers exactly.
fn exactValue(value: anytype) f64 {
return switch (@typeInfo(@TypeOf(value))) {
.int => @floatFromInt(value),
.float => value,
else => @compileError("not a numeric mirror slot: " ++ @typeName(@TypeOf(value))),
};
}
test "channel entries hold the bytes-envelope laws" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// A fresh boot (init is the deterministic re-init seam, so this
// test stands alone).
shim_core.rt.resetAll();
var shim_model = shim_core.commitModelRoot(shim_core.initialModel());
shim_core.rt.frameReset();
// Gating: a chorded key, an unchanged frame, and a begin pinch
// produce nothing (the archive's entry returns the two-byte
// nothing-produced envelope, which the mirror gates to null).
try testing.expect(shim_core.keyMsg(.{ .key = "space", .shift = false, .control = true, .alt = false, .super = false }) == null);
try testing.expect(shim_core.frameMsg(shim_model, .{ .width = 0, .height = 600, .timestampMs = 16, .intervalMs = 16 }) == null);
try testing.expect(shim_core.pinchMsg(.{ .windowId = 7, .label = "ts-markup-canvas", .phase = .begin, .scale = 0, .x = 1, .y = 2 }) == null);
// The presented-frame channel produces the resize at boot width,
// and the dispatched cycle updates the model so the same frame then
// gates (the idle law, proven across the round trip).
{
const msg = shim_core.frameMsg(shim_model, .{ .width = 800, .height = 600, .timestampMs = 16, .intervalMs = 16 }) orelse return error.TestUnexpectedResult;
shim_model = try singleCycle(arena, shim_model, msg);
try testing.expect(shim_core.frameMsg(shim_model, .{ .width = 800, .height = 600, .timestampMs = 32, .intervalMs = 16 }) == null);
// The raw archive entry receives f64 logical points before the
// generated mirror narrows its classed width. Compare after
// truncation so a fractional presentation at the committed
// width does not keep the channel alive.
try testing.expectEqualSlices(u8, &.{ 0, 0 }, rawFrameMsg(800.75, 600, 48, 16));
}
// The key-fallback channel: a bare arm rides the header-only
// envelope, and its cycle dispatches.
{
const msg = shim_core.keyMsg(.{ .key = "space", .shift = false, .control = false, .alt = false, .super = false }) orelse return error.TestUnexpectedResult;
try testing.expect(msg == .cycle);
shim_model = try singleCycle(arena, shim_model, msg);
}
// The pinch channel: a flattened record payload (factor, source
// identity) crosses the envelope and dispatches.
{
const msg = shim_core.pinchMsg(.{ .windowId = 7, .label = "ts-markup-canvas", .phase = .change, .scale = 0.25, .x = 1, .y = 2 }) orelse return error.TestUnexpectedResult;
try testing.expect(msg == .zoomed);
shim_model = try singleCycle(arena, shim_model, msg);
}
}
@@ -1,374 +0,0 @@
//! Behavior parity against a REAL compiled core: the build links a
//! caller-supplied compiled-core archive (NATIVE_SDK_EXTERNAL_CORE_ARCHIVE,
//! one or more link inputs joined by the platform path delimiter, which
//! together must export the markup fixture's attested symbol set under
//! the canonical prefix) into this binary, and the suite drives one
//! scripted message sequence through BOTH lanes:
//!
//! - the transpiler lane: tests/ts-core/markup_fixture.ts emitted by
//! the repo's own transpiler (`ts_core`), and
//! - the compiled-core lane: corewire's generated mirror
//! (`shim_core`) dispatching into the linked archive through the
//! C ABI bindings,
//!
//! comparing every observable byte surface per cycle: the command
//! bytes each dispatch returns, the committed-model snapshot (the
//! archive's raw snapshot bytes against the canonical encoding of the
//! transpiler lane's committed model), and the subscription bytes.
//! The suite also pins the ABI's collect invariant on the archive
//! side: a collect between dispatches leaves the observable snapshot
//! byte-identical.
//!
//! The channel entries ride the bytes envelope ([produced u8][tag u8]
//! [payload…]): the second test drives both lanes' channel functions
//! over gating and producing events — the archive's entries return the
//! envelope, the generated mirror unpacks it — and every produced
//! message dispatches through both lanes as a full cycle.
//!
//! The conformance suite (conformance_tests.zig) proves the two lanes'
//! REFLECTION surfaces identical with no compiled core present; this
//! suite is the executable half, and only builds when a caller
//! supplies the archive — `zig build test` skips it otherwise.
const std = @import("std");
const corewire_rt = @import("corewire_rt");
const core_abi = @import("core_abi");
const convertValue = @import("mirror_value.zig").convertValue;
const ts_core = @import("ts_core");
const shim_core = @import("shim_core");
const abi = core_abi.Bindings("nsc_core_");
const testing = std.testing;
/// The archive's raw committed-model snapshot bytes (result-arena
/// resident: read and compare before any frame reset or collect).
fn rawSnapshot() []const u8 {
var ptr: [*]const u8 = undefined;
var len: usize = 0;
abi.model_snapshot(&ptr, &len);
return ptr[0..len];
}
fn rawSubscriptions() []const u8 {
var ptr: [*]const u8 = undefined;
var len: usize = 0;
abi.subscriptions(&ptr, &len);
return ptr[0..len];
}
fn rawFrameMsg(width: f64, height: f64, timestamp_ms: f64, interval_ms: f64) []const u8 {
var ptr: [*]const u8 = undefined;
var len: usize = 0;
abi.frame_msg(width, height, timestamp_ms, interval_ms, &ptr, &len);
return ptr[0..len];
}
/// The transpiler lane's committed model, re-expressed in the mirror's
/// sidecar-classed layout and canonically encoded — the reference bytes
/// the archive's snapshot must equal.
fn referenceSnapshot(model: *const ts_core.Model, arena: std.mem.Allocator) ![]const u8 {
const converted = try convertValue(shim_core.Model, model, arena);
return corewire_rt.encodeAlloc(shim_core.Model, converted, arena);
}
/// The selection sample follows the supplied sidecar's classes: a
/// signed focus keeps the negative-value coverage, an unsigned one
/// exercises a backward selection (anchor past focus) instead.
const selection_sample = blk: {
const Selection = @FieldType(@FieldType(shim_core.Msg, "draft_edit"), "set_selection");
if (carriesNegatives(@FieldType(Selection, "focus"))) {
break :blk Selection{ .anchor = 1, .focus = -2 };
}
break :blk Selection{ .anchor = 3, .focus = 1 };
};
/// The scripted sequence: every dispatch entry class the fixture's
/// contract declares — bare arms, i64- and f64-classed numbers, bytes,
/// the text-input union (each payload family), and the three record
/// arms — plus the one command-producing arm (`stamp`, whose cycle
/// returns the `now` op's two wire bytes).
const script = [_]shim_core.Msg{
.add,
.add,
.{ .toggle = 2 },
.{ .pick = 2.5 },
.add,
.cycle,
.{ .banner_set = "parity" },
.{ .draft_edit = .{ .insert_text = "hi" } },
.{ .draft_edit = .delete_backward },
.{ .draft_edit = .{ .move_caret = .{ .direction = .next_word, .extend = true } } },
.{ .draft_edit = .{ .set_selection = selection_sample } },
.{ .draft_edit = .{ .set_composition = .{ .text = "ab", .cursor = 1 } } },
.{ .draft_edit = .{ .set_composition = .{ .text = "", .cursor = null } } },
.{ .draft_edit = .clear },
.{ .canvas_resized = 800 },
.{ .zoomed = .{ .factor = 1.25, .windowId = 7, .fromBoard = true } },
.{ .appearance_changed = .{ .colorScheme = .dark, .reduceMotion = false, .highContrast = true } },
.{ .chrome_changed = .{ .insets = .{ .top = 28, .right = 0, .bottom = 0, .left = 0 }, .buttons = .{ .x = 8, .y = 6, .width = 52, .height = 16 }, .tabsProjected = false } },
.stamp,
.{ .stamped = 42.5 },
.{ .toggle = 1 },
.cycle,
.clear,
};
test "a compiled core archive matches the transpiler lane byte-for-byte over the scripted cycle" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// Boot both lanes. The mirror's initialModel runs the full boot
// fence against the archive (identity getters, sink, init); the
// fixture's contract declares no boot command, so the archive's
// boot_cmd buffer must be empty.
var ts_model = ts_core.commitModelRoot(ts_core.initialModel());
var shim_model = shim_core.commitModelRoot(shim_core.initialModel());
{
var boot_ptr: [*]const u8 = undefined;
var boot_len: usize = 0;
abi.boot_cmd(&boot_ptr, &boot_len);
try testing.expectEqualSlices(u8, "", boot_ptr[0..boot_len]);
}
try testing.expectEqualSlices(u8, try referenceSnapshot(ts_model, arena), rawSnapshot());
// The decoded mirror root re-encodes to the same bytes: decode
// fidelity over the boot snapshot.
try testing.expectEqualSlices(u8, rawSnapshot(), corewire_rt.encodeAlloc(shim_core.Model, shim_model.*, arena));
ts_core.rt.frameReset();
shim_core.rt.frameReset();
for (script, 0..) |msg, step| {
// One cycle per lane, the host adapter's ordering: update,
// commit, consume the command bytes, then frame reset.
const ts_msg = try convertValue(ts_core.Msg, msg, arena);
const ts_out = ts_core.update(ts_model, ts_msg);
ts_model = ts_core.commitModelRoot(ts_out.model);
const shim_out = shim_core.update(shim_model, msg);
shim_model = shim_core.commitModelRoot(shim_out.model);
testing.expectEqualSlices(u8, ts_out.cmd, shim_out.cmd) catch |err| {
std.debug.print("command bytes diverge at script step {d} ({s})\n", .{ step, @tagName(msg) });
return err;
};
const reference = try referenceSnapshot(ts_model, arena);
testing.expectEqualSlices(u8, reference, rawSnapshot()) catch |err| {
std.debug.print("snapshot bytes diverge at script step {d} ({s})\n", .{ step, @tagName(msg) });
return err;
};
// Between dispatches, a collect must leave the observable model
// untouched (the ABI's collect invariant), and the fixture's
// contract declares no subscriptions, so the buffer stays empty.
if (step % 3 == 2) {
abi.collect();
testing.expectEqualSlices(u8, reference, rawSnapshot()) catch |err| {
std.debug.print("collect changed the observable snapshot at script step {d} ({s})\n", .{ step, @tagName(msg) });
return err;
};
}
try testing.expectEqualSlices(u8, "", rawSubscriptions());
ts_core.rt.frameReset();
shim_core.rt.frameReset();
}
// Deterministic re-init: a second boot lands both lanes back on the
// boot bytes.
ts_core.rt.resetAll();
shim_core.rt.resetAll();
ts_model = ts_core.commitModelRoot(ts_core.initialModel());
shim_model = shim_core.commitModelRoot(shim_core.initialModel());
try testing.expectEqualSlices(u8, try referenceSnapshot(ts_model, arena), rawSnapshot());
ts_core.rt.frameReset();
shim_core.rt.frameReset();
}
/// One dispatch cycle in both lanes over one channel-produced message:
/// the two lanes' messages must be one value (compared by canonical
/// bytes in the mirror's layout), and the cycle's command and snapshot
/// bytes must match — a channel Msg round-trips through the matching
/// dispatch entry byte-identically.
fn channelParityCycle(
arena: std.mem.Allocator,
ts_model: *const ts_core.Model,
shim_model: *const shim_core.Model,
ts_msg: ts_core.Msg,
shim_msg: shim_core.Msg,
) !struct { ts: *const ts_core.Model, shim: *const shim_core.Model } {
const converted = try convertValue(shim_core.Msg, ts_msg, arena);
try testing.expectEqualSlices(
u8,
corewire_rt.encodeAlloc(shim_core.Msg, converted, arena),
corewire_rt.encodeAlloc(shim_core.Msg, shim_msg, arena),
);
const ts_out = ts_core.update(ts_model, ts_msg);
const next_ts = ts_core.commitModelRoot(ts_out.model);
const shim_out = shim_core.update(shim_model, shim_msg);
const next_shim = shim_core.commitModelRoot(shim_out.model);
try testing.expectEqualSlices(u8, ts_out.cmd, shim_out.cmd);
try testing.expectEqualSlices(u8, try referenceSnapshot(next_ts, arena), rawSnapshot());
ts_core.rt.frameReset();
shim_core.rt.frameReset();
return .{ .ts = next_ts, .shim = next_shim };
}
/// One dispatch cycle in both lanes over one message, comparing the
/// command and snapshot byte surfaces.
fn parityCycle(
arena: std.mem.Allocator,
ts_model: *const ts_core.Model,
shim_model: *const shim_core.Model,
msg: shim_core.Msg,
) !struct { ts: *const ts_core.Model, shim: *const shim_core.Model } {
const ts_msg = try convertValue(ts_core.Msg, msg, arena);
const ts_out = ts_core.update(ts_model, ts_msg);
const next_ts = ts_core.commitModelRoot(ts_out.model);
const shim_out = shim_core.update(shim_model, msg);
const next_shim = shim_core.commitModelRoot(shim_out.model);
try testing.expectEqualSlices(u8, ts_out.cmd, shim_out.cmd);
try testing.expectEqualSlices(u8, try referenceSnapshot(next_ts, arena), rawSnapshot());
ts_core.rt.frameReset();
shim_core.rt.frameReset();
return .{ .ts = next_ts, .shim = next_shim };
}
test "integer-classed slots cross the compiler-provable extremes exactly" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// A fresh boot in both lanes. When the archive rides a
// compiler-emitted sidecar with a populated integer_slots section,
// this test is the executable proof of the attested classes: the
// i64-classed arms carry the +-(2^53 - 1) extremes through a real
// dispatch, the archive's snapshot bytes must equal the canonical
// encoding of the transpiler lane's model, and the generated
// mirror's decode of those bytes must hold the exact integers.
ts_core.rt.resetAll();
shim_core.rt.resetAll();
var ts_model = ts_core.commitModelRoot(ts_core.initialModel());
var shim_model = shim_core.commitModelRoot(shim_core.initialModel());
ts_core.rt.frameReset();
shim_core.rt.frameReset();
// The extremes follow each slot's class in the supplied sidecar: a
// signed slot (i64-attested, or f64 in a sidecar predating integer
// attestation) also crosses the negative extreme; a u64-attested
// one stays within its unsigned range. Every crossing value sits
// within +-(2^53 - 1), so each class carries it exactly.
const max_exact = 9007199254740991; // 2^53 - 1
const boundary_script = comptime blk: {
var msgs: []const shim_core.Msg = &.{
.{ .canvas_resized = max_exact },
.{ .toggle = max_exact },
};
if (carriesNegatives(@FieldType(shim_core.Msg, "toggle"))) {
msgs = msgs ++ [_]shim_core.Msg{.{ .toggle = -max_exact }};
}
if (carriesNegatives(@FieldType(shim_core.Msg, "canvas_resized"))) {
msgs = msgs ++ [_]shim_core.Msg{.{ .canvas_resized = -max_exact }};
}
msgs = msgs ++ [_]shim_core.Msg{.{ .canvas_resized = 0 }};
break :blk msgs;
};
inline for (boundary_script, 0..) |msg, step| {
const next = parityCycle(arena, ts_model, shim_model, msg) catch |err| {
std.debug.print("integer boundary parity diverges at step {d} ({s})\n", .{ step, @tagName(msg) });
return err;
};
ts_model = next.ts;
shim_model = next.shim;
// The mirror decoded the committed snapshot: its numeric slot
// holds the exact crossing value (compared class-agnostically —
// the arm and the field each follow their own class, and every
// crossing value is f64-exact).
switch (msg) {
.canvas_resized => |value| try testing.expectEqual(exactValue(value), exactValue(shim_model.canvasWidth)),
else => {},
}
}
}
/// Whether a mirror slot's class carries negative values: signed
/// integers and f64 do; the unsigned class does not.
fn carriesNegatives(comptime T: type) bool {
return switch (@typeInfo(T)) {
.int => |info| info.signedness == .signed,
.float => true,
else => @compileError("not a numeric mirror slot: " ++ @typeName(T)),
};
}
/// A class-agnostic exact comparison value: every crossing this suite
/// drives sits within +-(2^53 - 1), where f64 carries integers exactly.
fn exactValue(value: anytype) f64 {
return switch (@typeInfo(@TypeOf(value))) {
.int => @floatFromInt(value),
.float => value,
else => @compileError("not a numeric mirror slot: " ++ @typeName(@TypeOf(value))),
};
}
test "channel entries match the transpiler lane through the bytes envelope" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// A fresh boot in both lanes (init is the deterministic re-init
// seam, so this test stands alone).
ts_core.rt.resetAll();
shim_core.rt.resetAll();
var ts_model = ts_core.commitModelRoot(ts_core.initialModel());
var shim_model = shim_core.commitModelRoot(shim_core.initialModel());
ts_core.rt.frameReset();
shim_core.rt.frameReset();
// Gating parity: a chorded key, an unchanged frame, and a begin
// pinch produce nothing in either lane (the archive's entry returns
// the two-byte nothing-produced envelope).
try testing.expect(ts_core.keyMsg(.{ .key = "space", .shift = false, .control = true, .alt = false, .super = false }) == null);
try testing.expect(shim_core.keyMsg(.{ .key = "space", .shift = false, .control = true, .alt = false, .super = false }) == null);
try testing.expect(ts_core.frameMsg(ts_model, .{ .width = 0, .height = 600, .timestampMs = 16, .intervalMs = 16 }) == null);
try testing.expect(shim_core.frameMsg(shim_model, .{ .width = 0, .height = 600, .timestampMs = 16, .intervalMs = 16 }) == null);
try testing.expect(ts_core.pinchMsg(.{ .windowId = 7, .label = "ts-markup-canvas", .phase = .begin, .scale = 0, .x = 1, .y = 2 }) == null);
try testing.expect(shim_core.pinchMsg(.{ .windowId = 7, .label = "ts-markup-canvas", .phase = .begin, .scale = 0, .x = 1, .y = 2 }) == null);
// The presented-frame channel produces the resize at boot width,
// and the dispatched cycle updates the model so the same frame then
// gates (the idle law, proven across the round trip).
{
const ts_msg = ts_core.frameMsg(ts_model, .{ .width = 800, .height = 600, .timestampMs = 16, .intervalMs = 16 }) orelse return error.TestUnexpectedResult;
const shim_msg = shim_core.frameMsg(shim_model, .{ .width = 800, .height = 600, .timestampMs = 16, .intervalMs = 16 }) orelse return error.TestUnexpectedResult;
const next = try channelParityCycle(arena, ts_model, shim_model, ts_msg, shim_msg);
ts_model = next.ts;
shim_model = next.shim;
try testing.expect(ts_core.frameMsg(ts_model, .{ .width = 800, .height = 600, .timestampMs = 32, .intervalMs = 16 }) == null);
try testing.expect(shim_core.frameMsg(shim_model, .{ .width = 800, .height = 600, .timestampMs = 32, .intervalMs = 16 }) == null);
// The raw archive entry receives f64 logical points before the
// generated mirror narrows its classed width. Compare after
// truncation so a fractional presentation at the committed
// width does not keep the channel alive.
try testing.expectEqualSlices(u8, &.{ 0, 0 }, rawFrameMsg(800.75, 600, 48, 16));
}
// The key-fallback channel: a bare arm rides the header-only
// envelope.
{
const ts_msg = ts_core.keyMsg(.{ .key = "space", .shift = false, .control = false, .alt = false, .super = false }) orelse return error.TestUnexpectedResult;
const shim_msg = shim_core.keyMsg(.{ .key = "space", .shift = false, .control = false, .alt = false, .super = false }) orelse return error.TestUnexpectedResult;
const next = try channelParityCycle(arena, ts_model, shim_model, ts_msg, shim_msg);
ts_model = next.ts;
shim_model = next.shim;
}
// The pinch channel: a flattened record payload (factor, source
// identity) crosses the envelope and dispatches.
{
const ts_msg = ts_core.pinchMsg(.{ .windowId = 7, .label = "ts-markup-canvas", .phase = .change, .scale = 0.25, .x = 1, .y = 2 }) orelse return error.TestUnexpectedResult;
const shim_msg = shim_core.pinchMsg(.{ .windowId = 7, .label = "ts-markup-canvas", .phase = .change, .scale = 0.25, .x = 1, .y = 2 }) orelse return error.TestUnexpectedResult;
const next = try channelParityCycle(arena, ts_model, shim_model, ts_msg, shim_msg);
ts_model = next.ts;
shim_model = next.shim;
}
}
-80
View File
@@ -1,80 +0,0 @@
//! Value conversion between structurally-equivalent mirror types —
//! shared by the sidecar suites: the conformance suite converts
//! transpiled-module values into sidecar-classed shim layouts for the
//! facade parity axis, and the compiled-core behavior-parity suite
//! converts scripted messages and reference models the same way.
const std = @import("std");
/// Convert a value between two structurally-equivalent mirror types by
/// field/arm/member NAME, normalizing numeric classes and reference
/// storage (pointers deref on read, re-materialize on write).
pub fn convertValue(comptime Target: type, value: anytype, allocator: std.mem.Allocator) !Target {
// The walk is a comptime recursion over a whole model or message
// type; the corpus's widest ones (dozens of fields, a fifty-arm
// union) run past the default branch budget.
@setEvalBranchQuota(200_000);
const Source = @TypeOf(value);
if (@typeInfo(Source) == .pointer and @typeInfo(Source).pointer.size == .one) {
return convertValue(Target, value.*, allocator);
}
switch (@typeInfo(Target)) {
.bool => return value,
.int => return switch (@typeInfo(Source)) {
.int => @intCast(value),
.float => @intFromFloat(value),
else => @compileError("cannot convert " ++ @typeName(Source) ++ " to " ++ @typeName(Target)),
},
.float => return switch (@typeInfo(Source)) {
.float => @floatCast(value),
.int => @floatFromInt(value),
else => @compileError("cannot convert " ++ @typeName(Source) ++ " to " ++ @typeName(Target)),
},
.@"enum" => {
switch (value) {
inline else => |tag| return @field(Target, @tagName(tag)),
}
},
.optional => |info| {
if (value) |inner| return try convertValue(info.child, inner, allocator);
return null;
},
.pointer => |info| switch (info.size) {
.slice => {
if (info.child == u8) return allocator.dupe(u8, value);
const out = try allocator.alloc(info.child, value.len);
for (out, value) |*slot, element| {
slot.* = try convertValue(info.child, element, allocator);
}
return out;
},
.one => {
const out = try allocator.create(info.child);
out.* = try convertValue(info.child, value, allocator);
return out;
},
else => @compileError("no conversion for " ++ @typeName(Target)),
},
.@"struct" => |info| {
var out: Target = undefined;
inline for (info.fields) |field| {
@field(out, field.name) = try convertValue(field.type, @field(value, field.name), allocator);
}
return out;
},
.@"union" => |info| {
switch (value) {
inline else => |payload, tag| {
inline for (info.fields) |field| {
if (comptime std.mem.eql(u8, field.name, @tagName(tag))) {
if (field.type == void) return @unionInit(Target, field.name, {});
return @unionInit(Target, field.name, try convertValue(field.type, payload, allocator));
}
}
unreachable;
},
}
},
else => @compileError("no conversion for " ++ @typeName(Target)),
}
}
+12 -4
View File
@@ -1,7 +1,7 @@
//! End-to-end proof battery for examples/ai-chat-ts — the "can I call an
//! AI API?" answer as a real app: a chat client for an OpenAI-compatible
//! chat-completions endpoint authored in TypeScript + Native markup with
//! ZERO hand-written Zig. The build transpiles the example's REAL core
//! ZERO hand-written Zig. The build compiles the example's REAL core through the external core compiler
//! (examples/ai-chat-ts/src/core.ts + src/api.ts) and this suite drives
//! it through `TsUiApp` with the example's SHIPPING markup (app.native,
//! staged beside this file), so every pin here is the product path:
@@ -304,7 +304,7 @@ test "the teaching state holds until every launch variable arrives - and issues
try std.testing.expect(h.hasText(test_model_name));
try h.menu("chat.send");
try std.testing.expectEqual(@as(usize, 0), h.app_state.effects.pendingFetchCount());
try std.testing.expect(core.unconfigured(Bridge.model()));
try std.testing.expect(Bridge.model().unconfigured());
}
}
@@ -382,7 +382,11 @@ test "a scripted conversation pins the exact request bytes, the parse, and the h
try std.testing.expect(Bridge.model().turns[0].role == .user);
try std.testing.expectEqualStrings("Say hi in two words", Bridge.model().turns[0].text);
try std.testing.expect(Bridge.model().phase == .sending);
try std.testing.expectEqual(@as(usize, 0), core.draftText(Bridge.model()).len);
{
var draft_arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer draft_arena.deinit();
try std.testing.expectEqual(@as(usize, 0), Bridge.model().draftText(draft_arena.allocator()).len);
}
try std.testing.expect(h.hasText("waiting for the model"));
// The endpoint answers; the reply parses out of choices[0] (escapes
@@ -427,7 +431,11 @@ test "the in-flight guard: a second send issues nothing and loses nothing" {
try h.menu("chat.send");
try std.testing.expectEqual(@as(usize, 1), fx.pendingFetchCount());
try std.testing.expectEqual(@as(usize, 1), Bridge.model().turns.len);
try std.testing.expectEqualStrings("eager follow-up", core.draftText(Bridge.model()));
{
var draft_arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer draft_arena.deinit();
try std.testing.expectEqualStrings("eager follow-up", Bridge.model().draftText(draft_arena.allocator()));
}
// The reply lands and the guard lifts: the surviving draft sends
// through the journaled command path.
+14 -15
View File
@@ -1,6 +1,7 @@
//! End-to-end: a GENUINELY TRANSPILED core (tests/ts-core/fixture.ts,
//! emitted by the repo's own transpiler at build time — see the
//! ts-core-e2e wiring in build.zig) driven through the real
//! End-to-end: a GENUINELY COMPILED core (tests/ts-core/fixture.ts,
//! built by the external core compiler at build time and reached
//! through its generated mirror — see the ts-core-e2e wiring in
//! build.zig) driven through the real
//! runtime-core dispatch path: the first-class `TsUiApp(core)` adapter
//! (the committed TS model IS the app model — the view below reads it
//! straight off the UiApp), the null platform's live timer services, a
@@ -13,7 +14,9 @@
//! to identical state without a host call or a process launch.
//!
//! The markup-view / automation / pixel-fingerprint guarantees run in
//! markup_e2e_tests.zig over a second transpiled core.
//! markup_e2e_tests.zig over the markup fixture's core — its own
//! binary: the compiled-core symbol set is a fixed-prefix C ABI, so
//! one process carries ONE archive.
const std = @import("std");
const builtin = @import("builtin");
@@ -26,10 +29,6 @@ const Adapter = native_sdk.TsUiApp(fixture);
/// assertions may read the committed model straight off the bridge.
const Bridge = Adapter.Host;
test {
_ = @import("markup_e2e_tests.zig");
}
const canvas_label = "ts-core-canvas";
const e2e_views = [_]native_sdk.ShellView{
@@ -49,7 +48,7 @@ const App = Adapter.App;
/// A hand-written builder view over the COMMITTED TS MODEL — the model
/// parameter is the UiApp-held root the adapter refreshes each
/// dispatch, so this view (and the replay fingerprints derived from
/// what it renders) pins the transpiled core's state directly.
/// what it renders) pins the compiled core's state directly.
fn e2eView(ui: *App.Ui, model: *const fixture.Model) App.Ui.Node {
return ui.column(.{ .gap = 4, .padding = 8 }, .{
ui.text(.{}, ui.fmt("ticks {d} failures {d}", .{ model.ticks, model.failures })),
@@ -342,7 +341,7 @@ const Harness = struct {
}
};
test "the transpiled core boots through init_fx: boot request and subscription timer are live" {
test "the compiled core boots through init_fx: boot request and subscription timer are live" {
HostStub.reset();
const h = try Harness.create();
defer h.destroy();
@@ -447,7 +446,7 @@ test "Cmd.now stamps synchronously and host_bytes reaches the stub service" {
// -------------------------------------------------- named engine ops
test "writeFile and readFile round-trip real disk through the transpiled core" {
test "writeFile and readFile round-trip real disk through the compiled core" {
const io = std.testing.io;
HostStub.reset();
removeStore();
@@ -642,7 +641,7 @@ test "cancelling a spawn mid-stream ends the real child and routes the err arm"
try std.testing.expectEqual(@as(@TypeOf(Bridge.model().exitCode), -1), Bridge.model().exitCode);
}
test "audio playback streams events into the transpiled core through the fake channel" {
test "audio playback streams events into the compiled core through the fake channel" {
HostStub.reset();
const h = try Harness.createFake();
defer h.destroy();
@@ -688,7 +687,7 @@ test "audio playback streams events into the transpiled core through the fake ch
try std.testing.expectEqual(@as(@TypeOf(Bridge.model().audioEvents), 3), Bridge.model().audioEvents);
}
test "video playback streams events into the transpiled core through the fake channel" {
test "video playback streams events into the compiled core through the fake channel" {
HostStub.reset();
const h = try Harness.createFake();
defer h.destroy();
@@ -731,7 +730,7 @@ test "video playback streams events into the transpiled core through the fake ch
try std.testing.expectEqual(@as(@TypeOf(Bridge.model().videoEvents), 2), Bridge.model().videoEvents);
}
test "image loads route their one terminal into the transpiled core through the fake channel" {
test "image loads route their one terminal into the compiled core through the fake channel" {
HostStub.reset();
const h = try Harness.createFake();
defer h.destroy();
@@ -1391,7 +1390,7 @@ fn recordSession(buffer: *JournalBuffer) !CoreSnapshot {
return CoreSnapshot.take();
}
test "a recorded transpiled-core session replays byte-identically with no host calls" {
test "a recorded compiled-core session replays byte-identically with no host calls" {
const buffer = try std.heap.page_allocator.create(JournalBuffer);
defer std.heap.page_allocator.destroy(buffer);
buffer.len = 0;
+19 -138
View File
@@ -1,13 +1,15 @@
//! End-to-end: a `.native` MARKUP VIEW over a genuinely transpiled core
//! (tests/ts-core/markup_fixture.ts + markup_view.native), through the
//! first-class `TsUiApp(core)` adapter — the committed TS model is the
//! app model and markup binds its emitted fields directly: a record
//! array through `for each` + `key`, an optional scalar through `<if>`,
//! a string-literal-union filter as an enum binding, bytes text, and
//! camelCase TS fields bound by their own names — the emitted struct keeps the TS spellings.
//! End-to-end: a `.native` MARKUP VIEW over a genuinely compiled core
//! (tests/ts-core/markup_fixture.ts + markup_view.native, the core
//! built by the external core compiler and reached through its
//! generated mirror), through the first-class `TsUiApp(core)` adapter
//! — the committed TS model is the app model and markup binds its
//! fields directly: a record array through `for each` + `key`, an
//! optional scalar through `<if>`, a string-literal-union filter as an
//! enum binding, bytes text, and camelCase TS fields bound by their own
//! names — the mirror struct keeps the TS spellings.
//!
//! On top of the view, the round's platform guarantees run through the
//! transpiled app unchanged:
//! compiled app unchanged:
//! - automation: headless widget verbs, the a11y snapshot, and
//! published screenshot artifacts (byte-identical on an unchanged
//! scene);
@@ -15,16 +17,12 @@
//! and raw pointer events on markup buttons) plus a `Cmd.now`
//! effect records byte-identically twice, replays with matching
//! state fingerprints, verified checkpoints, and verified PIXEL
//! screenshot marks, and never calls a host;
//! - process contract: two different transpiled cores run live side
//! by side (each staged core owns its rt kernel instance) — the
//! one-live-app-per-core-module contract, pinned from both sides.
//! screenshot marks, and never calls a host.
const std = @import("std");
const builtin = @import("builtin");
const native_sdk = @import("native_sdk");
const board = @import("ts_markup_fixture");
const status_core = @import("ts_core_fixture");
const runtime_ns = native_sdk.runtime;
const canvas = native_sdk.canvas;
@@ -245,7 +243,7 @@ fn findTextIn(widget: canvas.Widget, text: []const u8) bool {
// ------------------------------------------------------- markup binding
test "markup binds the transpiled model: lists, optionals, enums, and the TS field names" {
test "markup binds the compiled core's model: lists, optionals, enums, and the TS field names" {
const h = try Harness.create();
defer h.destroy();
@@ -298,7 +296,7 @@ test "markup binds the transpiled model: lists, optionals, enums, and the TS fie
try std.testing.expect(!h.hasText("picked"));
}
test "markup hover bindings drive the transpiled core: enter and leave with row payloads" {
test "markup hover bindings drive the compiled core: enter and leave with row payloads" {
const h = try Harness.create();
defer h.destroy();
@@ -310,7 +308,7 @@ test "markup hover bindings drive the transpiled core: enter and leave with row
// A raw pointer move over the first row's TEXT: containment falls
// through the plain child to the listening row, and the enter Msg
// carries the row's `for each` payload into the transpiled core.
// carries the row's `for each` payload into the compiled core.
const first_row_text = h.findId(.text, "beta #1").?;
try h.pointerMove(try h.aim(first_row_text), 4_000_000);
try std.testing.expectEqual(@as(f64, 1), Bridge.model().hoveredId);
@@ -383,7 +381,7 @@ fn collectTexts(widget: canvas.Widget, out: *std.ArrayListUnmanaged(u8), allocat
}
}
test "markup text input reaches the transpiled core and re-renders the view" {
test "markup text input reaches the compiled core and re-renders the view" {
const h = try Harness.create();
defer h.destroy();
@@ -438,7 +436,7 @@ test "markup text input reaches the transpiled core and re-renders the view" {
try std.testing.expect(h.findId(.text_field, "hi ther") != null);
}
test "automation set_text drives a transpiled-core text field (select-all sentinel translates)" {
test "automation set_text drives a compiled-core text field (select-all sentinel translates)" {
const h = try Harness.create();
defer h.destroy();
@@ -459,7 +457,7 @@ test "automation set_text drives a transpiled-core text field (select-all sentin
// `set_selection` carrying the `focus = maxInt(usize)` "to the end"
// sentinel, and the declared-union translation must SATURATE it into
// the core's i64 field class — @intCast here panicked "integer does
// not fit in destination type" on every transpiled-core text field
// not fit in destination type" on every compiled-core text field
// (the live-GUI smoke's soundboard-ts search crash).
var buffer: [96]u8 = undefined;
const command = try std.fmt.bufPrint(&buffer, "widget-action {s} {d} set-text yo", .{ canvas_label, field });
@@ -577,7 +575,7 @@ test "the wiring channels drive the core: frame, key, appearance, and chrome" {
try std.testing.expect(Bridge.model().zoomFromBoard);
// The automation pinch verb dispatches the same real events into the
// transpiled core: one gesture whose single change carries scale - 1
// compiled core: one gesture whose single change carries scale - 1
// (the verb's <scale> is the FINAL multiplicative zoom).
var pinch_buffer: [96]u8 = undefined;
const pinch = try std.fmt.bufPrint(&pinch_buffer, "widget-pinch {s} 2", .{canvas_label});
@@ -614,7 +612,7 @@ test "boot images register and launch env overrides dispatch at install" {
// ----------------------------------------------------------- automation
test "the automation surface drives the transpiled markup app headlessly" {
test "the automation surface drives the compiled markup app headlessly" {
const directory = ".zig-cache/tmp/ts-markup-automation";
std.Io.Dir.cwd().deleteTree(std.testing.io, directory) catch {};
defer std.Io.Dir.cwd().deleteTree(std.testing.io, directory) catch {};
@@ -901,120 +899,3 @@ test "a recorded tooltip hover dwell replays its show and hide frames byte-ident
try std.testing.expect(report.checkpoints_verified > 0);
try std.testing.expectEqual(fingerprint, harness.runtime.sessionStateFingerprint());
}
// ------------------------------------------------- two live cores
/// A minimal host stub for the status core's boot request (the markup
/// core performs no host calls).
const CoexistStub = struct {
var request_count: usize = 0;
var context: u8 = 0;
fn send(ctx: *anyopaque, name: []const u8, payload: []const u8) void {
_ = ctx;
_ = name;
_ = payload;
}
fn request(ctx: *anyopaque, name: []const u8, key: u64, payload: []const u8) void {
_ = ctx;
_ = name;
_ = key;
_ = payload;
request_count += 1;
}
fn cancelNotice(ctx: *anyopaque, key: u64) void {
_ = ctx;
_ = key;
}
fn binding() native_sdk.HostCallBinding {
return .{ .context = @ptrCast(&context), .send_fn = send, .request_fn = request, .cancel_fn = cancelNotice };
}
};
const StatusAdapter = native_sdk.TsUiApp(status_core);
const StatusApp = StatusAdapter.App;
const status_canvas_label = "ts-core-canvas";
const status_views = [_]native_sdk.ShellView{
.{ .label = status_canvas_label, .kind = .gpu_surface, .fill = true, .gpu_backend = .metal },
};
const status_windows = [_]native_sdk.ShellWindow{.{
.label = "main",
.title = "TS Core",
.width = 400,
.height = 300,
.views = &status_views,
}};
const status_scene: native_sdk.ShellConfig = .{ .windows = &status_windows };
fn statusView(ui: *StatusApp.Ui, model: *const status_core.Model) StatusApp.Ui.Node {
return ui.column(.{ .gap = 4, .padding = 8 }, .{
ui.text(.{}, ui.fmt("ticks {d}", .{model.ticks})),
});
}
fn statusCommand(name: []const u8) ?status_core.Msg {
if (std.mem.eql(u8, name, "core.stamp")) return .stamp;
if (std.mem.eql(u8, name, "core.toggle")) return .toggle;
return null;
}
test "two live transpiled cores coexist: each staged core owns its kernel and committed root" {
// The markup board app...
const h = try Harness.create();
defer h.destroy();
// ...and the status-poller app from the OTHER emitted core, live in
// the same process at the same time.
CoexistStub.request_count = 0;
var status_clock: native_sdk.TestClock = .{};
status_clock.setWallMs(90_000);
const status_harness = try native_sdk.TestHarness().create(std.testing.allocator, .{
.size = geometry.SizeF.init(400, 300),
});
defer status_harness.destroy(std.testing.allocator);
status_harness.null_platform.gpu_surfaces = true;
const status_state = try std.testing.allocator.create(StatusApp);
defer std.testing.allocator.destroy(status_state);
status_state.* = StatusAdapter.init(std.heap.page_allocator, .{}, .{
.name = "ts-core-coexist",
.scene = status_scene,
.canvas_label = status_canvas_label,
.view = statusView,
.on_command = statusCommand,
});
defer status_state.deinit();
status_state.effects.bindHostCalls(CoexistStub.binding());
status_state.effects.clock = status_clock.clock();
const status_app = status_state.app();
try status_harness.start(status_app);
try status_harness.runtime.dispatchPlatformEvent(status_app, .{ .gpu_surface_frame = .{
.label = status_canvas_label,
.size = geometry.SizeF.init(400, 300),
.scale_factor = 1,
.frame_index = 1,
.timestamp_ns = 1_000_000,
} });
try std.testing.expect(status_state.installed);
try std.testing.expectEqual(@as(usize, 1), CoexistStub.request_count);
// Interleaved dispatches: each core's committed model progresses
// independently — no shared frame arena, no shared heap, no shared
// bridge tables.
try h.click(h.findId(.button, "Add").?);
try status_harness.runtime.dispatchPlatformEvent(status_app, .{ .menu_command = .{ .name = "core.stamp", .window_id = 1 } });
try h.click(h.findId(.button, "Add").?);
try status_harness.runtime.dispatchPlatformEvent(status_app, .{ .menu_command = .{ .name = "core.toggle", .window_id = 1 } });
try std.testing.expectEqual(@as(usize, 2), Bridge.model().tasks.len);
try std.testing.expectEqualStrings("beta", Bridge.model().tasks[0].title);
try std.testing.expectEqual(@as(f64, 90_000), StatusAdapter.Host.model().stampMs);
try std.testing.expect(!StatusAdapter.Host.model().polling);
// And the markup app still renders its own core's state.
try std.testing.expect(h.hasText("beta #1"));
try std.testing.expect(h.hasText("gamma #2"));
}
+6 -9
View File
@@ -1,5 +1,5 @@
//! End-to-end: the stock-IDE contract of TypeScript apps, proved with the
//! REAL tsc (the repo's own @typescript/typescript6 install) and zero
//! REAL tsc (the repo's own pinned @typescript/old install) and zero
//! injected paths — exactly the view VS Code's TypeScript service has.
//!
//! Both directions of the independence contract are pinned:
@@ -7,8 +7,8 @@
//! example port) typechecks through nothing but its own tsconfig.json
//! and the materialized node_modules/@native-sdk/core copy, resolving
//! `@native-sdk/core` AND the `@native-sdk/core/text` subpath;
//! - build truth: with node_modules deleted, the transpiler (the build's
//! checker + emitter) still runs the same core clean — builds never
//! - build truth: with node_modules deleted, the frontend (the build's
//! check-only pass) still takes the same core clean — builds never
//! read the editor surface — and the ensure hook check/dev/build run
//! re-materializes the copy for the editor.
//!
@@ -18,7 +18,7 @@
const std = @import("std");
const tooling = @import("tooling");
const tsc_js = "packages/core/node_modules/@typescript/typescript6/lib/tsc.js";
const tsc_js = "packages/core/node_modules/@typescript/old/lib/tsc.js";
/// The editor view: real tsc over the app's own tsconfig, no flags beyond
/// the project pointer. Non-zero exit prints tsc's diagnostics verbatim.
@@ -84,16 +84,13 @@ test "a fresh ts scaffold typechecks under stock tsc, and builds never need the
// paths — `@native-sdk/core` resolves through node_modules alone.
try expectTscClean(allocator, io, root);
// Build truth: delete the editor surface entirely; the transpiler (the
// exact checker + emitter every build runs) still takes the core clean.
// Build truth: delete the editor surface entirely; the frontend (the
// exact check-only pass every build runs) still takes the core clean.
try cwd.deleteTree(io, root ++ "/node_modules");
try cwd.createDirPath(io, ".zig-cache/e2e-ide-scaffold-out");
try runExpectZero(allocator, io, &.{
"node",
"packages/core/src/cli.ts",
root ++ "/src/core.ts",
"-o",
".zig-cache/e2e-ide-scaffold-out/core.zig",
});
// The self-heal hook check/dev/build run puts the copy back current.
+14 -14
View File
@@ -1,6 +1,6 @@
//! End-to-end proof battery for examples/soundboard-ts — the launch-gate
//! port: a soundboard-complexity app authored in TypeScript + Native
//! markup with ZERO hand-written Zig. The build transpiles the example's
//! markup with ZERO hand-written Zig. The build compiles the example's
//! REAL core (examples/soundboard-ts/src/core.ts) and this suite drives
//! it through `TsUiApp` with the example's SHIPPING markup
//! (app.native, staged beside this file), so every pin here is the
@@ -564,7 +564,7 @@ test "Escape clears the search field and the TS core hears it" {
// runtime-local editor operation — the field emptied on screen while
// the core's search mirror kept the stale term and the list stayed
// filtered. The keyboard derivation now stamps the clear it applies
// onto the dispatched event, so the transpiled core hears it through
// onto the dispatched event, so the compiled core hears it through
// the same on-input channel every keystroke uses.
const h = try Harness.create();
defer h.destroy();
@@ -764,14 +764,13 @@ test "dispatch at the rendered-clock cadence stays far under the frame budget" {
// The core alone (update + commit + command walk + subscription
// reconcile), without the runtime pipeline around it - the cost the
// transpiled tier adds per tick.
// TypeScript tier adds per tick. Every dispatch crosses the C ABI
// into the compiled core and decodes the committed-model snapshot
// through the mirror (the wire codec is the tier's real per-tick
// price), so the pin is microseconds-class, not the sub-microsecond
// number an in-process core would post.
const fx = &h.app_state.effects;
// The core-only budget describes ONE core's dispatch. Under the
// paired-lanes module every dispatch runs the transpiled core AND the
// compiled archive and byte-compares them at each seam, so that
// configuration gets half the frame budget instead; the single-lane
// run of this suite keeps the strict microseconds-class pin.
const core_budget_ns: u64 = if (@hasDecl(core, "paired_lanes")) 8_000_000 else 1_000_000;
const core_budget_ns: u64 = 1_000_000;
var per_core_dispatch_ns: u64 = std.math.maxInt(u64);
for (0..perf_attempts) |_| {
const core_start_ns = runtime_ns.monotonicNanoseconds();
@@ -787,8 +786,9 @@ test "dispatch at the rendered-clock cadence stays far under the frame budget" {
// Every whole-pipeline dispatch (update + commit + effects + the full
// 68-row markup rebuild) must fit ONE 60Hz frame budget even in a
// Debug build on loaded CI hardware; the core alone must be
// microseconds. Measured on an M-class laptop (Debug): ~3.3ms whole
// pipeline, ~450ns core-only.
// microseconds. Measured on an M-class laptop (Debug): ~3.7ms whole
// pipeline, ~5.3us core-only (the C ABI crossing plus the
// committed-model snapshot decode dominate the core-only number).
if (per_dispatch_ns >= 16_000_000 or per_core_dispatch_ns >= core_budget_ns) {
std.debug.print("dispatch budget exceeded: whole-pipeline {d}ns (budget 16000000), core-only {d}ns (budget {d}), best of {d} attempts\n", .{ per_dispatch_ns, per_core_dispatch_ns, core_budget_ns, perf_attempts });
}
@@ -896,7 +896,7 @@ test "a search narrowed below the column count keeps natural tile size" {
.frame_index = 2,
.timestamp_ns = 2_000_000,
} });
const full_tile = core.gridTileWidth(Bridge.model());
const full_tile = Bridge.model().gridTileWidth();
// Search down to ONE matching album: the grid narrows to one shown
// column and its exact one-tile width, so the lone cover keeps the
@@ -1177,8 +1177,8 @@ test "render parity screenshots (env-gated)" {
const sizes = [_]geometry.SizeF{ geometry.SizeF.init(1080, 720), geometry.SizeF.init(1400, 800) };
const cover_stems = [_][]const u8{
"exit-signs", "blue-season", "second-nature", "no-good-way-out",
"glass-flowers", "night-bloom", "motion-picture", "channel-surfing",
"exit-signs", "blue-season", "second-nature", "no-good-way-out",
"glass-flowers", "night-bloom", "motion-picture", "channel-surfing",
};
for (sizes, 0..) |size, size_index| {
const h = try Harness.create();
+1 -1
View File
@@ -1,6 +1,6 @@
//! End-to-end proof battery for examples/system-monitor-ts — the second
//! port: the spawn-showcase app authored in TypeScript + Native markup
//! with ZERO hand-written Zig. The build transpiles the example's REAL
//! with ZERO hand-written Zig. The build compiles the example's REAL
//! core (examples/system-monitor-ts/src/core.ts) and this suite drives it
//! through `TsUiApp` with the example's SHIPPING markup (app.native,
//! staged beside this file) on the FAKE effects executor, so every spawn
+34 -4
View File
@@ -1092,7 +1092,21 @@ const FacadeEmitter = struct {
\\// init_returns_cmd/update_returns_cmd flags.
\\
);
if (self.sidecar.init_returns_cmd) {
if (self.sidecar.init_returns_cmd and self.sidecar.init_returns_bare) {
self.use(.cmd_encoder);
// The mixed author shape (`Model | [Model, Cmd<Msg>]`, the
// documented idiom): the wrapper narrows — a tuple carries
// its command, a bare model the empty command buffer.
try self.print(
\\
\\export function init(): [{s}, Uint8Array] {{
\\ const out = nscfInitialModel();
\\ if (Array.isArray(out)) return [out[0], nscfCmdBytes(out[1])];
\\ return [out, new Uint8Array(0)];
\\}}
\\
, .{self.sidecar.model});
} else if (self.sidecar.init_returns_cmd) {
self.use(.cmd_encoder);
try self.print(
\\
@@ -1140,7 +1154,21 @@ const FacadeEmitter = struct {
\\}
\\
);
if (self.sidecar.update_returns_cmd) {
if (self.sidecar.update_returns_cmd and self.sidecar.update_returns_bare) {
self.use(.cmd_encoder);
// The mixed author shape (`Model | [Model, Cmd<Msg>]`, the
// documented idiom): the wrapper narrows — a tuple carries
// its command, a bare model the empty command buffer.
try self.print(
\\
\\export function coreUpdate(model: {s}, msg: {s}): [{s}, Uint8Array] {{
\\ const out = nscfUpdate(model, msg);
\\ if (Array.isArray(out)) return [out[0], nscfCmdBytes(out[1])];
\\ return [out, new Uint8Array(0)];
\\}}
\\
, .{ self.sidecar.model, self.sidecar.msg.name, self.sidecar.model });
} else if (self.sidecar.update_returns_cmd) {
self.use(.cmd_encoder);
try self.print(
\\
@@ -1184,7 +1212,9 @@ const FacadeEmitter = struct {
\\
);
if (self.sidecar.init_returns_cmd) {
try self.print("const nscfBootPair = nscfInitialModel();\nlet nscfCommitted: {s} = nscfBootPair[0];\n", .{self.sidecar.model});
// Boot through the normalized init wrapper: the boot pair's
// command already rides as bytes.
try self.print("const nscfBootPair = init();\nlet nscfCommitted: {s} = nscfBootPair[0];\n", .{self.sidecar.model});
} else {
try self.print("let nscfCommitted: {s} = nscfInitialModel();\n", .{self.sidecar.model});
}
@@ -1218,7 +1248,7 @@ const FacadeEmitter = struct {
try self.raw(
\\
\\export function boot_cmd(): Uint8Array {
\\ return nscfCmdBytes(nscfBootPair[1]);
\\ return nscfBootPair[1];
\\}
\\
);
-750
View File
@@ -1,750 +0,0 @@
//! Sidecar extraction from a compiled-at-comptime core module: reflect
//! a transpiler-emitted core's Model/Msg/helpers/channels into a
//! contract sidecar (core.contract.json, schema format 1).
//!
//! This is the conformance harness's fixture-scale producer. The
//! comparison loop is: transpiled types -> (this extractor) -> sidecar
//! -> (corewire) -> mirror types -> compared against the SAME
//! transpiled types by layout fingerprint and model-contract artifact.
//! Any infidelity in either hop lands in the comparison, because the
//! reference side is the real emitted module, never this extractor's
//! output — so tool-assisted sidecars prove the generator exactly as
//! hand-written ones do, at corpus scale.
//!
//! Identity fields are synthesized deterministically (a fixture sidecar
//! attests no real compile): hashes derive from the entry path and the
//! reflected surface, so re-runs are byte-identical (V13) and a fixture
//! edit moves them.
const std = @import("std");
pub fn emitMain(comptime core: type, comptime entry: []const u8, init: std.process.Init) !void {
const json = comptime sidecarJson(core, entry);
const arena = init.arena.allocator();
const args = try init.minimal.args.toSlice(arena);
if (args.len < 2) {
var stderr_buffer: [256]u8 = undefined;
var stderr_writer = std.Io.File.stderr().writerStreaming(init.io, &stderr_buffer);
try stderr_writer.interface.print("usage: <extractor> <out path>\n", .{});
try stderr_writer.interface.flush();
std.process.exit(2);
}
if (std.fs.path.dirname(args[1])) |dir| {
std.Io.Dir.cwd().createDirPath(init.io, dir) catch {};
}
try std.Io.Dir.cwd().writeFile(init.io, .{ .sub_path = args[1], .data = json });
}
pub fn sidecarJson(comptime core: type, comptime entry: []const u8) []const u8 {
comptime {
@setEvalBranchQuota(20_000_000);
// ---------------------------------------- phase 1: reach set
var reach = ReachSet{};
// Root names come from the REFLECTED types (an aliased root
// keeps its declared type's own name; the alias is the export's
// spelling, not the type's).
const model_name = lastComponent(@typeName(core.Model));
const msg_zig = lastComponent(@typeName(core.Msg));
reach.collect(core.Model, "", "", "");
for (@typeInfo(core.Msg).@"union".fields) |arm| {
if (arm.type == void) continue;
// The number_bytes family exists so the one ubiquitous
// inline shape never needs a table entry — skip it here
// exactly when payloadDescriptor classifies it that way.
if (isNumberBytesShape(arm.type, msg_zig)) continue;
reach.collect(arm.type, msg_zig, msg_zig, arm.name);
}
for (@typeInfo(core.Model).@"struct".decls) |decl| {
if (helperShape(core.Model, decl.name)) |helper| {
reach.collect(helper.Return, "", "helpers", decl.name);
}
}
// ------------------------------------- phase 2: emit sections
var slots: []const u8 = "";
var slot_count: usize = 0;
var structs: []const u8 = "";
var enums: []const u8 = "";
var unions: []const u8 = "";
var struct_count: usize = 0;
var enum_count: usize = 0;
var union_count: usize = 0;
for (reach.entries) |item| {
switch (@typeInfo(item.T)) {
.@"struct" => |info| {
var fields: []const u8 = "";
for (info.fields, 0..) |field, index| {
if (index > 0) fields = fields ++ ", ";
fields = fields ++ "{\"name\": " ++ js(field.name) ++ ", \"type\": " ++ typeRefJson(field.type, lastComponent(@typeName(item.T)), item.name, field.name) ++ "}";
if (spellsI64(field.type)) {
appendSlot(&slots, &slot_count, item.name ++ "." ++ field.name);
}
}
if (struct_count > 0) structs = structs ++ ",\n ";
structs = structs ++ "{\"name\": " ++ js(item.name) ++ originJson(core, item.name) ++ ", \"fields\": [" ++ fields ++ "]}";
struct_count += 1;
},
.@"enum" => |info| {
var members: []const u8 = "";
for (info.fields, 0..) |member, index| {
if (index > 0) members = members ++ ", ";
members = members ++ js(member.name);
}
if (enum_count > 0) enums = enums ++ ",\n ";
enums = enums ++ "{\"name\": " ++ js(item.name) ++ originJson(core, item.name) ++ ", \"members\": [" ++ members ++ "]}";
enum_count += 1;
},
.@"union" => |info| {
var arms: []const u8 = "";
for (info.fields, 0..) |arm, index| {
if (index > 0) arms = arms ++ ", ";
const payload = if (arm.type == void) "{\"kind\": \"void\"}" else typeRefJson(arm.type, lastComponent(@typeName(item.T)), item.name, arm.name);
arms = arms ++ "{\"name\": " ++ js(arm.name) ++ memberJson(item.T, arm.name) ++ ", \"payload\": " ++ payload ++ "}";
if (arm.type != void and spellsI64(arm.type)) {
appendSlot(&slots, &slot_count, item.name ++ "." ++ arm.name);
}
}
if (union_count > 0) unions = unions ++ ",\n ";
unions = unions ++ "{\"name\": " ++ js(item.name) ++ originJson(core, item.name) ++ ", \"arms\": [" ++ arms ++ "]}";
union_count += 1;
},
else => @compileError("the type table cannot carry " ++ @typeName(item.T)),
}
}
// Message arms with payload descriptors.
var msg_arms: []const u8 = "";
for (@typeInfo(core.Msg).@"union".fields, 0..) |arm, index| {
if (index > 0) msg_arms = msg_arms ++ ",\n ";
const descriptor = payloadDescriptor(arm.type, msg_zig, arm.name, &slots, &slot_count);
msg_arms = msg_arms ++ "{\"name\": " ++ js(arm.name) ++ memberJson(core.Msg, arm.name) ++ ", \"payload\": " ++ descriptor ++ "}";
}
// Helpers, in Model-declaration (= export) order.
var helpers: []const u8 = "";
var helper_count: usize = 0;
for (@typeInfo(core.Model).@"struct".decls) |decl| {
const helper = helperShape(core.Model, decl.name) orelse continue;
if (helper_count > 0) helpers = helpers ++ ",\n ";
var params: []const u8 = "";
_ = &params; // Transpiled helpers take no extra parameters.
if (spellsI64(helper.Return)) {
appendSlot(&slots, &slot_count, "helpers." ++ decl.name ++ ".return");
}
helpers = helpers ++ "{\"name\": " ++ js(decl.name) ++ ", \"params\": [" ++ params ++ "], \"returns\": " ++
typeRefJson(helper.Return, "", "helpers", decl.name) ++ ", \"arena\": " ++ (if (helper.arena) "true" else "false") ++ "}";
helper_count += 1;
}
const model_unbound = unboundJson(core.Model);
const msg_unbound = unboundJson(core.Msg);
// Channels: export presence IS the wiring decision.
const has_command = @hasDecl(core, "commandMsg");
const has_frame = @hasDecl(core, "frameMsg");
const has_key = @hasDecl(core, "keyMsg");
const has_pinch = @hasDecl(core, "pinchMsg");
var env_msgs: []const u8 = "";
if (@hasDecl(core, "envMsgs")) {
for (core.envMsgs, 0..) |env_entry, index| {
if (index > 0) env_msgs = env_msgs ++ ", ";
env_msgs = env_msgs ++ "{\"env\": " ++ js(env_entry.env) ++ ", \"msg\": " ++ js(env_entry.msg) ++ "}";
}
}
const appearance: []const u8 = if (@hasDecl(core, "appearanceMsg")) js(core.appearanceMsg) else "null";
const chrome: []const u8 = if (@hasDecl(core, "chromeMsg")) js(core.chromeMsg) else "null";
// Entry-shape flags, by return type — the same discrimination
// the bridge applies to transpiled modules.
const init_returns_cmd = @typeInfo(@typeInfo(@TypeOf(core.initialModel)).@"fn".return_type.?) != .pointer;
const update_returns_cmd = @typeInfo(@typeInfo(@TypeOf(core.update)).@"fn".return_type.?) != .pointer;
const has_subscriptions = @hasDecl(core, "subscriptions");
var abi_exports: []const u8 =
"\"abi_version\", \"build_id\", \"set_panic_sink\", \"init\", \"collect\", " ++
"\"frame_reset\", \"boot_cmd\", \"dispatch_void\", \"dispatch_bytes\", " ++
"\"dispatch_number\", \"dispatch_number_bytes\", \"dispatch_bool\", \"dispatch_enum\", " ++
"\"dispatch_record\", \"dispatch_text_input\", \"dispatch_scroll_state\", " ++
"\"subscriptions\", \"model_snapshot\", \"helper_call\"";
if (has_command) abi_exports = abi_exports ++ ", \"command_msg\"";
if (has_frame) abi_exports = abi_exports ++ ", \"frame_msg\"";
if (has_key) abi_exports = abi_exports ++ ", \"key_msg\"";
if (has_pinch) abi_exports = abi_exports ++ ", \"pinch_msg\"";
const types_json =
"{\n \"structs\": [\n " ++ structs ++ "\n ],\n" ++
" \"enums\": [\n " ++ enums ++ "\n ],\n" ++
" \"unions\": [\n " ++ unions ++ "\n ]\n }";
// Deterministic synthesized identity: the fixture has no real
// compile behind it, so hash the COMPLETE reflected surface —
// entry path, root names, types, arms, helpers, unbound lists,
// channel wiring, entry-shape flags, and the export set. Every
// section rides behind a label and a NUL separator (the
// sections are JSON text and can never contain one), so the
// serialization is injective: moving a fact between sections
// moves the identities, and re-runs reproduce them exactly.
const surface = "entry=" ++ entry ++
"\x00model=" ++ model_name ++
"\x00msg=" ++ msg_zig ++
"\x00types=" ++ types_json ++
"\x00arms=" ++ msg_arms ++
"\x00helpers=" ++ helpers ++
"\x00model_unbound=" ++ model_unbound ++
"\x00msg_unbound=" ++ msg_unbound ++
"\x00env=" ++ env_msgs ++
"\x00appearance=" ++ appearance ++
"\x00chrome=" ++ chrome ++
"\x00exports=" ++ abi_exports ++
"\x00flags=" ++ boolJson(init_returns_cmd) ++ boolJson(update_returns_cmd) ++
boolJson(has_subscriptions) ++ boolJson(has_command) ++ boolJson(has_frame) ++
boolJson(has_key) ++ boolJson(has_pinch);
const source_hash = std.hash.Wyhash.hash(0x5eed_c0de, surface);
const build_id = std.hash.Wyhash.hash(0xb11d1d00, surface);
return "{\n" ++
" \"format\": 1,\n" ++
" \"wire_version\": 3,\n" ++
" \"abi_version\": 1,\n" ++
" \"compiler_version\": \"0.0.1\",\n" ++
" \"entry\": " ++ js(entry) ++ ",\n" ++
" \"source_hash\": \"" ++ std.fmt.comptimePrint("{x:0>16}", .{source_hash}) ++ "\",\n" ++
" \"build_id\": \"" ++ std.fmt.comptimePrint("{x:0>16}", .{build_id}) ++ "\",\n" ++
" \"types\": " ++ types_json ++ ",\n" ++
" \"model\": " ++ js(model_name) ++ ",\n" ++
" \"model_helpers\": [" ++ (if (helper_count > 0) "\n " ++ helpers ++ "\n " else "") ++ "],\n" ++
" \"model_unbound\": [" ++ model_unbound ++ "],\n" ++
" \"msg\": {\n \"name\": " ++ js(msg_zig) ++ ",\n \"arms\": [\n " ++ msg_arms ++ "\n ],\n \"unbound\": [" ++ msg_unbound ++ "]\n },\n" ++
" \"init_returns_cmd\": " ++ boolJson(init_returns_cmd) ++ ",\n" ++
" \"update_returns_cmd\": " ++ boolJson(update_returns_cmd) ++ ",\n" ++
" \"has_subscriptions\": " ++ boolJson(has_subscriptions) ++ ",\n" ++
" \"channels\": {\n" ++
" \"command_msg\": " ++ boolJson(has_command) ++ ",\n" ++
" \"frame_msg\": " ++ boolJson(has_frame) ++ ",\n" ++
" \"key_msg\": " ++ boolJson(has_key) ++ ",\n" ++
" \"pinch_msg\": " ++ boolJson(has_pinch) ++ ",\n" ++
" \"appearance_msg\": " ++ appearance ++ ",\n" ++
" \"chrome_msg\": " ++ chrome ++ ",\n" ++
" \"env_msgs\": [" ++ env_msgs ++ "]\n" ++
" },\n" ++
" \"abi\": {\n \"prefix\": \"nsc_core_\",\n \"exports\": [" ++ abi_exports ++ "],\n \"snapshot_format\": 1\n },\n" ++
" \"integer_slots\": [" ++ (if (slot_count > 0) "\n " ++ slots ++ "\n " else "") ++ "],\n" ++
" \"deterministic\": true,\n" ++
" \"async_free\": true\n" ++
"}\n";
}
}
fn boolJson(comptime value: bool) []const u8 {
return if (value) "true" else "false";
}
/// A JSON string literal: quoted and escaped (author-controlled
/// spellings — env names, unbound lists, identifiers — may carry any
/// byte; the sidecar must stay valid JSON regardless).
fn js(comptime text: []const u8) []const u8 {
comptime {
var out: []const u8 = "\"";
for (text) |char| {
out = out ++ switch (char) {
'"' => "\\\"",
'\\' => "\\\\",
'\n' => "\\n",
'\r' => "\\r",
'\t' => "\\t",
else => if (char < 0x20)
std.fmt.comptimePrint("\\u{x:0>4}", .{char})
else
&[_]u8{char},
};
}
return out ++ "\"";
}
}
/// The authored member-name fact of a single-payload union arm, as a
/// JSON fragment (`, "member": "..."`), read from the transpiler's
/// `payload_members` table on the union; empty when the module carries
/// no fact for the arm (bare and multi-field arms, older modules).
fn memberJson(comptime T: type, comptime arm_name: []const u8) []const u8 {
comptime {
if (!@hasDecl(T, "payload_members")) return "";
if (!@hasField(@TypeOf(T.payload_members), arm_name)) return "";
return ", \"member\": " ++ js(@field(T.payload_members, arm_name));
}
}
/// The declaring-module fact of a named table type, as a JSON fragment
/// (`, "origin": "..."`), read from the transpiler's `type_origins`
/// table. A newer three-field entry carries `false` when the declaration
/// is private, so the facade can declare its structural twin instead of
/// importing a nonexistent export. Empty for synthesized names (declared
/// nowhere) and older modules.
fn originJson(comptime core: type, comptime name: []const u8) []const u8 {
comptime {
if (!@hasDecl(core, "type_origins")) return "";
for (core.type_origins) |entry| {
if (std.mem.eql(u8, entry[0], name)) {
const privacy = if (@typeInfo(@TypeOf(entry)).@"struct".fields.len >= 3 and !entry[2])
", \"exported\": false"
else
"";
return ", \"origin\": " ++ js(entry[1]) ++ privacy;
}
}
return "";
}
}
fn appendSlot(comptime slots: *[]const u8, comptime count: *usize, comptime path: []const u8) void {
if (count.* > 0) slots.* = slots.* ++ ",\n ";
slots.* = slots.* ++ "{\"slot\": " ++ js(path) ++ ", \"class\": \"i64\"}";
count.* += 1;
}
/// Whether a mirror type spells i64 at its own slot (through
/// optionals). Slice elements have no slot-path grammar in schema v1
/// and are not attested.
fn spellsI64(comptime T: type) bool {
return switch (@typeInfo(T)) {
.int => T == i64,
.optional => |info| spellsI64(info.child),
else => false,
};
}
const HelperShape = struct { Return: type, arena: bool };
/// Classify a Model decl as an exported helper: the zero-extra-arg form
/// `fn (*const Model) V` or the build-arena form
/// `fn (*const Model, std.mem.Allocator) V` — exactly the method shapes
/// the transpiler forwards onto Model.
fn helperShape(comptime Model: type, comptime decl_name: []const u8) ?HelperShape {
const DeclType = @TypeOf(@field(Model, decl_name));
const info = switch (@typeInfo(DeclType)) {
.@"fn" => |fn_info| fn_info,
else => return null,
};
if (info.params.len == 0 or info.params[0].type != *const Model) return null;
if (info.return_type == null) return null;
if (info.params.len == 1) return .{ .Return = info.return_type.?, .arena = false };
if (info.params.len == 2 and info.params[1].type == std.mem.Allocator) {
return .{ .Return = info.return_type.?, .arena = true };
}
return null;
}
fn unboundJson(comptime T: type) []const u8 {
comptime {
if (!@hasDecl(T, "view_unbound")) return "";
var out: []const u8 = "";
for (T.view_unbound, 0..) |name, index| {
if (index > 0) out = out ++ ", ";
out = out ++ js(name);
}
return out;
}
}
fn lastComponent(comptime full: []const u8) []const u8 {
if (std.mem.lastIndexOfScalar(u8, full, '.')) |dot| return full[dot + 1 ..];
return full;
}
/// The table name of a named or synthesized type at a reference site:
/// anonymous records take the schema's deterministic
/// `<Container>_<member>` pattern, named types keep the author's
/// spelling. `parent_zig` is the enclosing type's own emitted name —
/// the compiler names an anonymous member after its parent, so
/// anonymity is recognized only when the prefix IS that parent.
fn tableName(comptime T: type, comptime parent_zig: []const u8, comptime container: []const u8, comptime member: []const u8) []const u8 {
const last = lastComponent(@typeName(T));
if (isAnonymousName(last, parent_zig)) {
return container ++ "_" ++ member;
}
return last;
}
/// The compiler names anonymous containers `<Parent>__struct_<digits>`
/// — the enclosing declaration's name, the marker, and an instance
/// counter to the end. All three parts must line up: the FINAL marker
/// carries all-digit text to the end AND the prefix before it is the
/// parent's own name, so an authored `Foo__struct_17` under `Model`
/// keeps its identity (its prefix is not "Model"), while a genuine
/// anonymous member under any parent — synthesized-named parents
/// included — is recognized. The one spelling this cannot separate is
/// an AUTHORED type named exactly `<its own parent>__struct_<digits>`,
/// which sits inside the compiler's own generated-name space.
fn isAnonymousName(comptime name: []const u8, comptime parent_zig: []const u8) bool {
inline for (.{ "__struct_", "__union_" }) |marker| {
if (std.mem.lastIndexOf(u8, name, marker)) |at| {
if (!std.mem.eql(u8, name[0..at], parent_zig)) continue;
const digits = name[at + marker.len ..];
if (digits.len > 0) {
var all_digits = true;
for (digits) |char| {
if (char < '0' or char > '9') all_digits = false;
}
if (all_digits) return true;
}
}
}
return false;
}
fn isBytes(comptime T: type) bool {
return switch (@typeInfo(T)) {
.pointer => |info| info.size == .slice and info.child == u8,
else => false,
};
}
/// The TypeRef JSON of a mirror type at a reference site (phase 2:
/// naming only; the reach set already carries every referenced entry).
fn typeRefJson(comptime T: type, comptime parent_zig: []const u8, comptime container: []const u8, comptime member: []const u8) []const u8 {
if (T == bool) return "{\"kind\": \"bool\"}";
if (T == f64) return "{\"kind\": \"f64\"}";
if (T == i64) return "{\"kind\": \"i64\"}";
if (isBytes(T)) return "{\"kind\": \"bytes\"}";
switch (@typeInfo(T)) {
.optional => |info| return "{\"kind\": \"optional\", \"inner\": " ++ typeRefJson(info.child, parent_zig, container, member) ++ "}",
.pointer => |info| switch (info.size) {
.slice => return "{\"kind\": \"slice\", \"elem\": " ++ typeRefJson(info.child, parent_zig, container, member) ++ "}",
.one => return "{\"kind\": \"node\", \"name\": " ++ js(tableName(info.child, parent_zig, container, member)) ++ "}",
else => @compileError("no TypeRef form for " ++ @typeName(T)),
},
.@"struct" => return "{\"kind\": \"value\", \"name\": " ++ js(tableName(T, parent_zig, container, member)) ++ "}",
.@"enum" => return "{\"kind\": \"enum\", \"name\": " ++ js(tableName(T, parent_zig, container, member)) ++ "}",
.@"union" => return "{\"kind\": \"union\", \"name\": " ++ js(tableName(T, parent_zig, container, member)) ++ "}",
else => @compileError("no TypeRef form for " ++ @typeName(T)),
}
}
/// The anonymous two-field number-plus-bytes record, in its emitted
/// field order (number first) — the shape the number_bytes descriptor
/// family carries without a table entry.
fn isNumberBytesShape(comptime T: type, comptime msg_zig: []const u8) bool {
const info = switch (@typeInfo(T)) {
.@"struct" => |s| s,
else => return false,
};
const anonymous = isAnonymousName(lastComponent(@typeName(T)), msg_zig);
return anonymous and info.fields.len == 2 and
(info.fields[0].type == f64 or info.fields[0].type == i64) and
isBytes(info.fields[1].type);
}
/// The payload descriptor of one Msg arm, collecting integer slots for
/// the number-carrying families.
fn payloadDescriptor(comptime T: type, comptime msg_zig: []const u8, comptime arm_name: []const u8, comptime slots: *[]const u8, comptime slot_count: *usize) []const u8 {
if (T == void) return "{\"kind\": \"void\"}";
if (isBytes(T)) return "{\"kind\": \"bytes\"}";
if (T == f64) return "{\"kind\": \"number\", \"class\": \"f64\"}";
if (T == i64) {
// Message-side slot paths spell the union's AUTHORED name, never
// a literal `Msg` token.
appendSlot(slots, slot_count, msg_zig ++ "." ++ arm_name);
return "{\"kind\": \"number\", \"class\": \"i64\"}";
}
if (T == bool) return "{\"kind\": \"scalar\", \"type\": {\"kind\": \"bool\"}}";
switch (@typeInfo(T)) {
.@"enum" => return "{\"kind\": \"enum\", \"name\": " ++ js(tableName(T, msg_zig, msg_zig, arm_name)) ++ "}",
.@"union" => return "{\"kind\": \"union\", \"name\": " ++ js(tableName(T, msg_zig, msg_zig, arm_name)) ++ "}",
.@"struct" => |info| {
// The ubiquitous inline number-plus-bytes shape (fetch
// {status, body}, collect-spawn {code, output}) rides the
// dedicated two-field family instead of a synthesized
// table entry — but only in its emitted field order
// (number first); anything else stays a tabled record.
if (isNumberBytesShape(T, msg_zig)) {
const class = if (info.fields[0].type == i64) "i64" else "f64";
if (info.fields[0].type == i64) {
appendSlot(slots, slot_count, msg_zig ++ "." ++ arm_name ++ "." ++ info.fields[0].name);
}
return "{\"kind\": \"number_bytes\", \"number_field\": " ++ js(info.fields[0].name) ++
", \"number_class\": \"" ++ class ++ "\", \"bytes_field\": " ++ js(info.fields[1].name) ++ "}";
}
return "{\"kind\": \"record\", \"name\": " ++ js(tableName(T, msg_zig, msg_zig, arm_name)) ++ "}";
},
.pointer => @compileError("Msg arm payloads held BY REFERENCE (" ++ @typeName(T) ++ ") have no schema form: the named-type payload family carries no storage kind, so a by-reference record arm cannot be described faithfully in sidecar format 1 — see SCHEMA-GAPS.md"),
else => @compileError("no payload descriptor for Msg arm payload " ++ @typeName(T)),
}
}
// ------------------------------------------------- phase 1: reach set
const ReachEntry = struct { T: type, name: []const u8 };
const ReachSet = struct {
entries: []const ReachEntry = &.{},
fn listed(comptime self: *const ReachSet, comptime T: type) bool {
for (self.entries) |item| {
if (item.T == T) return true;
}
return false;
}
/// Add every table-worthy type reachable from `T`, in first-visit
/// order (deterministic; table order carries no meaning for a
/// mirror — Zig declarations are order-free — and V4/V6 order
/// checks are the emitter's, requiring the source).
fn collect(comptime self: *ReachSet, comptime T: type, comptime parent_zig: []const u8, comptime container: []const u8, comptime member: []const u8) void {
if (T == bool or T == f64 or T == i64 or isBytes(T)) return;
switch (@typeInfo(T)) {
.optional => |info| self.collect(info.child, parent_zig, container, member),
.pointer => |info| switch (info.size) {
.slice, .one => self.collect(info.child, parent_zig, container, member),
else => @compileError("unreachable table shape " ++ @typeName(T)),
},
.@"struct" => |info| {
if (self.listed(T)) return;
const name = tableName(T, parent_zig, container, member);
self.entries = self.entries ++ &[_]ReachEntry{.{ .T = T, .name = name }};
for (info.fields) |field| self.collect(field.type, lastComponent(@typeName(T)), name, field.name);
},
.@"union" => |info| {
if (self.listed(T)) return;
const name = tableName(T, parent_zig, container, member);
self.entries = self.entries ++ &[_]ReachEntry{.{ .T = T, .name = name }};
for (info.fields) |field| {
if (field.type == void) continue;
self.collect(field.type, lastComponent(@typeName(T)), name, field.name);
}
},
.@"enum" => {
if (self.listed(T)) return;
self.entries = self.entries ++ &[_]ReachEntry{.{ .T = T, .name = tableName(T, parent_zig, container, member) }};
},
else => @compileError("unreachable table shape " ++ @typeName(T)),
}
}
};
// --------------------------------------------------------------- tests
const testing = std.testing;
test "extraction of a small core produces a valid sidecar" {
const Core = struct {
pub const Role = enum(u8) { user = 0, assistant = 1 };
pub const Turn = struct { id: i64, role: Role, text: []const u8 };
pub const Model = struct {
turns: []const *const Turn,
nextId: i64,
title: []const u8,
pub fn turnCount(self: *const Model) i64 {
return @intCast(self.turns.len);
}
pub const view_unbound = .{"nextId"};
};
pub const Msg = union(enum) {
bump,
rename: []const u8,
fetched: struct { status: i64, body: []const u8 },
role_set: Role,
};
pub const envMsgs = .{
.{ .env = "APP_TITLE", .msg = "rename" },
};
pub fn initialModel() *const Model {
unreachable;
}
pub const UpdateResult = struct { model: *const Model, cmd: []const u8 };
pub fn update(model: *const Model, msg: Msg) UpdateResult {
_ = model;
_ = msg;
unreachable;
}
};
const json = comptime sidecarJson(Core, "src/core.ts");
// The extracted document must satisfy the reader end to end.
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const sidecar_mod = @import("sidecar.zig");
var diags = sidecar_mod.Diagnostics{ .arena = arena };
const sidecar = sidecar_mod.read(arena, json, &diags) catch |err| {
for (diags.list.items) |item| {
std.debug.print(" [{s}] {s}: {s}\n", .{ @tagName(item.severity), item.path, item.message });
}
return err;
};
try testing.expectEqualStrings("Model", sidecar.model);
try testing.expectEqual(@as(usize, 4), sidecar.msg.arms.len);
try testing.expect(sidecar.msg.arms[2].payload == .number_bytes);
try testing.expectEqualStrings("status", sidecar.msg.arms[2].payload.number_bytes.number_field);
try testing.expectEqual(@as(usize, 1), sidecar.model_helpers.len);
try testing.expect(!sidecar.init_returns_cmd);
try testing.expect(sidecar.update_returns_cmd);
try testing.expect(!sidecar.has_subscriptions);
try testing.expectEqual(@as(usize, 1), sidecar.channels.env_msgs.len);
// Determinism: a second evaluation is byte-identical.
try testing.expectEqualStrings(json, comptime sidecarJson(Core, "src/core.ts"));
}
test "message-side integer slot paths spell the union's authored name" {
const Core = struct {
pub const Model = struct { count: i64 };
pub const Event = union(enum) {
tick: i64,
fetched: struct { status: i64, body: []const u8 },
};
pub const Msg = Event;
pub fn initialModel() *const Model {
unreachable;
}
pub fn update(model: *const Model, msg: Msg) *const Model {
_ = msg;
return model;
}
};
const json = comptime sidecarJson(Core, "src/core.ts");
try testing.expect(std.mem.indexOf(u8, json, "{\"slot\": \"Event.tick\", \"class\": \"i64\"}") != null);
try testing.expect(std.mem.indexOf(u8, json, "{\"slot\": \"Event.fetched.status\", \"class\": \"i64\"}") != null);
try testing.expect(std.mem.indexOf(u8, json, "\"Msg.tick\"") == null);
// The reader's bijection accepts the authored spelling end to end.
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const sidecar_mod = @import("sidecar.zig");
var diags = sidecar_mod.Diagnostics{ .arena = arena };
const sidecar = try sidecar_mod.read(arena, json, &diags);
try testing.expectEqual(@as(usize, 3), sidecar.integer_slots.len);
}
test "private type-origin markers reach the additive sidecar fact" {
const Core = struct {
const Hidden = struct { value: f64 };
pub const Model = struct { hidden: *const Hidden };
pub const Msg = union(enum) { replace };
pub const type_origins = .{
.{ "Hidden", "core.ts", false },
.{ "Model", "core.ts" },
.{ "Msg", "core.ts" },
};
pub fn initialModel() *const Model {
unreachable;
}
pub fn update(model: *const Model, msg: Msg) *const Model {
_ = msg;
return model;
}
};
const json = comptime sidecarJson(Core, "core.ts");
try testing.expect(std.mem.indexOf(u8, json, "{\"name\": \"Hidden\", \"origin\": \"core.ts\", \"exported\": false") != null);
try testing.expect(std.mem.indexOf(u8, json, "{\"name\": \"Model\", \"origin\": \"core.ts\", \"exported\"") == null);
}
test "anonymous-name detection keys on the final compiler suffix" {
// Authored names merely containing the marker keep their identity;
// only a trailing instance counter marks a compiler-named type.
comptime {
std.debug.assert(!isAnonymousName("Foo__struct_Row", "Msg"));
std.debug.assert(!isAnonymousName("Task", "Msg"));
std.debug.assert(!isAnonymousName("Foo__union_2x", "Msg"));
std.debug.assert(isAnonymousName("Msg__struct_26987", "Msg"));
// An authored name that merely LOOKS compiler-made keeps its
// identity when its prefix is not the enclosing parent.
std.debug.assert(!isAnonymousName("Foo__struct_17", "Model"));
std.debug.assert(isAnonymousName("Foo__struct_Row__struct_17", "Foo__struct_Row"));
std.debug.assert(!isAnonymousName("Foo__struct_Row__struct_17", "Msg"));
std.debug.assert(isAnonymousName("Edit__union_4", "Edit"));
}
}
test "synthesized identities cover root names and section framing" {
const CoreA = struct {
pub const Model = struct { hidden: i64 };
pub const Event = union(enum) { bump };
pub const Msg = Event;
pub fn initialModel() *const Model {
unreachable;
}
pub fn update(model: *const Model, msg: Msg) *const Model {
_ = msg;
return model;
}
};
const CoreB = struct {
pub const Model = struct { hidden: i64 };
pub const Action = union(enum) { bump };
pub const Msg = Action;
pub fn initialModel() *const Model {
unreachable;
}
pub fn update(model: *const Model, msg: Msg) *const Model {
_ = msg;
return model;
}
};
const json_a = comptime sidecarJson(CoreA, "src/core.ts");
const json_b = comptime sidecarJson(CoreB, "src/core.ts");
// Only the reflected union name differs; the pairing identities
// must differ with it.
try testing.expect(std.mem.indexOf(u8, json_a, "\"name\": \"Event\"") != null);
try testing.expect(std.mem.indexOf(u8, json_b, "\"name\": \"Action\"") != null);
const id_a = json_a[std.mem.indexOf(u8, json_a, "build_id").?..][0..32];
const id_b = json_b[std.mem.indexOf(u8, json_b, "build_id").?..][0..32];
try testing.expect(!std.mem.eql(u8, id_a, id_b));
}
test "aliased roots extract under their reflected type names" {
const State = struct { count: i64 };
const Core = struct {
pub const Model = State;
pub const Msg = union(enum) { bump };
pub fn initialModel() *const Model {
unreachable;
}
pub fn update(model: *const Model, msg: Msg) *const Model {
_ = msg;
return model;
}
};
const json = comptime sidecarJson(Core, "src/core.ts");
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const sidecar_mod = @import("sidecar.zig");
var diags = sidecar_mod.Diagnostics{ .arena = arena };
const sidecar = try sidecar_mod.read(arena, json, &diags);
// The export is an alias; the TYPE's own name is what the module
// reflects, so the table and the model field must agree on it.
try testing.expectEqualStrings("State", sidecar.model);
try testing.expect(sidecar_mod.findStruct(sidecar.types, "State") != null);
}
test "reflected strings are JSON-escaped" {
const Core = struct {
pub const Model = struct { label: []const u8 };
pub const Msg = union(enum) { rename: []const u8 };
pub const envMsgs = .{
.{ .env = "APP\"MODE\\X", .msg = "rename" },
};
pub fn initialModel() *const Model {
unreachable;
}
pub fn update(model: *const Model, msg: Msg) *const Model {
_ = msg;
return model;
}
};
const json = comptime sidecarJson(Core, "src/core.ts");
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const sidecar_mod = @import("sidecar.zig");
var diags = sidecar_mod.Diagnostics{ .arena = arena };
const sidecar = try sidecar_mod.read(arena, json, &diags);
try testing.expectEqualStrings("APP\"MODE\\X", sidecar.channels.env_msgs[0].env);
try testing.expect(!sidecar.update_returns_cmd);
}
+14 -4
View File
@@ -196,6 +196,13 @@ pub const Sidecar = struct {
msg: Msg,
init_returns_cmd: bool,
update_returns_cmd: bool,
/// Whether initialModel/update may ALSO return the bare model (the
/// documented mixed idiom, `Model | [Model, Cmd<Msg>]`). Additive,
/// frontend-emitted facts: absent (an older document, or a
/// compiler's co-emitted sidecar) means the pair shape is
/// unconditional. The facade emitter keys its wrapper on them.
init_returns_bare: bool = false,
update_returns_bare: bool = false,
has_subscriptions: bool,
channels: Channels,
abi: Abi,
@@ -467,10 +474,11 @@ const Mapper = struct {
fn mapRoot(self: *Mapper, value: std.json.Value) error{ Refused, OutOfMemory }!Sidecar {
const top = try self.members(value, "", &.{
"format", "wire_version", "abi_version", "compiler_version", "entry",
"source_hash", "build_id", "types", "model", "model_helpers",
"model_unbound", "msg", "init_returns_cmd", "update_returns_cmd", "has_subscriptions",
"channels", "abi", "integer_slots", "deterministic", "async_free",
"format", "wire_version", "abi_version", "compiler_version", "entry",
"source_hash", "build_id", "types", "model", "model_helpers",
"model_unbound", "msg", "init_returns_cmd", "update_returns_cmd", "init_returns_bare",
"update_returns_bare", "has_subscriptions", "channels", "abi", "integer_slots",
"deterministic", "async_free",
});
top.warnUnknown();
@@ -496,6 +504,8 @@ const Mapper = struct {
.msg = try self.mapMsg(try top.get("msg")),
.init_returns_cmd = try self.boolean(try top.get("init_returns_cmd"), "init_returns_cmd"),
.update_returns_cmd = try self.boolean(try top.get("update_returns_cmd"), "update_returns_cmd"),
.init_returns_bare = try self.optionalBoolean(top.map, "init_returns_bare", "", false),
.update_returns_bare = try self.optionalBoolean(top.map, "update_returns_bare", "", false),
.has_subscriptions = try self.boolean(try top.get("has_subscriptions"), "has_subscriptions"),
.channels = try self.mapChannels(try top.get("channels")),
.abi = try self.mapAbi(try top.get("abi")),