feat(core): classify subset rules as guarantee or deferred (#345)

Every NS rule in the diagnostics catalogue carries class: "guarantee" |
"deferred". Guarantee rules protect a core invariant (determinism and
replay, fixed shapes, immutability of shared data, the one text
representation) and are permanent; deferred rules (NS1011, NS1019,
NS1040, NS1042, NS1044) wait on an easing decision and their
diagnostics say the capability is deliberately deferred, not
impossible.

Fix text names the concrete service alternative where one exists:
NS1002 (network via the generated client), NS1005 (clock reads),
NS1011 (Map/Set transforms), NS1040 (regex matching), NS1041 (JSON
parsing returning a typed record).

Docs and skills frame the core and services by role: the core is the
app's deterministic logic; services do the app's imperative work.
This commit is contained in:
Chris Tate
2026-08-13 14:15:06 -05:00
committed by GitHub
parent baef0d96d3
commit 716eb27c53
5 changed files with 139 additions and 27 deletions
+8 -3
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` — 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.
An app core is a Native SDK app's deterministic logic: `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 starts as three files and zero Zig: `src/core.ts`, `src/app.native`, and `app.zon`. When ordinary TypeScript work needs filesystem access, JSON, regexes, `Map`, `Date`, classes, or child processes, add modules under `src/services/`; they compile to native code too and answer the core through the same effect→Msg boundary as every other external action. 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 and render passes) is always Zig.
@@ -131,6 +131,11 @@ Both regions have fixed, build-time capacities (1 MiB each by default): the fram
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 core cannot carry (npm packages, regexes, `JSON`, Promises, `eval`) 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 — kind-tagged error shapes narrowed in the catch, time and randomness arrive as message payloads, keyed data is an id-keyed array, and ordinary static-tier work moves behind a `src/services/` request. 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 the core class. Files under `src/services/` skip NS1001NS1064 and are judged by the same pinned compiler's ordinary static tier instead; the class boundary rules NS1065NS1067 keep the deterministic core and ambient-authority service separate. Where the ecosystem fits has its own page: [Where Packages Go](/docs/typescript/packages).
Every rule in the catalogue carries a class. A `guarantee` rule protects a core invariant — determinism and replay, fixed shapes, immutability of shared data, the one text representation — and is permanent. A `deferred` rule bans nothing those invariants require; the capability waits on a deliberate easing decision, and its diagnostic says so.
- `guarantee` — permanent: NS1001 (shared data is immutable), NS1002 (updates are synchronous), NS1005 (update is deterministic), NS1010 (module state lives in the Model), the byte-text rules (NS1004, NS1018, NS1024, NS1060), and every other rule not listed as deferred.
- `deferred` — awaiting an easing decision: NS1011 (`Map`/`Set`), NS1019 (fixed arity: parameter defaults, rest, `arguments`, call spreads), NS1040 (regular expressions), NS1042 (generators), NS1044 (`BigInt`/`Symbol`).
<CodeToggle>
```ts
@@ -552,7 +557,7 @@ pub fn parseSample(bytes: []const u8) ?Sample { ... }
## TypeScript services
A service operation is a directly exported, non-default named synchronous function under `src/services/`, taking zero or one explicitly typed request and declaring a contract-encodable result. Crossing shapes live in an exported, subset-legal module outside `src/services/` so the core and service import one declaration. The operation name is `<module-basename>.<export>`; `native check` projects its complete type table into `services.contract.json`, checks both classes, and generates the typed core client:
The core is the app's deterministic logic — `Model`, `Msg`, `update`; services do the app's imperative work. A service operation is a directly exported, non-default named synchronous function under `src/services/`, taking zero or one explicitly typed request and declaring a contract-encodable result. Crossing shapes live in an exported, subset-legal module outside `src/services/` so the core and service import one declaration. The operation name is `<module-basename>.<export>`; `native check` projects its complete type table into `services.contract.json`, checks both classes, and generates the typed core client:
```ts:src/core.ts
import { feedsParse } from "@native-sdk/services";
@@ -592,7 +597,7 @@ The compiled core is a native static archive, not generated source: `native chec
## Where the subset ends
The deterministic core tier owns app state and decisions. The service tier owns imperative application work in ordinary TypeScript: parsing, filesystem transforms, environment inspection, subprocesses, and other ambient operations whose results cross back as messages. The toolkit-extension tier — custom widgets, rasterizer work, new engine-owned effects, and platform integration — remains Zig by design: that layer is the machinery itself, and [Building Components](/docs/building-components) is its guide. Services are not a backdoor storage engine or general FFI surface.
The core owns app state and decisions — the app's deterministic logic. Services own imperative application work in ordinary TypeScript: parsing, filesystem transforms, environment inspection, subprocesses, and other ambient operations whose results cross back as messages. The toolkit-extension tier — custom widgets, rasterizer work, new engine-owned effects, and platform integration — remains Zig by design: that layer is the machinery itself, and [Building Components](/docs/building-components) is its guide. Services are not a backdoor storage engine or general FFI surface.
## Reference
@@ -1,6 +1,6 @@
# TypeScript Services
Modules under `src/services/` are ordinary TypeScript compiled to native code on the compiler's full static tier: `fs`, `path`, `process`, `os`, `child_process`, `fetch`, regexes, `JSON`, `Map`/`Set`, `Date`, and classes, when the pinned compiler supports them. The same pinned compiler builds the deterministic core ([TypeScript Cores](/docs/typescript)) and the service tier; no JavaScript engine ships in either.
Modules under `src/services/` are ordinary TypeScript compiled to native code on the compiler's full static tier: `fs`, `path`, `process`, `os`, `child_process`, `fetch`, regexes, `JSON`, `Map`/`Set`, `Date`, and classes, when the pinned compiler supports them. The same pinned compiler builds the deterministic core ([TypeScript Cores](/docs/typescript)) and the services; no JavaScript engine ships in either.
The core calls a service by returning a command from `update`. The typed result returns as an ordinary `Msg`:
@@ -17,14 +17,14 @@ case "parse":
Services run on a supervised carrier — as a separate child process by default, or compiled into the app binary on an explicitly selected worker-thread pool — and are desktop-only today (see [Runtime behavior](#runtime-behavior)).
## The two tiers
## The two roles
Record→replay, headless testing, and [automation](/docs/automation) depend on `update` being a pure function of its inputs. A service reads the real filesystem, clock, and network, so the checker refuses a core import of a service file (NS1065) and the core-to-service edge is always a command. Service results are journaled like every other effect result.
The split is by role: the core is the app's deterministic logic — `Model`, `Msg`, `update` — and services do the app's imperative work. Record→replay, headless testing, and [automation](/docs/automation) depend on `update` being a pure function of its inputs. A service reads the real filesystem, clock, and network, so the checker refuses a core import of a service file (NS1065) and the core-to-service edge is always a command. Service results are journaled like every other effect result.
<table>
<thead>
<tr>
<th>Tier</th>
<th>Role</th>
<th>Owns</th>
<th>Language rules</th>
</tr>
@@ -102,7 +102,7 @@ A service module is any `.ts` file under `src/services/`. Every directly exporte
- It declares a contract-encodable result type.
- Its name is `<module-basename>.<export>` — `export function parse` in `src/services/feeds.ts` is `feeds.parse`.
Boundary shapes live in a shared, subset-legal module outside `src/services/`, imported by both tiers:
Boundary shapes live in a shared, subset-legal module outside `src/services/`, imported by the core and the service:
```ts:src/shared.ts
export type ParseRequest = {
@@ -130,7 +130,7 @@ export function parse(request: ParseRequest): ParseResult {
}
```
`native check` projects the complete type table into a contract sidecar (`services.contract.json`), checks both tiers, and generates the typed client the core imports. An operation shaped any other way — `async`, a default export, an unannotated request, a non-encodable result — is a teaching error (NS1067) naming the rewrite.
`native check` projects the complete type table into a contract sidecar (`services.contract.json`), checks both classes, and generates the typed client the core imports. An operation shaped any other way — `async`, a default export, an unannotated request, a non-encodable result — is a teaching error (NS1067) naming the rewrite.
### Boundary types
@@ -148,7 +148,7 @@ export function parse(request: ParseRequest): ParseResult {
</tr>
<tr>
<td><code>Uint8Array</code></td>
<td>The bytes form both tiers already share</td>
<td>The bytes form the core and services already share</td>
</tr>
<tr>
<td>Optionals, readonly slices</td>
@@ -380,4 +380,4 @@ Three checker rules enforce the boundary. Each teaches the fix and the reason at
## Reference
`examples/service-feed-reader` is the minimal two-tier app: a deterministic core, a service using `node:fs`, regex, `Map`, `Date`, and JSON, and a kind-tagged error path. The machine-precise authoring guide ships as `native skills get ts-services`.
`examples/service-feed-reader` is the minimal core-plus-service app: a deterministic core, a service using `node:fs`, regex, `Map`, `Date`, and JSON, and a kind-tagged error path. The machine-precise authoring guide ships as `native skills get ts-services`.
+90 -6
View File
@@ -13,12 +13,20 @@ export interface SubsetDiagnostic {
readonly column: number; // 1-based
}
/// Every rule is one of two classes. A `guarantee` rule protects a core
/// invariant — determinism and replay, fixed shapes, immutability of shared
/// data, the one text representation — and is permanent. A `deferred` rule
/// bans nothing the invariants require; the capability waits on a deliberate
/// easing decision, and its diagnostic says so.
export type RuleClass = "guarantee" | "deferred";
export interface RuleCopy {
readonly id: string;
readonly title: string;
/// `fix` and `why` are joined after the site-specific lead-in.
readonly fix: string;
readonly why: string;
readonly class: RuleClass;
}
export const rules = {
@@ -27,438 +35,511 @@ export const rules = {
title: "shared data is immutable; your own scratch is yours",
fix: "Build the next value instead (`{ ...model, tasks: [...model.tasks, task] }`), or take a copy you own first (`const copy = xs.slice();`) — arrays this function creates itself (literals, `.slice()`/`.map()`/`.filter()`/`.concat()`/`.toSorted()` copies) mutate freely until they escape, `xs[xs.length] = v` appends like `.push`, a `let` whose EVERY assignment is a fresh construction stays owned, and passing to a `readonly T[]` parameter of a reading helper keeps ownership.",
why: "The previous model stays live for rendering and undo, and a caller's array outlives the call; native builds share unchanged parts without copying, so the immutable style is not slower — while a locally-created array has exactly one holder, which is what makes mutating it deterministic and safe.",
class: "guarantee",
},
NS1002: {
id: "NS1002",
title: "updates are synchronous",
fix: "Return the work as a command: `[model, Cmd.host(\"fetch_profile\", userId)]`.",
fix: "Return the work as a command: `[model, Cmd.host(\"fetch_profile\", userId)]`; network and other ordinary async work runs in a `src/services/` module called through its generated `@native-sdk/services` client.",
why: "The runtime performs the effect after commit and dispatches your message with the result.",
class: "guarantee",
},
NS1003: {
id: "NS1003",
title: "models hold data, not functions",
fix: "Name the behavior as a message (`{ kind: \"tick\" }`) and handle it in update.",
why: "The model is data; commit walkers cannot (and should not) copy closures.",
class: "guarantee",
},
NS1004: {
id: "NS1004",
title: "text is not indexable",
fix: "Store text as `Uint8Array` bytes and index those; turn user-visible literals/templates into bytes with `utf8Bytes` from \"@native-sdk/core\" (`asciiBytes` is the narrower machine-text form).",
why: "Code-unit reads behave differently in JS (UTF-16) and native (UTF-8); with them gone the encodings are indistinguishable.",
class: "guarantee",
},
NS1005: {
id: "NS1005",
title: "update is deterministic",
fix: "Take the value as input instead: time via `[model, Cmd.now(\"tick\")]`; randomness rides in as a Msg payload from the host.",
fix: "Take the value as input instead: time via `[model, Cmd.now(\"tick\")]`; randomness rides in as a Msg payload from the host; a `src/services/` module may read the clock directly and return the value as a Msg.",
why: "Ambient time, randomness, and I/O make replay and testing lie.",
class: "guarantee",
},
NS1006: {
id: "NS1006",
title: "classes are data classes, declared at module level",
fix: "Declare the class at module level — annotated fields, one constructor, plain methods (`class Task { title: Uint8Array; constructor(...) {...} rename(...) {...} }`) — and construct it with `new Task(...)`; everything else stays records and functions.",
why: "A data class emits as a plain struct plus module-level functions; a class expression, a `this` outside a member body, or `new` of an arbitrary expression would need runtime prototypes and object identity the fixed native layout does not carry.",
class: "guarantee",
},
NS1007: {
id: "NS1007",
title: "implicit builtin throws stay out",
fix: "Give the operation its explicit form (`.reduce(f, init)` — the starting accumulator makes the empty array well-defined); your own `throw` of a subset value is supported, deterministic control flow.",
why: "JS builtins throw engine TypeError objects mid-operation; a user `throw` carries a subset value the native payload slot can hold, but a builtin's implicit throw has no such value and no native mapping.",
class: "guarantee",
},
NS1008: {
id: "NS1008",
title: "only erasable TypeScript syntax compiles",
fix: "Replace `enum` with a string-literal union; drop namespaces, decorators, and parameter properties.",
why: "The same file must run unmodified under node (`erasableSyntaxOnly`); these constructs generate code.",
class: "guarantee",
},
NS1009: {
id: "NS1009",
title: "for/in does not compile",
fix: "Model the data as an array and walk it with a classic `for (let i = 0; ...)` loop.",
why: "`for`/`in` walks the prototype chain; the subset has fixed shapes and no prototypes.",
class: "guarantee",
},
NS1010: {
id: "NS1010",
title: "module state lives in the Model",
fix: "Move the mutable value into the Model and update it through messages; module-level `const` is fine.",
why: "Mutable globals escape the dispatch/commit lifecycle and break replay.",
class: "guarantee",
},
NS1011: {
id: "NS1011",
title: "Map and Set are not part of v1",
fix: "Model the data as an id-keyed array of records (`readonly Item[]` with a `readonly id: number` field) and look items up with a loop or `.filter`.",
fix: "Model the data as an id-keyed array of records (`readonly Item[]` with a `readonly id: number` field) and look items up with a loop or `.filter`; transform-heavy work may use `Map`/`Set` inside a `src/services/` module and return plain records.",
why: "Hashed containers need identity and hashing machinery the commit walkers do not carry in v1; id-keyed arrays give the same access pattern with plain data.",
class: "deferred",
},
NS1012: {
id: "NS1012",
title: "object shapes are fixed",
fix: "Model optional data with `T | null` fields; build new objects instead of deleting fields.",
why: "Sparse arrays, `delete`, getters/setters, `Proxy`, and `Symbol` break the fixed native layouts the compiler emits.",
class: "guarantee",
},
NS1013: {
id: "NS1013",
title: "app cores are a closed world",
fix: "Remove `eval` / `new Function` / dynamic `import()`; express the logic as ordinary functions.",
why: "No JS engine ships in the binary, so code cannot be created at runtime.",
class: "guarantee",
},
NS1014: {
id: "NS1014",
title: "the core's entry points live in core.ts",
fix: "Move this export into src/core.ts (imported modules may hold the helpers it calls and the types it uses).",
why: "The build wires `update`, `initialModel`, `subscriptions`, the host-event channels, `themePack`, and `viewUnbound` from the entry module only, so an entry export in an imported file would be silently ignored.",
class: "guarantee",
},
NS1015: {
id: "NS1015",
title: "exhaustive switch required on message unions",
fix: "Add a case for every `kind` (no `default` needed once all arms are present).",
why: "Exhaustiveness is what lets the compiler emit a closed native switch with no fallback path.",
class: "guarantee",
},
NS1016: {
id: "NS1016",
title: "integer and fractional values cannot share a number slot",
fix: "Split the value into two fields, or keep every value on this path whole (no fractional literals or fractional math flowing into it).",
why: "Native code gives each `number` slot one machine type; this slot must be an integer where it is used, but a fractional value also flows in, and an integer type cannot hold both.",
class: "guarantee",
},
NS1017: {
id: "NS1017",
title: "commands are issued in update's return, not stored",
fix: "Construct the Cmd inline in update's return: `return [next, Cmd.persist()]` (several at once via `Cmd.batch([...])`).",
why: "A Cmd describes effects for the runtime to perform after this dispatch commits; a command that lives in the model, a message, a local, or a helper escapes the dispatch cycle and breaks replay.",
class: "guarantee",
},
NS1018: {
id: "NS1018",
title: "text builds with templates and bytes, not +",
fix: "Build the text as bytes: `utf8Bytes(`${count} items`)` from \"@native-sdk/core\", or stitch byte buffers with `new Uint8Array(n)` + `.set`.",
why: "Runtime string concatenation would need a JS string heap the native binary does not carry; bytes in the frame arena are the one dynamic-text representation.",
class: "guarantee",
},
NS1019: {
id: "NS1019",
title: "functions have fixed arity",
fix: "Pass every argument explicitly at every call site: drop parameter defaults (`= value`), rest parameters (`...xs`), `arguments`, and call spreads (`f(...xs)`) — take and pass an array instead.",
why: "Emitted native functions have exact signatures; a dynamic argument list would be materialized invisibly at each call site, and a missed site diverges from node instead of failing loudly.",
class: "deferred",
},
NS1020: {
id: "NS1020",
title: "host command arguments are numbers or one bytes payload",
fix: "Pass numbers (`Cmd.host(\"beep\", model.count)`), or exactly one payload — a `Uint8Array` or a flat record of number/boolean/`Uint8Array` fields (`Cmd.host(\"save\", model.draft)`).",
why: "The Cmd wire format encodes f64 scalars or one bytes payload per record; a value smuggled past the type with `as` has no encoding and would corrupt the effect stream.",
class: "guarantee",
},
NS1021: {
id: "NS1021",
title: "optional chains end in ?? or a value use, not a null test",
fix: "Normalize with `??` (`model.sel?.tag ?? null`) or guard the base first (`model.sel !== null && model.sel.tag === null`).",
why: "A short-circuiting `?.` yields JS `undefined` while the field's own empty value is `null`; native folds both into one null, so a null test on the chain would disagree with node.",
class: "guarantee",
},
NS1022: {
id: "NS1022",
title: "shared arrays sort by copy, not in place",
fix: "Sort a copy you own — `const copy = xs.slice(); copy.sort((a, b) => a - b);` — or inline with `.toSorted((a, b) => a - b)` and use the returned copy.",
why: "`.sort()` mutates the array it is called on; model data stays live for rendering and undo, so an in-place sort would corrupt the previous model. A local `.slice()` copy is yours, and sorting it in place is legal.",
class: "guarantee",
},
NS1023: {
id: "NS1023",
title: "sort comparators return a sign, not a boolean",
fix: "Return a number whose sign orders the pair: `(a, b) => a - b` for ascending numbers, or explicit -1/0/1 branches.",
why: "JS reads the comparator numerically — `true` coerces to 1 but `false` coerces to 0, which claims the pair is already ordered, so a boolean comparator leaves data unsorted under node too.",
class: "guarantee",
},
NS1024: {
id: "NS1024",
title: "model text is bytes",
fix: "Type the field `Uint8Array` and build user-visible values with `utf8Bytes` from \"@native-sdk/core\" (`asciiBytes` is for guaranteed-ASCII machine text), or use a string-literal union (`\"low\" | \"high\"`) when the field holds one of a closed set of tags.",
why: "A `string` model field would need a JS string heap at every commit; bytes have exactly one representation under node and native, and literal-union tags compile to a native enum.",
class: "guarantee",
},
NS1025: {
id: "NS1025",
title: "subscriptions are declared in subscriptions' return, not stored",
fix: "Derive the descriptors from the model and return them from `subscriptions`: `return model.running ? Sub.timer(\"tick\", 1000, \"tick\") : Sub.none;`.",
why: "A Sub describes recurring effects the host reconciles against the committed model after every dispatch; a descriptor stored in the model, a message, a local, or a helper escapes that reconciliation and breaks replay.",
class: "guarantee",
},
NS1026: {
id: "NS1026",
title: "host payloads are bytes or a flat scalar record",
fix: "For a raw host call, pass one `Uint8Array` (build text with `asciiBytes`) or one inline scalar record. For nested/typed service data, declare one exported shared shape and call its generated constructor from `@native-sdk/services`.",
why: "Raw host commands carry one primitive bytes payload. Generated service clients are the deliberate record-valued arm: their sidecar-derived codec carries the wider boundary vocabulary identically under node and native.",
class: "guarantee",
},
NS1027: {
id: "NS1027",
title: "effect results route to Msg arms by name",
fix: "Spell the routing as data — string-literal arm names, optionally keyed: `{ key: \"load\", ok: \"loaded\", err: \"load_failed\" }` — where each named arm carries exactly the payload the effect produces (one `Uint8Array` field for host results; one number field for timer fires).",
why: "The runtime builds the result Msg itself from the arm's declared shape, so the decoding derives from your types at build time; a callback would run outside the dispatch cycle and could capture state replay cannot see.",
class: "guarantee",
},
NS1028: {
id: "NS1028",
title: "Cmd.persist and its capability must agree",
fix: "Add `\"persist\"` to app.zon's `capabilities` and configure `.persist = .{ .version = 1, .restore = .{ .ok = \"restored\", .none = \"fresh_boot\", .err = \"restore_failed\" } }`; or remove the unused capability/command.",
why: "The persist capability controls whether the engine-owned snapshot store and `core.persist` binding are linked into the app. Keeping the declaration and command in lockstep prevents a silently unperformed write and sheds storage code from apps that do not use it.",
class: "guarantee",
},
NS1029: {
id: "NS1029",
title: "effect op arguments have a fixed shape",
fix: "Spell the built-in op exactly: paths/URLs/bodies are `Uint8Array`, `method` is a closed verb literal, `timeoutMs` is a number literal, and `headers` is an inline flat record. For an app service, use its generated `@native-sdk/services` constructor; its request and route shape come from the shared contract.",
why: "Every effect encodes to one fixed wire record. Built-ins own their hand-written shape; service operations own a generated shape and codec projected from services.contract.json.",
class: "guarantee",
},
NS1030: {
id: "NS1030",
title: "effect arguments respect the engine's limits",
fix: "Keep the value inside the engine bound this diagnostic names (shorter path/URL/header block, fewer headers, a delay between 1ms and one year).",
why: "The host effect engine enforces fixed capacities and would reject the op at runtime through the err arm; a bound that is knowable at compile time should stop the build instead of shipping a guaranteed rejection.",
class: "guarantee",
},
NS1031: {
id: "NS1031",
title: "exported model helpers join the model's binding surface",
fix: "Rename the helper or the colliding member so their emitted names differ.",
why: "An exported helper taking exactly one Model parameter also emits as a Model declaration markup binds by the helper's own name (`doneCount` binds as `{doneCount}`); two members with one emitted name would be ambiguous to every binding engine.",
class: "guarantee",
},
NS1032: {
id: "NS1032",
title: "viewUnbound names update-only model state",
fix: "Export a const array of string literals naming Model fields, exported model helpers, or Msg kinds: `export const viewUnbound = [\"nextId\", \"tick\"] as const;`.",
why: "The list emits as the `view_unbound` opt-out `native check` reads, keeping the unbound-state lint honest for state only update logic touches; a name outside the model surface would silence nothing and hide a typo.",
class: "guarantee",
},
NS1033: {
id: "NS1033",
title: "wiring exports match their runtime shapes",
fix: "Declare the channel exactly: `commandMsg(name: string)` / `keyMsg(key: KeyEvent)` / `frameMsg(model: Model, frame: FrameEvent)` / `pinchMsg(pinch: PinchEvent)` / `dropMsg(drop: FileDropEvent)` returning `Msg | null`; `themePack(model: Model): ThemePack`; singular `statusItem(model: Model): StatusItemState` or collection `statusItems(model: Model): readonly StatusItemDescriptor[]`; `appearanceMsg` / `chromeMsg` naming an arm with that channel's record shape; `envMsgs` entries targeting one-`Uint8Array`-field arms; and persistence ok/none routes naming void arms while err names a one-`Uint8Array`-field arm. Import canonical records from `@native-sdk/core/events`.",
why: "The generated wiring builds host events, persistence restore results, model-derived theme selection, and the live menu-bar status item structurally from your declarations at build time; a wrong shape would otherwise surface as a Zig compile error inside generated code instead of a teaching diagnostic here.",
class: "guarantee",
},
NS1034: {
id: "NS1034",
title: "core imports stay inside src/",
fix: "Move the module under the app's src/ directory and import it relatively (`./parsers.ts`, `./util/bytes.ts`).",
why: "The entry module's directory is the core's whole world — the build ships exactly that tree, so a file above it (`../`) or at an absolute path would exist on your machine but not in the app the build compiles.",
class: "guarantee",
},
NS1035: {
id: "NS1035",
title: "npm packages do not run inside a core",
fix: "Move ordinary TypeScript work into `src/services/` and call it through `Cmd.request`, vendor subset-legal core logic under src/ and import it relatively, or make the import type-only (`import type`); only \"@native-sdk/core\" modules carry runtime meaning in the core class.",
why: "No JS engine ships in the binary — the deterministic core carries only its closed subset, while service modules compile separately through scriptc's ordinary static tier and results return as messages.",
class: "guarantee",
},
NS1036: {
id: "NS1036",
title: "runtime modules do not import in a cycle",
fix: "Hoist the shared declarations into a module both sides import, or make the back-edge type-only (`import type { Model } from \"./core.ts\"` is fine).",
why: "A runtime import cycle only works through JS's live-binding indirection, which the emitted native module (and plain reading order) cannot represent; type-only edges erase and are exempt.",
class: "guarantee",
},
NS1037: {
id: "NS1037",
title: "an import names a real module file",
fix: "Point the specifier at an existing .ts file, spelled with its extension (`./parsers.ts` — node's module loader resolves real filenames, not bare stems).",
why: "The import graph is the build's whole input: a specifier that resolves to nothing would fail under node and silently vanish natively.",
class: "guarantee",
},
NS1038: {
id: "NS1038",
title: "module-scope names are unique across a core's files",
fix: "Rename one side, or declare the shared thing once and import it where it is used.",
why: "The core emits as one native module with one namespace: two types (or two exported values) with one name would collide there, and which one markup or a caller meant would be ambiguous.",
class: "guarantee",
},
NS1039: {
id: "NS1039",
title: "a namespace import is a compile-time alias",
fix: "Reference members through the alias (`ns.helper(x)`, `ns.Config`) or import them by name; the SDK intrinsics are always named imports — `import { Cmd, Sub, asciiBytes, utf8Bytes } from \"@native-sdk/core\"`.",
why: "The core emits as one flat namespace, so `ns` is dot-syntax that erases at build time — it is not an object value that can be stored or passed — and the effect purity rules recognize the SDK factories by their imported names.",
class: "guarantee",
},
NS1040: {
id: "NS1040",
title: "regular expressions are not part of v1",
fix: "Scan the bytes with the byte-text methods (`.includes`/`.indexOf`/`.startsWith`/`.split` on `Uint8Array`), a loop, or the SDK text helpers (`containsIgnoreCase` from \"@native-sdk/core/text\").",
fix: "Scan the bytes with the byte-text methods (`.includes`/`.indexOf`/`.startsWith`/`.split` on `Uint8Array`), a loop, or the SDK text helpers (`containsIgnoreCase` from \"@native-sdk/core/text\"); or run the match in a `src/services/` module, where regexes are ordinary TypeScript.",
why: "A regex is a runtime engine (backtracking, unicode tables) the native binary does not carry, and it reads text as UTF-16 code units where the core's text is bytes.",
class: "deferred",
},
NS1041: {
id: "NS1041",
title: "types are static: no runtime type or shape tests",
fix: "Model alternatives as a discriminated union and switch on its `kind`; optional data is `T | null` tested against null; walk arrays, not object keys.",
fix: "Model alternatives as a discriminated union and switch on its `kind`; optional data is `T | null` tested against null; walk arrays, not object keys; parse JSON in a `src/services/` module that returns a typed record.",
why: "Emitted values are fixed native layouts with no runtime tags to inspect (a union's `kind` is the one tag that exists), so `typeof`/`in`/`instanceof`/`Object.keys` have nothing to read.",
class: "guarantee",
},
NS1042: {
id: "NS1042",
title: "generators are not part of v1",
fix: "Build the sequence as an array — the push-builder (`const out: T[] = []` + `out.push(x)`) or `.map`/`.filter` — and return it whole.",
why: "A generator is a resumable stack frame with hidden state; the subset's collections are materialized arrays built by pure code, which replay and the commit walkers can see.",
class: "deferred",
},
NS1043: {
id: "NS1043",
title: "statements stay statements",
fix: "Write each step as its own statement. A classic for-loop may step several counters (`i++, j--`), and a number `++`/`--`/assignment may sit in a value position when it is the variable's only mention in the statement and JS cannot skip it (`arr[i++]`, `const n = ++count`); everywhere else a comma hides a statement and `void` manufactures a JS undefined (spell the empty `null`).",
why: "Comma, `void`, and the mixed read-write forms exist to squeeze statements into expression position; the emitted native code splits them back into statements, which is only JS-order-exact in the pinned positions.",
class: "guarantee",
},
NS1044: {
id: "NS1044",
title: "BigInt and Symbol are not part of v1",
fix: "Keep integer math in `number` (exact to 2^53, and integer-classed slots emit as native i64); model identities as number ids.",
why: "A core's numbers are IEEE f64 slots; arbitrary-precision integers and engine-allocated symbol identities have no native representation.",
class: "deferred",
},
NS1045: {
id: "NS1045",
title: "destructuring binds record fields into const locals",
fix: "Destructure records only: `const { total, done } = stats;` (rename with `{ done: doneCount }`). Bind array elements by index (`const first = xs[0];`), parameters by name, and drop defaults/rest.",
why: "A record field is always present, so the binding is a compile-time alias; array positions, rest, and defaults can be silently absent in JS (`undefined`), which a bounds-checked native read cannot mean.",
class: "guarantee",
},
NS1046: {
id: "NS1046",
title: "functions live at module level",
fix: "Move the function to module scope (or bind it once: `const helper = (x: number): number => ...` — a capture-free const helper hoists) and pass what it captured as parameters; inline arrow callbacks stay where they are — as call arguments (`xs.map((x) => x * 2)`).",
why: "A nested declaration, a non-const function value, or a `?.()` call treats a function as a runtime value closing over the enclosing frame; emitted native functions are plain module-level code with explicit inputs, so the capture has no representation.",
class: "guarantee",
},
NS1047: {
id: "NS1047",
title: "modules export their declarations by name",
fix: "Export by name: `export` on the declaration, an export list (`export { doneCount, helper as visible }`), or a named value re-export (`export { parsePs } from \"./parsers.ts\"`); what stays out is `export default`, `export =`, `export * from`, and bindings over things with no single emitted value (renamed generics/classes, wiring config, names from outside the core).",
why: "Every consumer — markup bindings, the generated wiring, imports across the core's modules — resolves the flat emitted namespace by NAME: an export list binds real names over real declarations (NS1038 keeps them unique), while a default has no name and a star re-export names nothing.",
class: "guarantee",
},
NS1048: {
id: "NS1048",
title: "equality is strict",
fix: "Compare with `===` / `!==`.",
why: "`==` applies JS's coercion table (\"1\" == 1 is true); the subset's typed values never coerce, so the loose forms are either identical to `===` or depend on string/number coercions that do not exist natively.",
class: "guarantee",
},
NS1049: {
id: "NS1049",
title: "locals declare with const and let",
fix: "Replace `var` with `const` (or `let` where the local is reassigned).",
why: "`var` hoists to function scope and reads as `undefined` before its line — behavior the emitted block-scoped native locals cannot have, so the subset keeps the two forms whose semantics map exactly.",
class: "guarantee",
},
NS1050: {
id: "NS1050",
title: "generics live on module-level declarations",
fix: "Make the generic a module-level `function`, `interface`, or `type` (those monomorphize per concrete use — `pick<Task>` emits `pick__Task`); the dispatch entry points (update/initialModel/subscriptions) and function values stay concrete.",
why: "A monomorphized generic needs a declaration the emitter can instantiate per call site; an entry point has one host-facing ABI signature, and a function value hoists as one concrete fn, so neither can vary by type parameter.",
class: "guarantee",
},
NS1051: {
id: "NS1051",
title: "a local array is yours until it escapes",
fix: "Finish mutating before the value escapes: move the mutation above the return/store/call, pass the array only after the last mutation, or mutate inside the callee instead — a call whose parameter is `readonly T[]` and only READS it (no return, no store, no onward pass into a mutable position) is a borrow, not an escape.",
why: "Once an array is returned, stored, passed where the callee could retain or mutate it, or aliased, other code can hold the same reference; JS would show it your later mutations through that reference, while the native value was shared structurally at the escape — so ownership (and with it mutability) ends there.",
class: "guarantee",
},
NS1052: {
id: "NS1052",
title: "spread array locals declare their array type",
fix: "Annotate the local with its array type: `const turns: readonly Turn[] = [...model.turns, next];`.",
why: "An array literal lowers against a declared slice target (the element type sizes the copy the spread allocates); an un-annotated spread local leaves that type unknown, so the emitter has nothing to lower against.",
class: "guarantee",
},
NS1053: {
id: "NS1053",
title: "generics instantiate per concrete call site",
fix: "Give the call site concrete types the emitter can name — resolved records, unions, arrays, optionals, numbers, booleans, or bytes (`pick<Task>(tasks)` or plain inference from typed arguments); a call whose type argument stays abstract (an empty `[]`, an `any`/`unknown`/`never`, an unnamed literal union) needs an annotation or a named alias.",
why: "A generic helper emits one monomorphic Zig function per distinct instantiation (`pick__Task`, `pick__f64`) from tsc's resolved type arguments; a type argument with no concrete native name has nothing to instantiate against.",
class: "guarantee",
},
NS1054: {
id: "NS1054",
title: "function values stay local helpers",
fix: "Bind the function once (`const helper = (x: number): number => x * 2;`), spell its full signature, take everything it needs as parameters (module constants are fine), and use it only by calling it directly or passing it where an inline callback is legal (`xs.map(helper)`).",
why: "A const-bound, capture-free, fully-annotated function value hoists to an ordinary module-level native function; captures, reassignment, storing or returning the value, and function-typed fields would make it a runtime closure, which has no native representation.",
class: "guarantee",
},
NS1055: {
id: "NS1055",
title: "classes hold data, not hierarchies",
fix: "Drop `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.",
why: "Emitted classes are flat structs with static dispatch; a subclass would need vtables, prototype chains, and layout subtyping that neither the native mapping nor the commit walkers carry.",
class: "guarantee",
},
NS1056: {
id: "NS1056",
title: "class members are annotated fields, one constructor, and plain methods",
fix: "Spell state as annotated fields (`count: number = 0`) and behavior as ordinary methods — `static` methods, `static readonly` consts, and erased `private`/`protected` keywords included; replace getters/setters with methods, `#`-privates with `private` (or module boundaries), and use `this` only to reach instance fields and methods (`this.count`, `this.step()` — statics go by the class name: `Task.LIMIT`).",
why: "A data class emits as a struct plus module-level functions (statics under the class's mangled names); accessors, runtime `#` privacy brands, and a `this` that escapes as a value are prototype/closure machinery with no struct representation — and a record-shaped instance must stay exactly its fields.",
class: "guarantee",
},
NS1057: {
id: "NS1057",
title: "thrown values are kind-tagged subset shapes",
fix: "Throw kind-discriminated record values (`throw { kind: \"parse\", at: i } as ParseError;` — several distinct shapes may throw; the checker collects them into the core's thrown union) and read the catch binding in place: test `e.kind` to narrow, read the arm's fields, rethrow bare (`throw e;`), or narrow a single-shape core once with `const err = e as YourError;`.",
why: "Every `throw` unwinds through one native payload slot typed as the union of the core's thrown shapes, and the `kind` tags are what let a catch narrow that slot exactly — so a thrown value with no subset shape, two shapes sharing one tag, or an error value smuggled out untyped has no sound reading.",
class: "guarantee",
},
NS1058: {
id: "NS1058",
title: "finally never redirects control flow",
fix: "Keep `finally` to cleanup statements; move `return`/`break`/`continue`/`throw` decisions into the `try` or `catch` blocks.",
why: "A `finally` that exits overrides the pending return or exception (JS's own no-unsafe-finally lint rule exists because that is almost always a bug); the native lowering runs finally on every path through a scoped defer, which cannot carry control flow out.",
class: "guarantee",
},
NS1059: {
id: "NS1059",
title: "arrays build from literals, spreads, and loops",
fix: "Spell the construction directly: `Array.of(a, b)` is the literal `[a, b]`, `Array.from(xs)` is the spread copy `[...xs]`, and `Array.from({ length: n }, f)` is a classic loop pushing `f(i)` into `const out: T[] = []`.",
why: "The `Array` statics consume iterables and array-like objects — runtime protocols (`Symbol.iterator`, dynamic `length` probing) the fixed native layouts do not carry — while the literal, spread, and push-builder forms construct the same arrays from data the emitter can see.",
class: "guarantee",
},
NS1060: {
id: "NS1060",
title: "byte text speaks the byte-honest method set",
fix: "Use the byte surface: case with `.toUpperCase()`/`.toLowerCase()` (Unicode simple case mapping, locale-free), search with `.includes`/`.indexOf`/`.lastIndexOf`/`.startsWith`/`.endsWith` (bytes needles), measure and pad in bytes (`.length`, `.padStart`), read bytes with `b[i]`/`.at(i)`, and rebuild text with `.split`, slices, and a push-builder.",
why: "Core text is UTF-8 bytes with exactly one representation under node and native; UTF-16 code-unit reads and Unicode normalization would reintroduce the encoding seam the bytes model exists to close, so their spellings teach the byte-honest form instead.",
class: "guarantee",
},
NS1061: {
id: "NS1061",
title: "value records stay scalar where the model keeps them",
fix: "Declare the record as an interface (reference storage) to hold heap-backed fields, sit in a model array, carry identity under `===`, or reference itself; an object-literal alias (value storage) in the model tree carries scalar fields only (numbers, booleans, literal-union tags).",
why: "An object-literal alias pins by-value storage — the contract projection's value-record spelling. The model's commit machinery copies by-value records shallowly, so heap-backed fields would dangle across frames, arrays of them have no commit walk, equality has no identity to compare, and a self-reference has no finite layout; each of those needs reference storage, which the interface form declares.",
class: "guarantee",
},
NS1062: {
id: "NS1062",
title: "the entry roots keep their contract shapes",
fix: "Declare `Model` as an interface record (`export interface Model { ... }`) and `Msg` as a kind-tagged union (`export type Msg = { kind: \"...\" } | ...`).",
why: "The generated wiring commits `Model` as the reference-stored record root and dispatches `Msg` by its declaration-order kind tags; any other shape under those names has no dispatch or commit path and would fail deep inside the emitted module instead of teaching here.",
class: "guarantee",
},
NS1063: {
id: "NS1063",
title: "the contract sidecar carries every crossing shape",
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.",
class: "guarantee",
},
NS1064: {
id: "NS1064",
title: "asciiBytes is ASCII-only",
fix: "Use `utf8Bytes(...)` for user-visible or Unicode text; keep `asciiBytes(...)` for guaranteed-ASCII command names, keys, paths, URLs, and protocol values.",
why: "JavaScript strings are UTF-16 while the native text boundary is UTF-8; naming the encoding explicitly prevents a non-ASCII code unit from being truncated into a different byte sequence.",
class: "guarantee",
},
NS1065: {
id: "NS1065",
title: "the core does not import services",
fix: "Import the generated constructor from `@native-sdk/services` and return it from update; raw `Cmd.request(\"module.operation\", bytes, { ok, err })` remains the low-level form. Shared boundary shapes live in a core module that the service imports, but the core-to-service edge is always an effect.",
why: "A direct import would run ambient, non-deterministic service authority inside update and erase the command/result boundary that journaling and replay depend on.",
class: "guarantee",
},
NS1066: {
id: "NS1066",
title: "service package imports are exact vendored facts",
fix: "Run `native vendor . package@X.Y.Z`, check in `src/services/vendor/` and the generated app.zon `service_packages` facts, then import that exact package name; or vendor a local source module and import it relatively.",
why: "Service builds have no package-manager or network input: scriptc sees only manifest-declared, hash-verified checked-in sources through an explicit `--npm-static` allowlist.",
class: "guarantee",
},
NS1067: {
id: "NS1067",
title: "service calls match the generated typed contract",
fix: "Export a synchronous, non-default named function with zero or one explicitly typed, contract-encodable request and a contract-encodable result. For a stream, add a last `emit: (chunk: SharedChunk) => void` parameter. Keep boundary records/enums/unions exported in a shared core-class module, let only `{ kind: \"...\", message: <string> }` escape, and call through `@native-sdk/services`.",
why: "The host codecs, runner registry, and typed client are projections of `services.contract.json`; every crossing data shape, stream declaration, deadline, and operation name must be stated there once.",
class: "guarantee",
},
NS1068: {
id: "NS1068",
title: "persistent model shapes advance monotonically",
fix: "Increase app.zon's `.persist.version` when the `Model` shape changes, and never decrease or reuse a version number.",
why: "The version selects the app's pure migration path while the model fingerprint rejects accidental shape drift; reusing a version would make old bytes ambiguous and could restore them into the wrong model layout.",
class: "guarantee",
},
NS1069: {
id: "NS1069",
title: "Cmd.store and its capability must agree",
fix: "Add `\"store\"` to app.zon's `capabilities`, or remove the unused capability/command.",
why: "The store capability controls whether SQLite and the engine-owned record-store binding are linked into the app. Keeping the declaration and command in lockstep prevents a rejected effect and sheds the storage engine from apps that do not use it.",
class: "guarantee",
},
NS1070: {
id: "NS1070",
title: "Cmd.db and its capability must agree",
fix: "Add `\"sqlite\"` to app.zon's `capabilities`, or remove the unused capability/command.",
why: "The sqlite capability controls whether the relational database and its effect binding are linked into the app. Keeping the declaration and command in lockstep prevents a rejected effect and sheds SQLite from apps that do not use either storage tier.",
class: "guarantee",
},
NS1071: {
id: "NS1071",
title: "Cmd.credentials and its capability must agree",
fix: "Add `\"credentials\"` to app.zon's `capabilities`, or remove the unused capability/command.",
why: "The credentials capability controls whether the core keychain path is linked into the app; keeping it in lockstep prevents a guaranteed denied effect and sheds the path from apps that do not use it.",
class: "guarantee",
},
NS1072: {
id: "NS1072",
title: "core credentials require explicit permission",
fix: "Add `\"credentials\"` to app.zon's `permissions`, or remove the `Cmd.credentials.*` call.",
why: "Secrets are the first permission-gated core effect: the runtime refuses every undeclared access with `denied`, even when the build capability is present.",
class: "guarantee",
},
NS1073: {
id: "NS1073",
title: "credential requests use the typed factory",
fix: "Use `Cmd.credentials.set`, `Cmd.credentials.get`, or `Cmd.credentials.delete` instead of spelling `core.credentials.*` through `Cmd.request`.",
why: "The typed factories own the bounded credential record encoding; reserving their wire namespace keeps arbitrary request bytes from being mistaken for secrets or keys.",
class: "guarantee",
},
NS1074: {
id: "NS1074",
@@ -471,6 +552,7 @@ export const rules = {
title: "declare stable SQL in src/queries.sql",
fix: "Move the string literal into a named `-- name: ...` block in src/queries.sql and call its generated `Cmd.q<Name>` constructor; keep Cmd.db only for genuinely dynamic escape-hatch SQL.",
why: "Declared SQL is prepared against the complete migration schema during native check, so table, column, result, and parameter mistakes never reach an installed app.",
class: "guarantee",
},
} as const satisfies Record<string, RuleCopy>;
@@ -488,10 +570,12 @@ export function makeDiagnostic(
column: number,
): SubsetDiagnostic {
const rule = rules[id];
const classNote =
rule.class === "deferred" ? " The capability is deliberately deferred, not impossible." : "";
return {
id: rule.id,
title: rule.title,
message: `${site} ${rule.fix} ${rule.why}`,
message: `${site} ${rule.fix} ${rule.why}${classNote}`,
file,
line,
column,
+23
View File
@@ -750,6 +750,29 @@ test("diagnostics carry rule, fix, and why", () => {
assert.ok(d.title.length > 0, "has a rule title");
assert.ok(d.message.includes("Cmd."), "shows the idiomatic rewrite");
assert.ok(d.message.toLowerCase().includes("replay"), "says why");
assert.ok(d.message.includes("src/services/"), "names the service alternative");
assert.ok(!d.message.includes("deliberately deferred"), "guarantee rules carry no deferral clause");
});
test("every rule carries a class and the deferred set is exact", async () => {
const { rules } = await import("../src/diagnostics.ts");
const deferred = Object.values(rules)
.filter((r) => r.class === "deferred")
.map((r) => r.id)
.sort();
assert.deepEqual(deferred, ["NS1011", "NS1019", "NS1040", "NS1042", "NS1044"]);
for (const r of Object.values(rules)) {
assert.ok(r.class === "guarantee" || r.class === "deferred", `${r.id} has a class`);
}
});
test("deferred rules teach the deferral, not impossibility", () => {
const result = checkOnly(`export function f(): number { const m = new Map<number, number>(); return m.size; }`);
const d = result.diagnostics.find((x) => x.id === "NS1011");
assert.ok(d, `got ${ruleIds(result)}`);
assert.ok(d.message.includes("deliberately deferred"), "names the class");
assert.ok(d.message.includes("id-keyed array"), "still teaches the core idiom first");
assert.ok(d.message.includes("src/services/"), "names the service alternative");
});
test("NS1022 in-place sort teaches the toSorted rewrite", () => {
+10 -10
View File
@@ -5,7 +5,7 @@ description: Authoring guide for the primary Native SDK app-logic path: TypeScri
# Author app cores in the TypeScript subset
TypeScript is the primary app-authoring language. 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.
TypeScript is the primary app-authoring language. An app core is a Native SDK app's deterministic logic: `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 starts as three files of truth and zero Zig: `src/core.ts` (this guide; plus core-class modules it imports under `src/`), `src/app.native` (the markup view over the core's model), and `app.zon` (windows, identity, permissions). Optional ordinary-TypeScript service modules live under `src/services/` and are never imported by the core; load `native skills get ts-services` for that boundary. `native init` scaffolds the three-file base; 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:
@@ -85,7 +85,7 @@ Logic:
- **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.
- Local-mutation shape notes: `push`/`unshift` return the new length in JS, which has no mapping — mutate as a statement and read `.length` after; `sort`/`reverse`/`fill` return the same array — mutate as a statement, then use the array by name (`return copy.sort(cmp)` is a taught stop; the canonical form is `const copy = xs.slice(); copy.sort(cmp); return copy;`); `pop()`/`shift()` return `T | undefined` — the same one-empty the `.find` miss produces, so test `=== undefined` or fold with `??` (`stack.pop() ?? fallback`); spread arguments (`out.push(...xs)`) stay taught — one element per iteration; `xs[xs.length] = v` on an owned array IS a push (the one growth shape — compound forms like `xs[xs.length] += v` read the missing slot first and stay taught), and other out-of-bounds writes are JS sparse arrays with no mapping (they trap on the native bounds check in safe builds — keep writes inside `0..length-1`); changing the LENGTH of the array a `for...of` (or one of its own callbacks) is iterating is a taught stop (JS walks the live array; fixed-length writes during iteration are fine and identical to node); `copyWithin` stays out of v1 (splice/fill cover it).
- `.toSorted(cmp)` sorts a copy in one expression; `.sort(cmp)` sorts in place on an array you own (on shared data it keeps the NS1022 teaching, which names the copy idiom). Both comparators follow the same rules: return a sign — `(a, b) => a - b` for ascending numbers, or explicit -1/0/1 branches; a boolean comparator is wrong in JS itself (false claims equality) and is rejected by the types plus a taught NS1023. The comparator-less arity sorts by string ToString order in JS (`[10, 9]` stays `[10, 9]`), which has no float-text mapping — pass a comparator. Both sorts are stable exactly like JS: comparator 0 (or NaN) keeps the original order of the pair. One honesty note: a comparator that is inconsistent over the actual data (e.g. `a - b` when elements can be `NaN`) is implementation-defined in JS itself, so node and native may then disagree — keep comparators consistent.
- A `.find` miss is the tier's one empty value: JS spells it `undefined`, so test the result with `=== undefined` (never `=== null` — the checker teaches the difference) or fold it away with `??`: `tasks.find((t) => t.id === id) ?? fallback`.
- A `.find` miss is the core's one empty value: JS spells it `undefined`, so test the result with `=== undefined` (never `=== null` — the checker teaches the difference) or fold it away with `??`: `tasks.find((t) => t.id === id) ?? fallback`.
- Optional chaining `?.` on property chains (`model.sel?.at ?? 0`), element hops (`m?.xs[0] ?? 0`, `xs?.[i] ?? d`), and method hops on supported receivers (`xs?.slice(0, 2)`, `xs?.includes(3) ?? false` — every mapped array/bytes method): each hop null-propagates exactly like JS, and the chain value is optional — end it in `??` or compare it against a real value. A `?.` chain compared against `null`/`undefined` is a taught error (NS1021), and `g?.()` on a function value stays taught.
- Null-guard narrowing through `&&`/`||` chains, exactly the way TS narrows: `if (x !== null && x.items.length > 0)`, the flipped order (`null !== x`), the `||` dual (`x === null || x.items.length === 0`, including as an early-exit guard — the code after the exit stays narrowed), ternary conditions (`x !== null && x.at > 0 ? x.at : -1`), and `while (cur !== null && cur.n > 0)` loops (re-tested per iteration; assigning the guarded local drops the narrowing for what follows, like TS). Relational comparisons on guarded optionals (`cls !== null && cls < lim`) work too.
- Nullish `??`, comparisons (including `===` on `string`-typed values — content equality, same as node; `==`/`!=` are taught NS1048 — coercion), `+ - * / % **` on numbers, unary `+`/`-`, and the bitwise family `& | ^ ~ << >> >>>` — all with JS number semantics (`/` is float division, `%` truncates, `**` is float pow with the exact JS corners — `1 ** NaN` is NaN, `(-1) ** Infinity` is NaN, right-associative `2 ** 3 ** 2` is 512; bitwise and shifts are ToInt32 with the shift count masked & 31, `>>>` yielding the unsigned 32-bit value; unary `+` is the identity on numbers). `**` and `/` results are float-classed; bitwise/shift operands are integer-required positions (a float operand is a taught NS1016).
@@ -98,7 +98,7 @@ Logic:
- **Exceptions — `throw`/`try`/`catch`/`finally` as pure control flow.** Inside a core, exceptions are deterministic: `throw` carries a subset VALUE and unwinds to the nearest enclosing `catch` — across helper calls, out of array-method callbacks (a `throw` inside `.map`'s callback exits the whole loop, like JS), through nested `try`s, with `finally` running on every path (fall-through, `return`, `break`/`continue`, and throw alike). The discipline is two rules. First (NS1057): thrown values are kind-tagged subset shapes — throw kind-discriminated records (`throw { kind: "bad_digit", at: i } as ParseError`, where `ParseError` is an interface with a string-literal `kind` field or a `kind`-discriminated union; a single-shape core may also throw a number), and SEVERAL distinct shapes may throw: the checker collects every shape the core throws into its implicit thrown union. The catch binding IS that union — narrow it in place with kind tests, no `as` ceremony: `catch (e) { if (e.kind === "bad_digit") return -e.at; if (e.kind === "io") return e.code; return -1; }` (or `switch (e.kind)` — tsc cannot prove exhaustiveness over the implicit union, so give the switch a `default` or a trailing return). Bare rethrow (`throw e;`) re-raises the bound value — a narrowed arm included — and `catch { ... }` needs no binding; the single-`as` form (`const err = e as ParseError;`) stays legal in single-shape cores (and for a DECLARED union whose arms equal the thrown set — declare `type AppError = ... | ...` and `as AppError` works). What teaches: untagged values in a heterogeneous set, two shapes sharing one `kind` with different payloads, asserting one member shape of a multi-shape core, the binding escaping untyped into a call/store/return, and `throw new Error(...)` (engine error objects carry stack traces with no native layout). Second (NS1058): `finally` never redirects control flow — no `return`/`throw`/`break`-out inside it (JS's own no-unsafe-finally rule; loops fully inside the finally may break within themselves). An UNCAUGHT throw that reaches an exported function's boundary is a defined deterministic panic — exactly where node's process would crash. A throw mid-mutation of an owned array keeps the mutations applied so far, exactly like JS — the catch sees the array as node would.
- **Local function values — const helpers hoist.** `const scale = (x: number): number => x * 3;` (arrow or `function` expression) hoists to an ordinary module-level fn when it is capture-free (module constants and other const helpers are fine to reference; enclosing locals/params are not — pass them as parameters), fully annotated (every parameter and the return type), and used only by direct calls (`scale(v)`, recursion included) or as an array-method callback (`xs.map(scale)`, comparators included). Everything else teaches NS1054: captures, missing annotations, `let` bindings, returning/storing the value, passing it to your own functions, calling through a record field. Capturing a locally-owned array also ENDS its ownership at the capture (a later mutation is the NS1051 teach) — the stored closure would retain the reference.
Not yet in v1 — genuine roadmap deferrals, each stopping with a loud, tailored NS9001 naming the rewrite (never missing basic syntax; the banned-with-a-rule families live in "What the subset means" above): `.toSorted()`/`.sort()` without a comparator (JS ToString ordering; pass `(a, b) => a - b`), `.reduce` without an initial value (a taught NS1007 — JS throws on an empty array) or with an index parameter (use a classic loop), `.indexOf`/`.includes` on record arrays (match a field with `.find`/`.findIndex`), `.join` on number arrays (elements are float-valued; join byte values instead), float values (`/`, `**`, `Math.round`, `Math.sqrt`, float `Math.floor`-family results) where an integer is required such as an index (a taught NS1016 — those values can be fractional or NaN), Math methods beyond the batch above, `Number` methods beyond the three classifiers, float-valued template holes (JS float-to-string fidelity is a runtime v2 surface), arrays of unions (`readonly View[]`) or arrays of byte-strings (`readonly Uint8Array[]`) as model fields (wrap the element in a single-field interface), a collect spawn's stderr tail (v1 delivers the exit code and stdout; stderr is not surfaced — put diagnostics on stdout or check the code), per-line truncation flags (a stdout or response line over its configured bound arrives cut, without a flag), and non-timer subscriptions (`Sub.timer` is the one subscription; one-shot needs are `Cmd.delay`, and process/fetch/audio streams are Cmd-initiated, not subscribed).
Not yet in v1 — genuine roadmap deferrals, each stopping with a loud, tailored NS9001 naming the rewrite (never missing basic syntax; the banned-with-a-rule families live in "What the subset means" above). The rule-level deferrals carry `class: "deferred"` in the diagnostics catalogue — NS1011 (Map/Set), NS1019 (fixed arity), NS1040 (regexes), NS1042 (generators), NS1044 (BigInt/Symbol) — and their diagnostics say the capability is deliberately deferred, not impossible; the list below is the method-level remainder: `.toSorted()`/`.sort()` without a comparator (JS ToString ordering; pass `(a, b) => a - b`), `.reduce` without an initial value (a taught NS1007 — JS throws on an empty array) or with an index parameter (use a classic loop), `.indexOf`/`.includes` on record arrays (match a field with `.find`/`.findIndex`), `.join` on number arrays (elements are float-valued; join byte values instead), float values (`/`, `**`, `Math.round`, `Math.sqrt`, float `Math.floor`-family results) where an integer is required such as an index (a taught NS1016 — those values can be fractional or NaN), Math methods beyond the batch above, `Number` methods beyond the three classifiers, float-valued template holes (JS float-to-string fidelity is a runtime v2 surface), arrays of unions (`readonly View[]`) or arrays of byte-strings (`readonly Uint8Array[]`) as model fields (wrap the element in a single-field interface), a collect spawn's stderr tail (v1 delivers the exit code and stdout; stderr is not surfaced — put diagnostics on stdout or check the code), per-line truncation flags (a stdout or response line over its configured bound arrives cut, without a flag), and non-timer subscriptions (`Sub.timer` is the one subscription; one-shot needs are `Cmd.delay`, and process/fetch/audio streams are Cmd-initiated, not subscribed).
## Effects are Cmd data
@@ -165,7 +165,7 @@ Four effect families deliver MANY results from one command — a keyed stream th
- `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 — 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.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 core 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.audioCaptureStart(key, { source, sampleRate?, channels? }, { event })` — start a native `"microphone"` or `"system"` audio stream. The host converts into interleaved signed-16 little-endian PCM at 16/24/48 kHz and mono/stereo (default 48 kHz mono), in chunks no larger than 20 ms. The ten-field event arm is matched by NAME: `key`, `state` (exactly `"started" | "data" | "failed" | "stopped" | "rejected"`), `source` (exactly `"microphone" | "system"`), `sampleRate`, `channels`, `timestampMs`, `frames`, `pcm`, `droppedPending`, and `droppedTotal`. PCM is dispatch-lifetime bytes; the commit walker copies it when stored in the model. The bounded queue, wake behavior, drop accounting, journaling, and offline replay are the channel transport's. Microphone and system may run concurrently; a second start for one source replaces that source's prior key.
- `Cmd.audioCaptureStop(key)` — synchronously quiesce native callbacks, flush already accepted chunks, deliver exactly one `"stopped"` terminal, and free the key. The key remains occupied until that terminal is delivered, so wait for `"stopped"` before reusing it; an earlier restart is rejected. A missing key no-ops. Declare `"microphone"` and/or `"system_audio"` in `app.zon` permissions so macOS dev executables and packaged apps carry the required usage descriptions.
@@ -374,19 +374,19 @@ The generated wiring detects each channel from an export (export exists → wire
## The rules the checker enforces
Every diagnostic carries one of these IDs plus the fix and the why. Write to them up front:
Every diagnostic carries one of these IDs plus the fix and the why, and every rule carries a class: `guarantee` rules protect a core invariant (determinism and replay, fixed shapes, immutability of shared data, the one text representation) and are permanent; `deferred` rules (NS1011, NS1019, NS1040, NS1042, NS1044) wait on a deliberate easing decision and say so in their output. Write to them up front:
- **NS1001 shared data is immutable; your own scratch is yours.** Never mutate `model`, `msg`, parameters, module tables, or anything reached from them — build the next value (`{ ...model, tasks: [...model.tasks, task] }`) or copy first (`const copy = xs.slice()`). Arrays this function creates itself mutate freely until they escape — appends via `xs[xs.length] = v`, all-owning reassigned bindings, and readonly-reader borrows included (see "Local mutation" above). The previous model stays live for rendering and undo; unchanged parts are shared, not copied.
- **NS1002 updates are synchronous.** No `async`/`await`/Promises in a core. Work that takes time is command data; the runtime dispatches your `Msg` with the result.
- **NS1002 updates are synchronous.** No `async`/`await`/Promises in a core. Work that takes time is command data; the runtime dispatches your `Msg` with the result. Network and other ordinary async work runs in `src/services/` through the generated `@native-sdk/services` client.
- **NS1003 models hold data, not functions.** No function-typed fields in the Model/Msg tree. Name the behavior as a message and handle it in `update`.
- **NS1004 text is not indexable.** No `.length`/`s[i]`/`.charCodeAt` on `string`. Bytes: `Uint8Array`; display literals/templates use `utf8Bytes`, guaranteed-ASCII machine text uses `asciiBytes`.
- **NS1005 update is deterministic.** No `Date.now()`, `Math.random()`, or ambient IO. Take time and randomness as message payloads.
- **NS1005 update is deterministic.** No `Date.now()`, `Math.random()`, or ambient IO. Take time and randomness as message payloads: time via `Cmd.now`, and a `src/services/` module may read the clock directly and return the value as a Msg.
- **NS1006 classes are data classes, declared at module level.** `class` with annotated fields, one constructor, and plain methods compiles (see "Data classes" above); class expressions, `this` outside a member body, and `new` of anything but a data class (or `Uint8Array`) are taught.
- **NS1007 implicit builtin throws stay out.** A JS builtin that throws mid-operation (`.reduce` with no initial value on a possibly-empty array) is taught toward its explicit form; your own `throw` of a subset value is supported control flow (see "Exceptions" above).
- **NS1008 only erasable syntax.** No `enum`, `namespace`, decorators, parameter properties. String-literal unions are the enum.
- **NS1009 no `for`/`in`.** Fixed shapes, no prototypes; model the data as an array and walk it with a classic loop.
- **NS1010 module state lives in the Model.** Module-level `let` is banned (`const` is fine), and a class's MUTABLE `static` field is the same thing by another spelling — `static readonly` consts compile.
- **NS1011 no Map/Set in v1.** Model the data as an id-keyed array of records and look up with a loop or `.filter`.
- **NS1011 no Map/Set in v1.** Model the data as an id-keyed array of records and look up with a loop or `.filter`; transform-heavy work may use `Map`/`Set` inside a `src/services/` module and return plain records.
- **NS1012 object shapes are fixed.** No `delete`, getters/setters, sparse arrays, `Proxy`, `Symbol`. Optional data is `T | null`.
- **NS1013 closed world.** No `eval`, `new Function`, dynamic `import()`.
- **NS1014 the core's entry points live in core.ts.** `update`, `initialModel`, `subscriptions`, the wiring channel exports, and `viewUnbound` belong to the entry module; an entry export in an imported file would be silently ignored by the build, so it is taught instead. Move the export into `src/core.ts` and let the imported module hold what it calls.
@@ -419,8 +419,8 @@ Every diagnostic carries one of these IDs plus the fix and the why. Write to the
- **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`/`utf8Bytes`.
- **NS1064 `asciiBytes` is ASCII-only.** Use `utf8Bytes` for user-visible or Unicode text; reserve `asciiBytes` for values whose contract guarantees 7-bit ASCII.
- **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`.
- **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`), or run the match in a `src/services/` module, where regexes are ordinary TypeScript.
- **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`, and parse JSON in a `src/services/` module that returns a typed record.
- **NS1042 no generators.** `function*`/`yield` is a resumable frame with hidden state; build the sequence as an array (push-builder or `.map`/`.filter`) and return it whole.
- **NS1043 statements stay statements.** Comma sequences (outside a for-loop incrementor) and `void` squeeze statements into expression position (the subset's empty value is spelled `null`); a number `++`/`--`/assignment in value position is legal only where the split statement is order-exact (sole mention, unskippable — `arr[i++]`, `const n = ++count`), and the taught remainder names why.
- **NS1044 no BigInt or Symbol.** A core's numbers are IEEE f64 slots (integer-classed slots emit i64); model identities as number ids.