Files
vercel-labs--zero-native/packages/core/test/checker.test.ts
T
Chris Tate 41c4cdc47a feat(files): add delete file effects (#350)
* feat(files): add delete file effects

- Expose Cmd.deleteFile as the primary TypeScript API with checked routing and documentation.
- Add real, fake, permission, journal, and replay support through the shared file-effect engine.
- Cover compiled cores across macOS, Linux, and Windows, including reliable Windows append behavior.

* fix(files): await Windows append completion

* fix(files): preserve final symlink on delete
2026-08-13 21:00:48 -05:00

1569 lines
60 KiB
TypeScript

// Subset checker tests: one violation in -> the teaching rule's ID out.
import test from "node:test";
import assert from "node:assert/strict";
import { checkOnly, ruleIds, check, checkFiles } from "./helpers.ts";
const core = `
export interface Model { readonly count: number; }
export type Msg = { readonly kind: "tick" };
export function update(model: Model, msg: Msg): Model {
switch (msg.kind) {
case "tick":
return { count: model.count + 1 };
}
}
`;
test("clean core passes the checker", () => {
assert.deepEqual(ruleIds(checkOnly(core)), []);
});
test("NS1069 keeps every Cmd.store factory in capability lockstep", () => {
const source = `
import { Cmd, asciiBytes } from "@native-sdk/core";
export interface Model { readonly bytes: Uint8Array; }
export type Msg =
| { readonly kind: "go" }
| { readonly kind: "wrote" }
| { readonly kind: "loaded"; readonly bytes: Uint8Array }
| { readonly kind: "failed"; readonly reason: Uint8Array };
export function initialModel(): Model { return { bytes: asciiBytes("v") }; }
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "go": return [model, Cmd.batch([
Cmd.store.set("doc/1", model.bytes, { key: "put", ok: "wrote", err: "failed" }),
Cmd.store.get("doc/1", { key: "get", ok: "loaded", err: "failed" }),
Cmd.store.delete("doc/1", { key: "del", ok: "wrote", err: "failed" }),
Cmd.store.scan("doc/", { limit: 10 }, { key: "scan", ok: "loaded", err: "failed" }),
Cmd.store.setMany([["doc/2", model.bytes]], { key: "many", ok: "wrote", err: "failed" }),
])];
case "wrote":
case "loaded":
case "failed": return model;
}
}
`;
const enabled = check(source, { capabilities: ["store"] });
assert.equal(enabled.ok, true, JSON.stringify(enabled));
assert.equal(enabled.warnings.some((d) => d.id === "NS1069"), false);
const missing = check(source);
assert.equal(missing.warnings.filter((d) => d.id === "NS1069").length, 5);
const unused = check(core, { capabilities: ["store"] });
assert.equal(unused.warnings.filter((d) => d.id === "NS1069").length, 1);
});
test("NS1070 keeps every Cmd.db factory in capability lockstep", () => {
const source = `
import { Cmd } from "@native-sdk/core";
export interface Model { readonly count: number; }
export type Msg =
| { readonly kind: "go" }
| { readonly kind: "page"; readonly bytes: Uint8Array }
| { readonly kind: "done" }
| { readonly kind: "wrote" }
| { readonly kind: "failed"; readonly reason: Uint8Array };
export function initialModel(): Model { return { count: 0 }; }
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "go": return [model, Cmd.batch([
Cmd.db.query("SELECT ?", [1], { key: "q", page: "page", done: "done", err: "failed" }),
Cmd.db.exec([["CREATE TABLE note(id INTEGER)", []]], { key: "x", ok: "wrote", err: "failed" }),
])];
case "page":
case "done":
case "wrote":
case "failed": return model;
}
}
`;
const enabled = check(source, { capabilities: ["sqlite"] });
assert.equal(enabled.ok, true, JSON.stringify(enabled));
assert.equal(enabled.warnings.some((d) => d.id === "NS1070"), false);
const missing = check(source);
assert.equal(missing.warnings.filter((d) => d.id === "NS1070").length, 2);
const unused = check(core, { capabilities: ["sqlite"] });
assert.equal(unused.warnings.filter((d) => d.id === "NS1070").length, 1);
const quit = check(`
import { Cmd } from "@native-sdk/core";
export interface Model { readonly done: boolean; }
export type Msg = { readonly kind: "quit" };
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) { case "quit": return [model, Cmd.quitApp()]; }
}
`);
assert.equal(quit.warnings.some((d) => d.id === "NS1070"), false);
});
test("NS1071/NS1072 keep core credentials behind capability and permission", () => {
const source = `
import { Cmd, asciiBytes } from "@native-sdk/core";
export interface Model { readonly token: Uint8Array; }
export type Msg =
| { readonly kind: "go" }
| { readonly kind: "wrote" }
| { readonly kind: "loaded"; readonly token: Uint8Array }
| { readonly kind: "failed"; readonly reason: Uint8Array };
export function initialModel(): Model { return { token: asciiBytes("secret") }; }
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "go": return [model, Cmd.batch([
Cmd.credentials.set("api-token", model.token, { key: "set", ok: "wrote", err: "failed" }),
Cmd.credentials.get("api-token", { key: "get", ok: "loaded", err: "failed" }),
Cmd.credentials.delete("api-token", { key: "delete", ok: "wrote", err: "failed" }),
])];
case "wrote":
case "loaded":
case "failed": return model;
}
}
`;
const enabled = check(source, { capabilities: ["credentials"], permissions: ["credentials"] });
assert.equal(enabled.ok, true, JSON.stringify(enabled));
assert.equal(enabled.warnings.some((d) => d.id === "NS1071"), false);
const missingPermission = check(source, { capabilities: ["credentials"] });
assert.equal(missingPermission.ok, false);
assert.equal(missingPermission.diagnostics.filter((d) => d.id === "NS1072").length, 3);
const missingCapability = check(source, { permissions: ["credentials"] });
assert.equal(missingCapability.ok, true);
assert.equal(missingCapability.warnings.filter((d) => d.id === "NS1071").length, 3);
const unused = check(core, { capabilities: ["credentials"], permissions: ["credentials"] });
assert.equal(unused.warnings.filter((d) => d.id === "NS1071").length, 1);
});
test("NS1073 reserves the core credential request namespace for typed factories", () => {
const result = check(`
import { Cmd } from "@native-sdk/core";
export interface Model { readonly bytes: Uint8Array; }
export type Msg =
| { readonly kind: "go" }
| { readonly kind: "loaded"; readonly bytes: Uint8Array }
| { readonly kind: "failed"; readonly reason: Uint8Array };
export function initialModel(): Model { return { bytes: new Uint8Array(0) }; }
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "go": return [model, Cmd.request("core.credentials.get", model.bytes, { ok: "loaded", err: "failed" })];
case "loaded":
case "failed": return model;
}
}
`, { capabilities: ["credentials"], permissions: ["credentials"] });
assert.equal(result.ok, false);
assert.equal(result.diagnostics.filter((d) => d.id === "NS1073").length, 1);
assert.equal(result.warnings.some((d) => d.id === "NS1071"), false);
});
test("NS1074 catches certainly-external literal file paths without filesystem permission", () => {
const source = `
import { Cmd, asciiBytes } from "@native-sdk/core";
export interface Model { readonly bytes: Uint8Array; }
export type Msg =
| { readonly kind: "go" }
| { readonly kind: "wrote" }
| { readonly kind: "loaded"; readonly bytes: Uint8Array }
| { readonly kind: "stat"; readonly exists: boolean; readonly size: number; readonly mtimeMs: number }
| { readonly kind: "done"; readonly total: number }
| { readonly kind: "failed"; readonly reason: Uint8Array };
export function initialModel(): Model { return { bytes: asciiBytes("x") }; }
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "go": return [model, Cmd.batch([
Cmd.readFile(asciiBytes("/tmp/input"), { ok: "loaded", err: "failed" }),
Cmd.writeFile(asciiBytes("../output"), model.bytes, { ok: "wrote", err: "failed" }),
Cmd.appendFile(asciiBytes("logs/local"), model.bytes, { ok: "wrote", err: "failed" }),
Cmd.statFile(asciiBytes("C:\\\\Users\\\\outside"), { ok: "stat", err: "failed" }),
Cmd.deleteFile(asciiBytes("/tmp/obsolete"), { ok: "wrote", err: "failed" }),
Cmd.readFileStream(asciiBytes("safe/import"), { chunk: "loaded", done: "done", err: "failed" }),
Cmd.writeFileStream("sink", asciiBytes("/tmp/export"), { ok: "wrote", err: "failed" }),
])];
case "wrote":
case "loaded":
case "stat":
case "done":
case "failed": return model;
}
}
`;
const denied = check(source);
assert.equal(denied.diagnostics.filter((d) => d.id === "NS1074").length, 5);
const granted = check(source, { permissions: ["filesystem"] });
assert.equal(granted.diagnostics.some((d) => d.id === "NS1074"), false);
});
test("NS1033 validates app.zon persistence restore routes against Msg", () => {
const source = `
import { Cmd } from "@native-sdk/core";
export interface Model { readonly count: number; }
export type Msg =
| { readonly kind: "save" }
| { readonly kind: "restored" }
| { readonly kind: "fresh_boot" }
| { readonly kind: "restore_failed"; readonly reason: Uint8Array }
| { readonly kind: "wrong_ok"; readonly reason: Uint8Array }
| { readonly kind: "wrong_err" };
export function initialModel(): Model { return { count: 0 }; }
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "save": return [model, Cmd.persist()];
case "restored":
case "fresh_boot":
case "restore_failed":
case "wrong_ok":
case "wrong_err": return model;
}
}
`;
const options = {
capabilities: ["persist"],
persistRoutes: { ok: "restored", none: "fresh_boot", err: "restore_failed" },
} as const;
assert.equal(check(source, options).ok, true);
const missing = check(source, {
...options,
persistRoutes: { ...options.persistRoutes, none: "typo" },
});
assert.equal(missing.ok, false);
assert.ok(missing.diagnostics.some((d) => d.id === "NS1033" && d.message.includes("names no Msg arm")));
const wrongOk = check(source, {
...options,
persistRoutes: { ...options.persistRoutes, ok: "wrong_ok" },
});
assert.equal(wrongOk.ok, false);
assert.ok(wrongOk.diagnostics.some((d) => d.id === "NS1033" && d.message.includes("wrong Msg payload")));
const wrongErr = check(source, {
...options,
persistRoutes: { ...options.persistRoutes, err: "wrong_err" },
});
assert.equal(wrongErr.ok, false);
assert.ok(wrongErr.diagnostics.some((d) => d.id === "NS1033" && d.message.includes("wrong Msg payload")));
});
test("NS1038 reserves generated contract metadata names", () => {
const moduleName = checkOnly(`${core}\nexport const type_origins = 1;`);
assert.ok(ruleIds(moduleName).includes("NS1038"), `got ${ruleIds(moduleName)}`);
assert.ok(moduleName.diagnostics.some((d) => d.message.includes("type-origin metadata")), JSON.stringify(moduleName.diagnostics));
const unionMember = checkOnly(`
export interface Model { readonly count: number; }
export type Msg =
| { readonly kind: "payload_members"; readonly value: number }
| { readonly kind: "tick" };
export function update(model: Model, msg: Msg): Model {
switch (msg.kind) {
case "payload_members": return { count: msg.value };
case "tick": return model;
}
}
`);
assert.ok(ruleIds(unionMember).includes("NS1038"), `got ${ruleIds(unionMember)}`);
assert.ok(unionMember.diagnostics.some((d) => d.message.includes("single-payload metadata")), JSON.stringify(unionMember.diagnostics));
});
test("NS1001 mutating array methods", () => {
const ids = ruleIds(
checkOnly(`
export interface Model { readonly items: readonly number[]; }
export function update(model: Model): Model {
(model.items as number[]).push(1);
return model;
}
`),
);
assert.ok(ids.includes("NS1001"), `got ${ids}`);
});
test("NS1002 async and await", () => {
const ids = ruleIds(
checkOnly(`
export async function update(x: number): Promise<number> {
return await Promise.resolve(x);
}
`),
);
assert.ok(ids.includes("NS1002"), `got ${ids}`);
});
test("NS1003 functions stored in the model", () => {
const ids = ruleIds(
checkOnly(`
export interface Model { readonly onTick: () => void; }
export function update(model: Model): Model { return model; }
`),
);
assert.ok(ids.includes("NS1003"), `got ${ids}`);
});
test("NS1004 string length, indexing, charCodeAt, relational", () => {
for (const expr of ["s.length", "s[0]", "s.charCodeAt(0)", 's < "z" ? 1 : 2']) {
const ids = ruleIds(checkOnly(`export function f(s: string): number { return ${expr} as number; }`));
assert.ok(ids.includes("NS1004"), `${expr}: got ${ids}`);
}
});
test("NS1004 relational comparison on string-literal union values", () => {
const ids = ruleIds(
checkOnly(`
export type Rank = "bronze" | "silver" | "gold";
export function before(a: Rank, b: Rank): boolean { return a < b; }
`),
);
assert.ok(ids.includes("NS1004"), `got ${ids}`);
});
test("NS1004 fires on a hand-rolled ascii bridge (the SDK intrinsic replaced structural recognition)", () => {
const result = checkOnly(`
export function asciiBytes(s: string): Uint8Array {
const out = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
return out;
}
`);
const ids = ruleIds(result);
assert.deepEqual(ids, ["NS1004"]);
const d = result.diagnostics[0];
assert.ok(d.message.includes("asciiBytes"), "the fix names the SDK intrinsic");
assert.ok(d.message.includes("@native-sdk/core"), "the fix names where it comes from");
});
test("the SDK asciiBytes intrinsic passes the checker", () => {
const ids = ruleIds(
checkOnly(`
import { asciiBytes } from "@native-sdk/core";
export function greeting(): Uint8Array { return asciiBytes("hello"); }
`),
);
assert.deepEqual(ids, []);
});
test("NS1064 rejects Unicode in asciiBytes and points to utf8Bytes", () => {
const direct = checkOnly(`
import { asciiBytes } from "@native-sdk/core";
export function label(): Uint8Array { return asciiBytes("Loading…"); }
`);
assert.deepEqual(ruleIds(direct), ["NS1064"]);
assert.ok(direct.diagnostics[0]?.message.includes("U+2026"));
assert.ok(direct.diagnostics[0]?.message.includes("utf8Bytes"));
const renamedTemplate = checkOnly(`
import { asciiBytes as bytes } from "@native-sdk/core";
export function label(n: number): Uint8Array { return bytes(\`Today · \${n}\`); }
`);
assert.deepEqual(ruleIds(renamedTemplate), ["NS1064"]);
});
test("NS1064 inspects rendered template values, not literals used only by conditions", () => {
const comparisonOnly = checkOnly(`
import { asciiBytes } from "@native-sdk/core";
export type Kind = "café" | "tea";
export interface Model { readonly kind: Kind; readonly count: number; }
export function label(model: Model): Uint8Array {
return asciiBytes(\`\${model.kind === "café" ? model.count : 0}\`);
}
`);
assert.deepEqual(ruleIds(comparisonOnly), []);
const renderedUnion = checkOnly(`
import { asciiBytes } from "@native-sdk/core";
export type Kind = "café" | "tea";
export interface Model { readonly kind: Kind; }
export function label(model: Model): Uint8Array { return asciiBytes(\`kind: \${model.kind}\`); }
`);
assert.deepEqual(ruleIds(renderedUnion), ["NS1064"]);
assert.ok(renderedUnion.diagnostics[0]?.message.includes("U+00E9"));
});
test("the SDK utf8Bytes intrinsic accepts Unicode literals and templates", () => {
const ids = ruleIds(
checkOnly(`
import { utf8Bytes } from "@native-sdk/core";
export function label(n: number): Uint8Array { return utf8Bytes(\`Today · \${n}… 😀\`); }
`),
);
assert.deepEqual(ids, []);
});
test("NS1005 ambient time and randomness", () => {
for (const expr of ["Math.random()", "Date.now()"]) {
const ids = ruleIds(checkOnly(`export function f(): number { return ${expr}; }`));
assert.ok(ids.includes("NS1005"), `${expr}: got ${ids}`);
}
});
test("NS1006 class expressions and `this` outside members", () => {
const ids = ruleIds(checkOnly(`export const C = class { id: number = 1; };`));
assert.ok(ids.includes("NS1006"), `got ${ids}`);
const ids2 = ruleIds(checkOnly(`export function f(): number { return (this as { n: number }).n; }`));
assert.ok(ids2.includes("NS1006"), `got ${ids2}`);
});
test("data classes pass the checker; the banned tail teaches by name", () => {
const ok = checkOnly(`
export class Counter {
count: number = 0;
bump(): void { this.count += 1; }
}
export function f(): number { const c = new Counter(); c.bump(); return c.count; }
`);
assert.deepEqual(ruleIds(ok), []);
const ids = ruleIds(checkOnly(`
class A { n: number = 0; }
export class B extends A { get twice(): number { return 2; } static s: number = 1; }
`));
assert.ok(ids.includes("NS1055"), `extends: got ${ids}`);
assert.ok(ids.includes("NS1056"), `accessor/static: got ${ids}`);
});
test("R20 exceptions: try/catch/throw pass the checker; the discipline rules teach", () => {
const ok = checkOnly(`
export type Failure = { readonly kind: "negative" } | { readonly kind: "bad" };
export function f(x: number): number {
try {
if (x < 0) throw { kind: "negative" } as Failure;
} catch (e) {
const err = e as Failure;
return err.kind === "negative" ? 0 : 1;
}
return x;
}
`);
assert.deepEqual(ruleIds(ok), []);
// NS1057: `new Error` has no native shape.
const ids = ruleIds(checkOnly(`export function f(): number { throw new Error("x"); }`));
assert.ok(ids.includes("NS1057"), `got ${ids}`);
// NS1057: two distinct thrown shapes.
const ids2 = ruleIds(
checkOnly(`
export function f(x: number): number {
if (x < 0) throw 1;
if (x > 10) throw true;
return x;
}
`),
);
assert.ok(ids2.includes("NS1057"), `got ${ids2}`);
// NS1057: a directly-used catch binding.
const ids3 = ruleIds(
checkOnly(`
export function f(): number {
try { return 1; } catch (e) { return e === null ? 0 : 2; }
}
`),
);
assert.ok(ids3.includes("NS1057"), `got ${ids3}`);
// NS1058: control flow out of finally.
const ids4 = ruleIds(
checkOnly(`export function f(): number { try { return 1; } finally { return 0; } }`),
);
assert.ok(ids4.includes("NS1058"), `got ${ids4}`);
// ...while a break WITHIN a loop inside finally is that loop's own.
const ids5 = ruleIds(
checkOnly(`
export function f(): number {
let n = 0;
try { n = 1; } finally {
for (let i = 0; i < 3; i++) { if (i === 1) break; n += 1; }
}
return n;
}
`),
);
assert.deepEqual(ids5, []);
});
test("NS1008 non-erasable syntax (enum)", () => {
const ids = ruleIds(checkOnly(`export enum Filter { All, Active }`));
assert.ok(ids.includes("NS1008"), `got ${ids}`);
});
test("NS1009 for/in", () => {
const ids = ruleIds(
checkOnly(`
export function f(o: { readonly a: number }): number {
let n = 0;
for (const k in o) n += 1;
return n;
}
`),
);
assert.ok(ids.includes("NS1009"), `got ${ids}`);
});
test("NS1010 module-level let", () => {
const ids = ruleIds(checkOnly(`let counter = 0;\nexport function f(): number { return counter; }`));
assert.ok(ids.includes("NS1010"), `got ${ids}`);
});
test("NS1011 object-keyed Map", () => {
const ids = ruleIds(
checkOnly(`
export interface Key { readonly id: number; }
export function f(): number {
const m = new Map<Key, number>();
return m.size;
}
`),
);
assert.ok(ids.includes("NS1011"), `got ${ids}`);
});
test("NS1011 bare new Map() and new Set() teach the id-keyed-array idiom", () => {
for (const expr of ["new Map()", "new Map<number, number>()", "new Set()"]) {
const result = checkOnly(`export function f(): number { const m = ${expr}; return m.size; }`);
const ids = ruleIds(result);
assert.ok(ids.includes("NS1011"), `${expr}: got ${ids}`);
const d = result.diagnostics.find((x) => x.id === "NS1011");
assert.ok(d && d.message.includes("id-keyed array"), `${expr}: teaches the idiom`);
}
});
test("NS1012 delete", () => {
const ids = ruleIds(
checkOnly(`
export function f(o: { a?: number }): void {
delete o.a;
}
`),
);
assert.ok(ids.includes("NS1012"), `got ${ids}`);
});
test("NS1013 eval and dynamic import", () => {
const ids = ruleIds(checkOnly(`export function f(): void { eval("1"); }`));
assert.ok(ids.includes("NS1013"), `got ${ids}`);
});
test("NS1035 runtime npm import (module boundary rules live in the graph resolver)", () => {
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)}`);
assert.ok(d.title.includes("npm packages"), "teaches the npm rule");
});
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 = 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 = 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)}`);
assert.ok(d.message.includes("extension") || d.message.includes("names no"), "teaches the real-filename rule");
});
test("NS1018 string concatenation with +", () => {
for (const src of [
`export function f(s: string): string { return "hi " + s; }`,
`export function f(s: string, n: number): string { return s + n; }`,
`export function f(s: string): string { let t = s; t += "!"; return t; }`,
]) {
const ids = ruleIds(checkOnly(src));
assert.ok(ids.includes("NS1018"), `${src}: got ${ids}`);
}
});
test("numeric + does not trip the concatenation rule", () => {
const ids = ruleIds(checkOnly(`export function f(a: number, b: number): number { return a + b; }`));
assert.ok(!ids.includes("NS1018"), `got ${ids}`);
});
test("NS1019 parameter default values", () => {
const ids = ruleIds(
checkOnly(`
function step(n: number, by: number = 1): number { return n + by; }
export function f(n: number): number { return step(n); }
`),
);
assert.ok(ids.includes("NS1019"), `got ${ids}`);
});
test("NS1021 null test on an optional chain", () => {
for (const expr of ["model.inner?.x === null", "model.inner?.x !== null", "undefined === model.inner?.x"]) {
const ids = ruleIds(
checkOnly(`
export interface Inner { readonly x: number | null; }
export interface Model { readonly inner: Inner | null; }
export function f(model: Model): boolean { return ${expr}; }
`),
);
assert.ok(ids.includes("NS1021"), `${expr}: got ${ids}`);
}
});
test("?? and value comparisons on optional chains stay checker-clean", () => {
const ids = ruleIds(
checkOnly(`
export interface Inner { readonly x: number; }
export interface Model { readonly inner: Inner | null; }
export function f(model: Model): number { return model.inner?.x ?? 0; }
export function g(model: Model): boolean { return model.inner?.x === 5; }
`),
);
assert.deepEqual(ids, []);
});
const cmdCore = (update: string) => `
import { Cmd } from "@native-sdk/core";
export interface Model { readonly count: number; }
export type Msg =
| { readonly kind: "add" }
| { readonly kind: "tick"; readonly at: number };
${update}
`;
test("Cmd in update's return path passes the checker", () => {
const ids = ruleIds(
checkOnly(
cmdCore(`
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "add": return [{ count: model.count + 1 }, Cmd.batch([Cmd.persist(), Cmd.now("tick")])];
case "tick": return { count: msg.at };
}
}
`),
),
);
assert.deepEqual(ids, []);
});
test("NS1017 Cmd stored in a local", () => {
const ids = ruleIds(
checkOnly(
cmdCore(`
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
const cmd = Cmd.persist();
switch (msg.kind) {
case "add": return [model, cmd];
case "tick": return model;
}
}
`),
),
);
assert.ok(ids.includes("NS1017"), `got ${ids}`);
});
test("NS1017 Cmd built in a helper", () => {
const ids = ruleIds(
checkOnly(
cmdCore(`
function saveCmd(): Cmd<Msg> {
return Cmd.persist();
}
export function update(model: Model, msg: Msg): Model { return model; }
`),
),
);
assert.ok(ids.includes("NS1017"), `got ${ids}`);
});
test("NS1017 Cmd stored in the model", () => {
const ids = ruleIds(
checkOnly(`
import { Cmd } from "@native-sdk/core";
export type Msg = { readonly kind: "add" } | { readonly kind: "noop" };
export interface Model { readonly pending: Cmd<Msg>; }
export function update(model: Model, msg: Msg): Model { return model; }
`),
);
assert.ok(ids.includes("NS1017"), `got ${ids}`);
});
test("NS1017 Cmd in the model slot of the returned tuple", () => {
const ids = ruleIds(
checkOnly(
cmdCore(`
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "add": return [[model, Cmd.persist()][0], Cmd.none];
case "tick": return model;
}
}
`),
),
);
assert.ok(ids.includes("NS1017"), `got ${ids}`);
});
test("Cmd in initialModel's boot pair passes the checker (the init command)", () => {
const ids = ruleIds(
checkOnly(
cmdCore(`
export function initialModel(): [Model, Cmd<Msg>] {
return [{ count: 0 }, Cmd.now("tick")];
}
export function update(model: Model, msg: Msg): Model { return model; }
`),
),
);
assert.deepEqual(ids, []);
});
test("Sub in subscriptions' return path passes the checker", () => {
const ids = ruleIds(
checkOnly(`
import { Sub } from "@native-sdk/core";
export interface Model { readonly running: boolean; }
export type Msg = { readonly kind: "toggle" } | { readonly kind: "tick"; readonly at: number };
export function update(model: Model, msg: Msg): Model { return model; }
export function subscriptions(model: Model): Sub<Msg> {
return model.running ? Sub.timer("tick", 100, "tick") : Sub.none;
}
`),
);
assert.deepEqual(ids, []);
});
test("NS1025 Sub built anywhere else", () => {
const result = checkOnly(`
import { Sub } from "@native-sdk/core";
export interface Model { readonly running: boolean; }
export type Msg = { readonly kind: "toggle" } | { readonly kind: "tick"; readonly at: number };
export function update(model: Model, msg: Msg): Model { return model; }
function timers(model: Model): Sub<Msg> {
return Sub.timer("tick", 100, "tick");
}
`);
const d = result.diagnostics.find((x) => x.id === "NS1025");
assert.ok(d, `got ${ruleIds(result)}`);
assert.ok(d.message.includes("subscriptions"), "names the home");
assert.ok(d.message.toLowerCase().includes("replay"), "says why");
});
test("diagnostics carry rule, fix, and why", () => {
const result = checkOnly(`export function f(): number { return Math.random(); }`);
const d = result.diagnostics.find((x) => x.id === "NS1005");
assert.ok(d);
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", () => {
const result = checkOnly(`
export function ordered(xs: number[]): number[] {
xs.sort((a, b) => a - b);
return xs;
}
`);
const d = result.diagnostics.find((x) => x.id === "NS1022");
assert.ok(d, `got ${ruleIds(result)}`);
assert.ok(d.message.includes(".toSorted"), "names the copying rewrite");
});
test("NS1022 fires on sort even where the base looks readonly", () => {
const ids = ruleIds(
checkOnly(`
export interface Model { readonly xs: readonly number[]; }
export function f(model: Model): readonly number[] {
(model.xs as number[]).sort((a, b) => a - b);
return model.xs;
}
`),
);
assert.ok(ids.includes("NS1022"), `got ${ids}`);
});
test("NS1023 boolean comparator is taught beneath tsc's own signature error", () => {
// tsc's toSorted signature already rejects a boolean comparator (TS2345
// gates the pipeline first); the checker rule is the teaching layer, and
// the emitter re-derives it.
const result = checkOnly(`
export function ordered(xs: readonly number[]): readonly number[] {
return xs.toSorted((a, b) => a > b);
}
`);
const d = result.diagnostics.find((x) => x.id === "NS1023");
assert.ok(d, `got ${ruleIds(result)}`);
assert.ok(d.message.includes("a - b"), "names the sign-returning fix");
});
test("push on a local builder array is the emitter's to shape, not NS1001", () => {
const ids = ruleIds(
checkOnly(`
export function collect(xs: readonly number[]): readonly number[] {
const out: number[] = [];
for (const x of xs) {
if (x > 0) out.push(x);
}
return out;
}
`),
);
assert.deepEqual(ids, []);
});
test("NS1001 still fires for push on a parameter array", () => {
const ids = ruleIds(
checkOnly(`
export function grow(xs: number[], x: number): number[] {
xs.push(x);
return xs;
}
`),
);
assert.ok(ids.includes("NS1001"), `got ${ids}`);
});
test("local mutation: the full owned method set passes the checker clean", () => {
const ids = ruleIds(
checkOnly(`
export function work(xs: readonly number[]): readonly number[] {
const copy = xs.slice();
copy.push(1);
copy.pop();
copy.shift();
copy.unshift(0);
copy.splice(1, 1, 9);
copy.reverse();
copy.fill(0, 0, 1);
copy.sort((a, b) => a - b);
copy[0] = 5;
return copy;
}
`),
);
assert.deepEqual(ids, []);
});
test("NS1051 mutation after the array was passed to a call, with the escape named", () => {
const result = checkOnly(`
function probe(xs: number[]): number { return xs.length; }
export function f(): number {
const out: number[] = [1];
const t = probe(out);
out.push(2);
return t;
}
`);
const d = result.diagnostics.find((x) => x.id === "NS1051");
assert.ok(d, `got ${ruleIds(result)}`);
assert.ok(d.message.includes("was passed to a call"), d.message);
assert.ok(/line \d/.test(d.message), "names the escape line");
});
test("NS1051 mutation after storing the array into a record", () => {
const ids = ruleIds(
checkOnly(`
export interface Pair { readonly xs: readonly number[]; }
export function f(): Pair {
const out: number[] = [1];
const pair: Pair = { xs: out };
out.pop();
return pair;
}
`),
);
assert.ok(ids.includes("NS1051"), `got ${ids}`);
});
test("NS1051 aliasing ends the original binding's ownership", () => {
const ids = ruleIds(
checkOnly(`
export function f(): number {
const a: number[] = [1];
const b = a;
a.push(2);
return b.length;
}
`),
);
assert.ok(ids.includes("NS1051"), `got ${ids}`);
});
test("an early-exit return is terminal, not an escape (mutation after it stays legal)", () => {
const ids = ruleIds(
checkOnly(`
export function padded(xs: readonly number[], min: number): readonly number[] {
const work = xs.slice();
if (work.length >= min) return work;
while (work.length < min) work.push(0);
return work;
}
`),
);
assert.deepEqual(ids, []);
});
test("an escape inside a loop gates mutations anywhere in that loop", () => {
const ids = ruleIds(
checkOnly(`
function probe(xs: number[]): number { return xs.length; }
export function f(n: number): number {
const out: number[] = [];
let t = 0;
for (let i = 0; i < n; i++) {
out.push(i);
t += probe(out);
}
return t;
}
`),
);
assert.ok(ids.includes("NS1051"), `got ${ids}`);
});
test("NS1001 mutating the alias of an owned array (the alias owns nothing)", () => {
const result = checkOnly(`
export function f(): number {
const a: number[] = [1];
const b = a;
b.push(2);
return a.length;
}
`);
const d = result.diagnostics.find((x) => x.id === "NS1001");
assert.ok(d, `got ${ruleIds(result)}`);
assert.ok(d.message.includes("aliases"), d.message);
});
test("NS1001 mutating a module-level table", () => {
const ids = ruleIds(
checkOnly(`
const TABLE: number[] = [1, 2];
export function f(): number {
TABLE.push(3);
return TABLE.length;
}
`),
);
assert.ok(ids.includes("NS1001"), `got ${ids}`);
});
test("NS1001 indexed writes through parameters and NS1051 after an escape", () => {
const paramWrite = ruleIds(
checkOnly(`
export function f(xs: number[]): number {
xs[0] = 1;
return xs[0];
}
`),
);
assert.ok(paramWrite.includes("NS1001"), `got ${paramWrite}`);
const escapedWrite = ruleIds(
checkOnly(`
function probe(xs: number[]): number { return xs.length; }
export function f(): number {
const out: number[] = [1];
const t = probe(out);
out[0] = 2;
return t;
}
`),
);
assert.ok(escapedWrite.includes("NS1051"), `got ${escapedWrite}`);
});
test("NS1022 keeps teaching on shared sorts and now names the local-copy idiom", () => {
const result = checkOnly(`
export function ordered(xs: number[]): number[] {
xs.sort((a, b) => a - b);
return xs;
}
`);
const d = result.diagnostics.find((x) => x.id === "NS1022");
assert.ok(d, `got ${ruleIds(result)}`);
assert.ok(d.message.includes("copy.sort"), "names the slice-copy idiom");
assert.ok(d.message.includes(".toSorted"), "keeps the copying rewrite");
});
test("NS1023 fires on an in-place sort's boolean comparator too", () => {
const result = checkOnly(`
export function ordered(xs: readonly number[]): readonly number[] {
const copy = xs.slice();
copy.sort((a, b) => a > b);
return copy;
}
`);
const d = result.diagnostics.find((x) => x.id === "NS1023");
assert.ok(d, `got ${ruleIds(result)}`);
assert.ok(d.message.includes("a - b"), "names the sign-returning fix");
});
test("NS1024 string model fields are taught at the declaration, not first use", () => {
const result = checkOnly(`
export interface Model { readonly title: string; readonly n: number; }
export type Msg = { readonly kind: "a" } | { readonly kind: "b" };
export function initialModel(): Model { return { title: "x", n: 0 }; }
export function update(model: Model, msg: Msg): Model {
switch (msg.kind) { case "a": return model; case "b": return model; }
}
export function read(model: Model): boolean { return model.title === "x"; }
`);
const d = result.diagnostics.find((x) => x.id === "NS1024");
assert.ok(d, `expected NS1024, got ${ruleIds(result)}`);
// The declaration sits on line 2 (the interface); first use is far below.
assert.equal(d.line, 2);
assert.ok(d.message.includes("`title`"), d.message);
assert.ok(d.message.includes("Uint8Array"), d.message);
});
test("NS1024 does not fire for literal-union tag fields or Msg payload strings", () => {
const ids = ruleIds(
checkOnly(`
export type Filter = "all" | "done";
export interface Model { readonly filter: Filter; }
export type Msg = { readonly kind: "run"; readonly name: string } | { readonly kind: "stop" };
export function initialModel(): Model { return { filter: "all" }; }
export function update(model: Model, msg: Msg): Model {
switch (msg.kind) { case "run": return model; case "stop": return model; }
}
`),
);
assert.ok(!ids.includes("NS1024"), `got ${ids}`);
});
test("NS1031 an exported helper colliding with a model field's emitted name is taught", () => {
const result = checkOnly(`
interface Totals { readonly doneCount: number; }
export interface Model { readonly totals: Totals; readonly doneCount: number; }
export type Msg = { readonly kind: "a" } | { readonly kind: "b" };
export function initialModel(): Model { return { totals: { doneCount: 0 }, doneCount: 0 }; }
export function update(model: Model, msg: Msg): Model {
switch (msg.kind) { case "a": return model; case "b": return model; }
}
export function doneCount(model: Model): number { return model.totals.doneCount; }
`);
const d = result.diagnostics.find((x) => x.id === "NS1031");
assert.ok(d, `expected NS1031, got ${ruleIds(result)}`);
assert.ok(d.message.includes("doneCount"), d.message);
});
test("NS1031 does not fire across casings: names emit verbatim, so doneCount and done_count coexist", () => {
const result = checkOnly(`
export interface Model { readonly done_count: number; }
export type Msg = { readonly kind: "a" } | { readonly kind: "b" };
export function initialModel(): Model { return { done_count: 0 }; }
export function update(model: Model, msg: Msg): Model {
switch (msg.kind) { case "a": return model; case "b": return model; }
}
export function doneCount(model: Model): number { return model.done_count; }
`);
assert.ok(!ruleIds(result).includes("NS1031"), `got ${ruleIds(result)}`);
});
test("NS1032 viewUnbound entries must name the model surface", () => {
const result = checkOnly(`
export interface Model { readonly count: number; }
export type Msg = { readonly kind: "add" } | { readonly kind: "tick"; readonly at: number };
export const viewUnbound = ["count", "tick", "nope"] as const;
export function initialModel(): Model { return { count: 0 }; }
export function update(model: Model, msg: Msg): Model {
switch (msg.kind) { case "add": return model; case "tick": return model; }
}
`);
const d = result.diagnostics.find((x) => x.id === "NS1032");
assert.ok(d, `expected NS1032, got ${ruleIds(result)}`);
assert.ok(d.message.includes('"nope"'), d.message);
// The two valid entries alone are clean.
const clean = checkOnly(`
export interface Model { readonly count: number; }
export type Msg = { readonly kind: "add" } | { readonly kind: "tick"; readonly at: number };
export const viewUnbound = ["count", "tick"] as const;
export function initialModel(): Model { return { count: 0 }; }
export function update(model: Model, msg: Msg): Model {
switch (msg.kind) { case "add": return model; case "tick": return model; }
}
`);
assert.ok(!ruleIds(clean).includes("NS1032"), `got ${ruleIds(clean)}`);
});
test("NS1033 themePack is an exact model-derived built-in pack helper", () => {
const clean = checkOnly(`
export type ThemePack = "house" | "geist";
export interface Model { readonly theme: ThemePack; }
export type Msg = { readonly kind: "house" } | { readonly kind: "geist" };
export function initialModel(): Model { return { theme: "house" }; }
export function themePack(model: Model): ThemePack { return model.theme; }
export function update(model: Model, msg: Msg): Model {
switch (msg.kind) { case "house": return { theme: "house" }; case "geist": return { theme: "geist" }; }
}
`);
assert.ok(!ruleIds(clean).includes("NS1033"), `got ${ruleIds(clean)}`);
const wrongPack = checkOnly(`
export type ThemePack = "house" | "solarized";
export interface Model { readonly theme: ThemePack; }
export type Msg = { readonly kind: "tick" };
export function initialModel(): Model { return { theme: "house" }; }
export function themePack(model: Model): ThemePack { return model.theme; }
export function update(model: Model, msg: Msg): Model { return model; }
`);
assert.ok(ruleIds(wrongPack).includes("NS1033"), `got ${ruleIds(wrongPack)}`);
const wrongShape = checkOnly(`
export type ThemePack = "house" | "geist";
export interface Model { readonly theme: ThemePack; }
export type Msg = { readonly kind: "tick" };
export function initialModel(): Model { return { theme: "house" }; }
export function themePack(): ThemePack { return "house"; }
export function update(model: Model, msg: Msg): Model { return model; }
`);
assert.ok(ruleIds(wrongShape).includes("NS1033"), `got ${ruleIds(wrongShape)}`);
});
test("NS1033 validates migrate hooks exported through an export list", () => {
const valid = checkOnly(`
export interface Model { readonly count: number; }
export type Msg = { readonly kind: "tick" };
export function initialModel(): Model { return { count: 0 }; }
export function update(model: Model, msg: Msg): Model { return model; }
function migrate(snapshot: Uint8Array, fromVersion: number): Model { return { count: fromVersion }; }
export { migrate };
`);
assert.ok(!ruleIds(valid).includes("NS1033"), `got ${ruleIds(valid)}`);
const malformed = checkOnly(`
export interface Model { readonly count: number; }
export type Msg = { readonly kind: "tick" };
export function initialModel(): Model { return { count: 0 }; }
export function update(model: Model, msg: Msg): Model { return model; }
function migrate(snapshot: string, fromVersion: number): Model { return { count: fromVersion }; }
export { migrate };
`);
assert.ok(ruleIds(malformed).includes("NS1033"), `got ${ruleIds(malformed)}`);
});
test("NS1033 statusItem is the exact model-derived presentation and menu helper", () => {
const clean = check(`
import { asciiBytes } from "@native-sdk/core";
import { type StatusItemState } from "@native-sdk/core/events";
export interface Model { readonly playing: boolean; }
export type Msg = { readonly kind: "toggle" };
export function initialModel(): Model { return { playing: false }; }
export function update(model: Model, msg: Msg): Model { return { playing: !model.playing }; }
export function statusItem(model: Model): StatusItemState {
return {
iconPath: asciiBytes("assets/tray.svg"),
tooltip: asciiBytes("Player"),
activationCommand: asciiBytes("refresh"),
alternateActivationCommand: asciiBytes("toggle"),
openCommand: asciiBytes("refresh"),
presentation: { title: asciiBytes(model.playing ? "MB on" : "MB"), width: 52, tone: "normal", iconOpacity: 1, monospaced: true },
items: [
{ id: 1, label: asciiBytes(model.playing ? "Pause" : "Play"), command: asciiBytes("toggle"), separator: false, enabled: true, detail: asciiBytes("configured"), role: "agent", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false } },
{ id: 0, label: asciiBytes(""), command: asciiBytes(""), separator: true, enabled: false, detail: asciiBytes(""), role: "command", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false } },
],
};
}
`);
assert.equal(clean.ok, true, clean.diagnostics.map((d) => d.message).join("\n"));
assert.ok(!ruleIds(clean).includes("NS1033"), `got ${ruleIds(clean)}`);
const wrongShape = checkOnly(`
export interface StatusItemMenuItem { readonly id: number; readonly label: Uint8Array; readonly command: Uint8Array; readonly separator: boolean; }
export interface StatusItemState { readonly title: Uint8Array; readonly items: readonly StatusItemMenuItem[]; }
export interface Model { readonly playing: boolean; }
export type Msg = { readonly kind: "toggle" };
export function initialModel(): Model { return { playing: false }; }
export function update(model: Model, msg: Msg): Model { return model; }
export function statusItem(model: Model): StatusItemState { throw { kind: "unreachable" }; }
`);
assert.ok(ruleIds(wrongShape).includes("NS1033"), `got ${ruleIds(wrongShape)}`);
const wrongHelper = checkOnly(`
export interface StatusItemState { readonly title: Uint8Array; readonly items: readonly Uint8Array[]; }
export interface Model { readonly playing: boolean; }
export type Msg = { readonly kind: "toggle" };
export function initialModel(): Model { return { playing: false }; }
export function update(model: Model, msg: Msg): Model { return model; }
export function statusItem(): StatusItemState { throw { kind: "unreachable" }; }
`);
assert.ok(ruleIds(wrongHelper).includes("NS1033"), `got ${ruleIds(wrongHelper)}`);
});
test("NS1033 statusItems is the canonical independent-item collection", () => {
const clean = check(`
import { type StatusItemDescriptor } from "@native-sdk/core/events";
export interface Model { readonly spend: number; }
export type Msg = { readonly kind: "tick" };
export function initialModel(): Model { return { spend: 0 }; }
export function update(model: Model, msg: Msg): Model { return model; }
export function statusItems(model: Model): readonly StatusItemDescriptor[] { return []; }
`);
assert.equal(clean.ok, true, clean.diagnostics.map((d) => d.message).join("\n"));
assert.ok(!ruleIds(clean).includes("NS1033"), `got ${ruleIds(clean)}`);
const exportList = check(`
import { type StatusItemDescriptor } from "@native-sdk/core/events";
export interface Model { readonly spend: number; }
export type Msg = { readonly kind: "tick" };
export function initialModel(): Model { return { spend: 0 }; }
export function update(model: Model, msg: Msg): Model { return model; }
function statusItems(model: Model): readonly StatusItemDescriptor[] { return []; }
export { statusItems };
`);
assert.equal(exportList.ok, true, exportList.diagnostics.map((d) => d.message).join("\n"));
assert.ok(!ruleIds(exportList).includes("NS1033"), `got ${ruleIds(exportList)}`);
const wrongNestedShape = checkOnly(`
export interface BadPresentation { readonly title: Uint8Array; }
export interface BadItem { readonly id: number; }
export interface StatusItemDescriptor {
readonly id: number;
readonly visible: boolean;
readonly iconPath: Uint8Array;
readonly tooltip: Uint8Array;
readonly activationCommand: Uint8Array;
readonly alternateActivationCommand: Uint8Array;
readonly openCommand: Uint8Array;
readonly presentation: BadPresentation;
readonly items: readonly BadItem[];
}
export interface Model { readonly spend: number; }
export type Msg = { readonly kind: "tick" };
export function initialModel(): Model { return { spend: 0 }; }
export function update(model: Model, msg: Msg): Model { return model; }
export function statusItems(model: Model): readonly StatusItemDescriptor[] { return []; }
`);
assert.ok(ruleIds(wrongNestedShape).includes("NS1033"), `got ${ruleIds(wrongNestedShape)}`);
const mutuallyExclusive = checkOnly(`
import { type StatusItemDescriptor, type StatusItemState } from "@native-sdk/core/events";
export interface Model { readonly spend: number; }
export type Msg = { readonly kind: "tick" };
export function initialModel(): Model { return { spend: 0 }; }
export function update(model: Model, msg: Msg): Model { return model; }
export function statusItem(model: Model): StatusItemState { throw { kind: "unreachable" }; }
export function statusItems(model: Model): readonly StatusItemDescriptor[] { return []; }
`);
assert.ok(ruleIds(mutuallyExclusive).includes("NS1033"), `got ${ruleIds(mutuallyExclusive)}`);
});
test("NS1061: value-record aliases refuse the shapes value storage cannot carry", () => {
// The model root is reference storage by contract.
const root = checkOnly(`
export type Model = { readonly n: number };
export type Msg = { readonly kind: "a" } | { readonly kind: "b" };
export function initialModel(): Model { return { n: 0 }; }
export function update(model: Model, msg: Msg): Model { return model; }
`);
assert.ok(ruleIds(root).includes("NS1061"), `got ${ruleIds(root)}`);
// A model-kept alias with a heap-backed field would dangle across
// frames — through an optional wrapper all the same.
const heap = checkOnly(`
export type Cache = { readonly data: Uint8Array };
export interface Model { readonly cache: Cache | null; }
export type Msg = { readonly kind: "a" } | { readonly kind: "b" };
export function initialModel(): Model { return { cache: null }; }
export function update(model: Model, msg: Msg): Model { return model; }
`);
assert.ok(ruleIds(heap).includes("NS1061"), `got ${ruleIds(heap)}`);
// Model arrays carry reference-stored records.
const arr = checkOnly(`
export type Pos = { readonly x: number };
export interface Model { readonly points: readonly Pos[]; }
export type Msg = { readonly kind: "a" } | { readonly kind: "b" };
export function initialModel(): Model { return { points: [] }; }
export function update(model: Model, msg: Msg): Model { return model; }
`);
assert.ok(ruleIds(arr).includes("NS1061"), `got ${ruleIds(arr)}`);
// Identity comparison over a value record compares nothing the
// storage carries.
const eq = checkOnly(`
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" };
export function initialModel(): Model { return { pos: { x: 0 }, n: 0 }; }
export function update(model: Model, msg: Msg): Model {
if (msg.kind === "moved") {
return { pos: msg.pos, n: model.pos === msg.pos ? 1 : 0 };
}
return model;
}
`);
assert.ok(ruleIds(eq).includes("NS1061"), `got ${ruleIds(eq)}`);
// A self-reference has no finite by-value layout.
const cyclic = checkOnly(`
export type Link = { readonly next: Link | null };
export interface Model { readonly n: number; }
export type Msg = { readonly kind: "a"; readonly link: Link } | { readonly kind: "b" };
export function initialModel(): Model { return { n: 0 }; }
export function update(model: Model, msg: Msg): Model { return model; }
`);
assert.ok(ruleIds(cyclic).includes("NS1061"), `got ${ruleIds(cyclic)}`);
// The scalar shapes value storage exists for stay clean: a scalar
// alias kept by the model directly, and a heap-carrying alias that
// never enters the model tree.
const clean = checkOnly(`
export type Pos = { readonly x: number; readonly y: number };
export type Note = { readonly text: Uint8Array };
export interface Model { readonly pos: Pos; readonly n: number; }
export type Msg = { readonly kind: "noted"; readonly note: Note } | { readonly kind: "b" };
export function initialModel(): Model { return { pos: { x: 1, y: 2 }, n: 0 }; }
export function update(model: Model, msg: Msg): Model { return model; }
`);
assert.ok(!ruleIds(clean).includes("NS1061"), `got ${ruleIds(clean)}`);
});
test("NS1062: the entry roots keep their contract shapes", () => {
// A plain object alias (or an interface) named Msg has no dispatch
// path; a tagged singleton named Model has no commit path.
const structMsg = checkOnly(`
export type Msg = { readonly value: number };
export interface Model { readonly n: number; }
export function initialModel(): Model { return { n: 0 }; }
export function update(model: Model, msg: Msg): Model { return model; }
`);
assert.ok(ruleIds(structMsg).includes("NS1062"), `got ${ruleIds(structMsg)}`);
const interfaceMsg = checkOnly(`
export interface Msg { readonly value: number; }
export interface Model { readonly n: number; }
export function initialModel(): Model { return { n: 0 }; }
export function update(model: Model, msg: Msg): Model { return model; }
`);
assert.ok(ruleIds(interfaceMsg).includes("NS1062"), `got ${ruleIds(interfaceMsg)}`);
const unionModel = checkOnly(`
export type Model = { readonly kind: "ready"; readonly count: number };
export type Msg = { readonly kind: "a" } | { readonly kind: "b" };
export function initialModel(): Model { return { kind: "ready", count: 0 }; }
export function update(model: Model, msg: Msg): Model { return model; }
`);
assert.ok(ruleIds(unionModel).includes("NS1062"), `got ${ruleIds(unionModel)}`);
});
test("NS1061: by-value recursion through a singleton union and wrapped identity comparison refuse", () => {
const unionCycle = checkOnly(`
export type Link = { readonly kind: "link"; readonly next: Link | null };
export interface Model { readonly n: number; }
export type Msg = { readonly kind: "a"; readonly link: Link } | { readonly kind: "b" };
export function initialModel(): Model { return { n: 0 }; }
export function update(model: Model, msg: Msg): Model { return model; }
`);
assert.ok(ruleIds(unionCycle).includes("NS1061"), `got ${ruleIds(unionCycle)}`);
const nullableEq = checkOnly(`
export type Pos = { readonly x: number };
export interface Model { readonly pos: Pos | null; readonly n: number; }
export type Msg = { readonly kind: "moved"; readonly pos: Pos | null } | { readonly kind: "b" };
export function initialModel(): Model { return { pos: null, n: 0 }; }
export function update(model: Model, msg: Msg): Model {
if (msg.kind === "moved") {
return { pos: msg.pos, n: model.pos === msg.pos ? 1 : 0 };
}
return model;
}
`);
assert.ok(ruleIds(nullableEq).includes("NS1061"), `got ${ruleIds(nullableEq)}`);
// An array breaks the cycle by indirection: a tree over a kids list
// stays clean.
const treeOverArray = checkOnly(`
export type Tree = { readonly kind: "node"; readonly label: number } | { readonly kind: "branch"; readonly kids: readonly Tree[] };
export interface Model { readonly n: number; }
export type Msg = { readonly kind: "a"; readonly tree: Tree } | { readonly kind: "b" };
export function initialModel(): Model { return { n: 0 }; }
export function update(model: Model, msg: Msg): Model { return model; }
`);
assert.ok(!ruleIds(treeOverArray).includes("NS1061"), `got ${ruleIds(treeOverArray)}`);
});
test("NS1061/NS1062: presence checks pass; optional arm payloads and class roots refuse", () => {
// A nullable presence check compares the option, not the record.
const presence = checkOnly(`
export type Pos = { readonly x: number };
export interface Model { readonly pos: Pos | null; readonly n: number; }
export type Msg = { readonly kind: "a" } | { readonly kind: "b" };
export function initialModel(): Model { return { pos: null, n: 0 }; }
export function update(model: Model, msg: Msg): Model {
if (model.pos !== null) {
return { pos: model.pos, n: model.pos.x };
}
return model;
}
`);
assert.ok(!ruleIds(presence).includes("NS1061"), `got ${ruleIds(presence)}`);
// An optional payload property has no native slot: the shape is not a
// kind-tagged union, so the Msg root refuses with the teaching.
const optionalArm = checkOnly(`
export interface Model { readonly n: number; }
export type Msg = { readonly kind: "set"; readonly value?: number } | { readonly kind: "b" };
export function initialModel(): Model { return { n: 0 }; }
export function update(model: Model, msg: Msg): Model { return model; }
`);
assert.ok(ruleIds(optionalArm).includes("NS1062"), `got ${ruleIds(optionalArm)}`);
// A class named Msg is a struct, never a tagged union.
const classMsg = checkOnly(`
export interface Model { readonly n: number; }
export class Msg {
value: number = 0;
constructor(value: number) { this.value = value; }
}
export function initialModel(): Model { return { n: 0 }; }
export function update(model: Model, msg: Msg): Model { return model; }
`);
assert.ok(ruleIds(classMsg).includes("NS1062"), `got ${ruleIds(classMsg)}`);
});
test("NS1061: an assertion-erased operand cannot slip identity comparison", () => {
const asserted = checkOnly(`
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" };
export function initialModel(): Model { return { pos: { x: 0 }, n: 0 }; }
export function update(model: Model, msg: Msg): Model {
if (msg.kind === "moved") {
return { pos: msg.pos, n: model.pos === (msg.pos as { readonly x: number }) ? 1 : 0 };
}
return model;
}
`);
assert.ok(ruleIds(asserted).includes("NS1061"), `got ${ruleIds(asserted)}`);
});
test("NS1061/NS1001/NS1014/NS1032: round-trip edges of the value-record and unbound surfaces", () => {
// Both operands asserted: assertions erase at emission, so the guard
// types the peeled expressions.
const bothAsserted = checkOnly(`
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" };
export function initialModel(): Model { return { pos: { x: 0 }, n: 0 }; }
export function update(model: Model, msg: Msg): Model {
if (msg.kind === "moved") {
return { pos: msg.pos, n: (model.pos as { readonly x: number }) === (msg.pos as { readonly x: number }) ? 1 : 0 };
}
return model;
}
`);
assert.ok(ruleIds(bothAsserted).includes("NS1061"), `got ${ruleIds(bothAsserted)}`);
// Record fields have no in-place write: mutation through a mutable
// property refuses with the reconstruction teaching.
const paramWrite = checkOnly(`
export type Pos = { x: number };
export interface Model { readonly n: number; }
export type Msg = { readonly kind: "a"; readonly pos: Pos } | { readonly kind: "b" };
function bump(p: Pos): number { p.x++; return p.x; }
export function initialModel(): Model { return { n: 0 }; }
export function update(model: Model, msg: Msg): Model {
if (msg.kind === "a") { return { n: bump({ x: 1 }) }; }
return model;
}
`);
assert.ok(ruleIds(paramWrite).includes("NS1001"), `got ${ruleIds(paramWrite)}`);
// An unexported reserved const in an imported module is inert
// configuration and refuses like the exported form.
const imported = checkFiles({
"core.ts": `
import { other } from "./lists.ts";
export interface Model { readonly n: number; readonly hidden: number; }
export type Msg = { readonly kind: "a" } | { readonly kind: "b" };
export function initialModel(): Model { return { n: other, hidden: 1 }; }
export function update(model: Model, msg: Msg): Model { return model; }
`,
"lists.ts": `const modelUnbound = ["hidden"] as const;\nexport const other = modelUnbound.length;\n`,
});
assert.equal(imported.ok, false);
assert.ok(imported.diagnostics.some((d) => d.id === "NS1014"), JSON.stringify(imported.diagnostics));
// 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", "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));
// 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 function initialModel(): Model { return { n: 0, hidden: 1 }; }
export function update(model: Model, msg: Msg): Model { return model; }
`,
{ 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 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" };
function same<T>(a: T, b: T): boolean { return a === b; }
export function initialModel(): Model { return { pos: { x: 0 }, n: 0 }; }
export function update(model: Model, msg: Msg): Model {
if (msg.kind === "moved") {
return { pos: msg.pos, n: same(model.pos, msg.pos) ? 1 : 0 };
}
return model;
}
`);
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.
const asserted = checkOnly(`
export type Pos = { readonly x: number };
export interface Model { readonly n: number; }
export type Msg = { readonly kind: "a"; readonly pos: Pos } | { readonly kind: "b" };
export function initialModel(): Model { return { n: 0 }; }
export function update(model: Model, msg: Msg): Model {
return { n: ({ x: 1 } as Pos) === ({ x: 1 } as Pos) ? 1 : 0 };
}
`);
assert.ok(ruleIds(asserted).includes("NS1061"), `got ${ruleIds(asserted)}`);
});