41c4cdc47a
* 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
1569 lines
60 KiB
TypeScript
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)}`);
|
|
});
|