// The subset checker: everything tsc cannot enforce, with teaching errors. // // Layering (spec section 5): tsc's own readonly/type errors fire first; this // checker adds the subset rules on the typed AST; the emitter re-derives every // rule during emission and turns any gap into a loud internal error. import { ts, TypedAst, lineColumn, hasExportModifier, exportListBindings, sdkCoreModulePath, type ExportListBinding } from "./typed_ast.ts"; import path from "node:path"; import { makeDiagnostic, type SubsetDiagnostic, type RuleId } from "./diagnostics.ts"; import type { TypeTable } from "./types.ts"; import { arrayOwnership, constFunctionValue, functionValueLegality, instanceOwnership, mutatingArrayMethods, ownedMutatingMethods, valuePositionStep, } from "./ownership.ts"; /// The VariableDeclaration a function value is const-bound to (through /// paren/as wrappers), else null. function owningConstDecl(fn: ts.Node): ts.VariableDeclaration | null { let cur: ts.Node = fn; while (ts.isParenthesizedExpression(cur.parent) || ts.isAsExpression(cur.parent) || ts.isSatisfiesExpression(cur.parent)) { cur = cur.parent; } return ts.isVariableDeclaration(cur.parent) && cur.parent.initializer !== undefined ? cur.parent : null; } const stringObservers = new Set(["charCodeAt", "codePointAt", "charAt", "at"]); /// The STAYS-OUT tail of the byte-text method surface (declared ambient on /// Uint8Array only so these spellings reach a teaching here instead of a /// bare tsc "property does not exist"). Each entry names its rule and why: /// UTF-16 code units and normalization (NS1060), ambient locale state /// (NS1005), regex engines (NS1040). const bytesTextStaysOut = new Map([ ["charCodeAt", { id: "NS1060", site: "`.charCodeAt` reads UTF-16 code units, which byte text does not have — read the byte (`b[i]`, `.at(i)`)." }], ["charAt", { id: "NS1060", site: "`.charAt` reads UTF-16 code units, which byte text does not have — slice the byte range (`.subarray(i, j)`)." }], ["codePointAt", { id: "NS1060", site: "`.codePointAt` walks UTF-16 code-unit indices, which byte text does not have — read bytes, or decode where the host renders." }], ["normalize", { id: "NS1060", site: "`.normalize` applies Unicode normalization forms, a table set with no place in the byte model — compare exact bytes, or normalize at the host edge." }], ["replace", { id: "NS1060", site: "`.replace` is not in v1 — rebuild the text with `.split(sep)`, slices, and a push-builder of parts." }], ["replaceAll", { id: "NS1060", site: "`.replaceAll` is not in v1 — rebuild the text with `.split(sep)`, slices, and a push-builder of parts." }], ["localeCompare", { id: "NS1005", site: "`.localeCompare` orders by the ambient locale (use `orderIgnoreCase` from \"@native-sdk/core/text\", or compare bytes)." }], ["toLocaleUpperCase", { id: "NS1005", site: "`.toLocaleUpperCase` cases by the ambient locale (`.toUpperCase()` is the locale-free simple mapping)." }], ["toLocaleLowerCase", { id: "NS1005", site: "`.toLocaleLowerCase` cases by the ambient locale (`.toLowerCase()` is the locale-free simple mapping)." }], ["match", { id: "NS1040", site: "`.match` takes a regular expression." }], ["matchAll", { id: "NS1040", site: "`.matchAll` takes a regular expression." }], ["search", { id: "NS1040", site: "`.search` takes a regular expression." }], ]); const ambientGlobals = new Set(["Date", "console", "fetch", "setTimeout", "setInterval", "performance", "process", "globalThis"]); /// Every assignment operator, `=` and the compound family alike — for the /// NS1043 statement-position rule (the emitter maps them as statements). const compoundAndPlainAssignmentOps = new Set([ ts.SyntaxKind.EqualsToken, ts.SyntaxKind.PlusEqualsToken, ts.SyntaxKind.MinusEqualsToken, ts.SyntaxKind.AsteriskEqualsToken, ts.SyntaxKind.SlashEqualsToken, ts.SyntaxKind.PercentEqualsToken, ts.SyntaxKind.AsteriskAsteriskEqualsToken, ts.SyntaxKind.AmpersandEqualsToken, ts.SyntaxKind.BarEqualsToken, ts.SyntaxKind.CaretEqualsToken, ts.SyntaxKind.LessThanLessThanEqualsToken, ts.SyntaxKind.GreaterThanGreaterThanEqualsToken, ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken, ts.SyntaxKind.AmpersandAmpersandEqualsToken, ts.SyntaxKind.BarBarEqualsToken, ts.SyntaxKind.QuestionQuestionEqualsToken, ]); /// An arrow inside a call's argument expression — the inline-callback /// position (array methods), reached through literal wrappers so more /// specific teachings (e.g. NS1027 on routing objects) stay in charge. /// Everything else is a STORED function value (NS1046). function isCallArgument(fn: ts.ArrowFunction): boolean { let cur: ts.Node = fn; for (;;) { const p: ts.Node = cur.parent; if (ts.isCallExpression(p) && (p.arguments as readonly ts.Node[]).includes(cur)) return true; if ( ts.isParenthesizedExpression(p) || ts.isPropertyAssignment(p) || ts.isObjectLiteralExpression(p) || ts.isArrayLiteralExpression(p) || ts.isAsExpression(p) || ts.isSatisfiesExpression(p) ) { cur = p; continue; } return false; } } /// Whether an expression sits inside a classic for-loop's incrementor slot /// (walking up through comma chains and parens) — the one home for comma /// sequences and statement-position assignment forms in expression syntax. function inForIncrementor(node: ts.Expression): boolean { let cur: ts.Node = node; for (;;) { const p: ts.Node = cur.parent; if (ts.isForStatement(p) && p.incrementor === cur) return true; const chains = ts.isParenthesizedExpression(p) || (ts.isBinaryExpression(p) && p.operatorToken.kind === ts.SyntaxKind.CommaToken); if (!chains) return false; cur = p; } } /// `xs.push(...)` / `xs.unshift(...)` / `xs.splice(...)` — spread arguments /// there keep the emitter's tailored teaching (one element per iteration) /// instead of the arity rule. function isMutatingAppendCall(call: ts.CallExpression): boolean { return ( ts.isPropertyAccessExpression(call.expression) && ["push", "unshift", "splice"].includes(call.expression.name.text) ); } /// `for (const [i, x] of xs.entries())` — the entries() teach (use the /// classic loop) is more useful than the destructuring rule for this shape. function isEntriesLoopBinding(pattern: ts.ArrayBindingPattern): boolean { const decl = pattern.parent; if (!ts.isVariableDeclaration(decl) || !ts.isVariableDeclarationList(decl.parent)) return false; const loop = decl.parent.parent; if (!ts.isForOfStatement(loop)) return false; let iter = loop.expression; while (ts.isParenthesizedExpression(iter)) iter = iter.expression; return ( ts.isCallExpression(iter) && ts.isPropertyAccessExpression(iter.expression) && iter.expression.name.text === "entries" ); } /// The core's single thrown shape (NS1057): every `throw` carries a value /// of one type, because the value unwinds through one native payload slot. /// The shape comes from catch assertions (`const err = e as ParseError;`) /// when any exist — all must agree — else structurally from the throw /// expressions themselves (each must resolve to the same named table type, /// number, boolean, or bytes). Shared by the checker (teaching) and the /// emitter (layer-3 re-derivation + the slot's emitted type). export interface ThrownShapeResult { readonly shape: import("./types.ts").ZType | null; /// The declared type node the shape came from (a catch assertion), used /// for tsc-assignability checks on every throw; null when structural. readonly shapeNode: ts.TypeNode | null; /// True when `shape` is the checker-SYNTHESIZED union of several distinct /// thrown shapes (registered in the table under THROWN_UNION_NAME so the /// whole narrowing pipeline sees an ordinary union); false when one shape /// — or a DECLARED union whose arms equal the thrown set — carries it. readonly synthesized: boolean; readonly problems: readonly { readonly node: ts.Node; readonly msg: string }[]; } /// The emitted name of the synthesized thrown union (heterogeneous throws /// with no declared union matching the thrown set). Reserved alongside the /// `Thrown` error set and the `thrown_payload` slot. export const THROWN_UNION_NAME = "ThrownPayload"; /// The kind-tagged arm(s) a thrown shape contributes to the core's thrown /// union: a registered union contributes its arms; an interface with a /// string-literal `kind` field contributes one arm (its other fields as the /// payload). Anything else cannot join a heterogeneous thrown set. export function thrownArmsOfShape( ty: import("./types.ts").ZType, table: TypeTable, ): readonly { readonly tag: string; readonly fields: readonly import("./types.ts").ZField[] }[] | null { if (ty.k === "union") return table.unions.get(ty.name)?.arms ?? null; if (ty.k !== "struct") return null; const info = table.structs.get(ty.name); if (!info) return null; const kindField = info.fields.find((f) => f.tsName === "kind"); if (!kindField) return null; const decl = kindField.decl; if (!ts.isPropertySignature(decl) || !decl.type) return null; if (!ts.isLiteralTypeNode(decl.type) || !ts.isStringLiteral(decl.type.literal)) return null; return [{ tag: decl.type.literal.text, fields: info.fields.filter((f) => f.tsName !== "kind") }]; } export function thrownShapeOf( tast: TypedAst, table: TypeTable, files: readonly ts.SourceFile[], ): ThrownShapeResult { const throws: ts.ThrowStatement[] = []; const catchVars = new Set(); const assertions: ts.AsExpression[] = []; const collect = (n: ts.Node): void => { if (ts.isThrowStatement(n)) throws.push(n); if (ts.isCatchClause(n) && n.variableDeclaration && ts.isIdentifier(n.variableDeclaration.name)) { catchVars.add(n.variableDeclaration); } ts.forEachChild(n, collect); }; for (const f of files) collect(f); if (throws.length === 0 && catchVars.size === 0) return { shape: null, shapeNode: null, synthesized: false, problems: [] }; const findAssertions = (n: ts.Node): void => { if (ts.isAsExpression(n) && ts.isIdentifier(n.expression)) { const d = tast.declarationOf(n.expression); if (d && catchVars.has(d)) assertions.push(n); } ts.forEachChild(n, findAssertions); }; for (const f of files) findAssertions(f); const canonRef = (t: import("./types.ts").ZType): string => table.zigTypeRef(t.k === "number" ? { k: "f64" } : t); const problems: { node: ts.Node; msg: string }[] = []; /// A throw expression's shape: an assertion resolves its named type; an /// identifier resolves its declared/inferred named type; numbers and /// booleans and bytes resolve directly. A rethrow of the catch binding /// is shape-neutral (it IS the slot). const typeOfThrow = (e: ts.Expression): import("./types.ts").ZType | "rethrow" | null => { let cur = e; while (ts.isParenthesizedExpression(cur)) cur = cur.expression; if (ts.isIdentifier(cur)) { const d = tast.declarationOf(cur); if (d && catchVars.has(d)) return "rethrow"; } if (ts.isAsExpression(cur) || ts.isSatisfiesExpression(cur)) { const t = table.resolveTypeNode(cur.type); return t.k === "void" ? null : t; } const flags = tast.typeOf(cur).flags; if ((flags & (ts.TypeFlags.Number | ts.TypeFlags.NumberLiteral)) !== 0) return { k: "f64" }; if ((flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral)) !== 0) return { k: "bool" }; const str = tast.typeToString(tast.typeOf(cur)); if (str === "Uint8Array" || str.startsWith("Uint8Array<")) return { k: "bytes" }; return table.resolveName(str); }; // Structural pre-pass: the DISTINCT shapes the core actually throws. // Two or more distinct shapes take the heterogeneous path — the thrown // union — and the single-shape path below stays byte-for-byte what it // always was. const distinct = new Map(); for (const t of throws) { const ty = typeOfThrow(t.expression); if (ty === "rethrow" || ty === null) continue; const key = canonRef(ty); if (!distinct.has(key)) distinct.set(key, { ty, node: t }); } if (distinct.size >= 2) { return thrownUnionOf(table, distinct, throws, assertions, typeOfThrow, canonRef, problems); } let shape: import("./types.ts").ZType | null = null; let shapeNode: ts.TypeNode | null = null; for (const a of assertions) { const t = table.resolveTypeNode(a.type); if (t.k === "void") { problems.push({ node: a, msg: `the catch assertion's type does not resolve to a subset shape.` }); continue; } if (shape === null) { shape = t; shapeNode = a.type; } else if (canonRef(shape) !== canonRef(t)) { problems.push({ node: a, msg: `this catch narrows to \`${a.type.getText()}\`, but the core's error shape is already \`${shapeNode?.getText() ?? canonRef(shape)}\`.`, }); } } for (const t of throws) { const e = t.expression; if (shapeNode !== null) { let cur = e; while (ts.isParenthesizedExpression(cur)) cur = cur.expression; if (ts.isIdentifier(cur)) { const d = tast.declarationOf(cur); if (d && catchVars.has(d)) continue; // rethrow } if (!tast.isAssignableToNode(e, shapeNode)) { problems.push({ node: t, msg: `this \`throw\` does not carry the core's error shape \`${shapeNode.getText()}\` (tsc says the value is not assignable to it).`, }); } continue; } const ty = typeOfThrow(e); if (ty === "rethrow") continue; if (ty === null) { problems.push({ node: t, msg: `the thrown value's shape does not resolve — throw a named value (\`const err: ParseError = {...}; throw err;\` or \`throw {...} as ParseError\`).`, }); continue; } if (shape === null) shape = ty; else if (canonRef(shape) !== canonRef(ty)) { problems.push({ node: t, msg: `this \`throw\` carries \`${canonRef(ty)}\`, but the core already throws \`${canonRef(shape)}\`.`, }); } } if (shape !== null && shape.k === "number") shape = { k: "f64" }; return { shape, shapeNode, synthesized: false, problems }; } /// The heterogeneous path: merge every distinct thrown shape's kind-tagged /// arms into ONE union — a declared union whose arms equal the merged set /// when one exists, else a synthesized union registered in the table under /// THROWN_UNION_NAME. Catch assertions must name that union (arm shapes /// narrow with kind tests, not `as`). function thrownUnionOf( table: TypeTable, distinct: ReadonlyMap, throws: readonly ts.ThrowStatement[], assertions: readonly ts.AsExpression[], typeOfThrow: (e: ts.Expression) => import("./types.ts").ZType | "rethrow" | null, canonRef: (t: import("./types.ts").ZType) => string, problems: { node: ts.Node; msg: string }[], ): ThrownShapeResult { type Arm = { readonly tag: string; readonly fields: readonly import("./types.ts").ZField[] }; const arms: Arm[] = []; const tagOwner = new Map(); for (const [key, { ty, node }] of distinct) { const memberArms = thrownArmsOfShape(ty, table); if (memberArms === null) { problems.push({ node, msg: `this \`throw\` carries \`${key}\`, which cannot join the core's thrown union — heterogeneous throws narrow by \`kind\`, so give each thrown value a kind-discriminated record shape (an interface with a string-literal \`kind\` field, or a \`kind\`-discriminated union).`, }); continue; } for (const arm of memberArms) { const owner = tagOwner.get(arm.tag); if (owner !== undefined) { // The same tag from two member shapes is ONE arm when the payloads // agree (a shape overlapping a declared union's arm); different // payloads under one tag could never be told apart in a catch. const prior = arms.find((a) => a.tag === arm.tag); const samePayload = prior !== undefined && prior.fields.length === arm.fields.length && prior.fields.every((f, i) => arm.fields[i].tsName === f.tsName && canonRef(arm.fields[i].type) === canonRef(f.type)); if (owner !== key && !samePayload) { problems.push({ node, msg: `thrown shapes \`${owner}\` and \`${key}\` both carry kind "${arm.tag}" with different payloads — a catch could not tell them apart; give each shape its own tag.`, }); } continue; } tagOwner.set(arm.tag, key); arms.push(arm); } } // Unresolvable throws still teach the naming fix. for (const t of throws) { if (typeOfThrow(t.expression) === null) { problems.push({ node: t, msg: `the thrown value's shape does not resolve — throw a named value (\`const err: ParseError = {...}; throw err;\` or \`throw {...} as ParseError\`).`, }); } } // A declared union whose arm set equals the merged set IS the thrown // union (assertions may name it; nothing synthesizes). const armsEqual = (a: readonly Arm[], b: readonly Arm[]): boolean => { if (a.length !== b.length) return false; return a.every((arm) => { const other = b.find((x) => x.tag === arm.tag); if (!other || other.fields.length !== arm.fields.length) return false; return arm.fields.every( (f, i) => other.fields[i].tsName === f.tsName && canonRef(other.fields[i].type) === canonRef(f.type), ); }); }; let name: string | null = null; let synthesized = false; for (const info of table.unions.values()) { if (info.name === THROWN_UNION_NAME) continue; if (armsEqual(info.arms, arms)) { name = info.name; break; } } if (name === null) { name = THROWN_UNION_NAME; synthesized = true; if (!table.unions.has(THROWN_UNION_NAME)) { // The registered entry is what lets zigTypeRef, kind narrowing, and // the switch lowering treat the thrown union as an ordinary union; // `decl` is never consulted for it (no source statement matches). const anyUnion = [...distinct.values()].find((d) => d.ty.k === "union"); const decl = (anyUnion ? table.unions.get((anyUnion.ty as { name: string }).name)?.decl : undefined) as ts.TypeAliasDeclaration; table.unions.set(THROWN_UNION_NAME, { name: THROWN_UNION_NAME, decl, arms, exported: false }); } } for (const a of assertions) { const t = table.resolveTypeNode(a.type); if (!(t.k === "union" && t.name === name)) { problems.push({ node: a, msg: `this catch narrows to \`${a.type.getText()}\`, but the core throws several shapes — test \`kind\` in the catch (\`if (e.kind === ...)\`) instead of asserting one of them.`, }); } } return { shape: { k: "union", name }, shapeNode: null, synthesized, problems }; } export interface CheckResult { readonly diagnostics: SubsetDiagnostic[]; /// Teaching notices that do NOT stop the build (today: capability and /// persistence-contract lints). Same shape as diagnostics, surfaced /// as warnings by the CLI. readonly warnings: SubsetDiagnostic[]; /// Local names bound to the SDK `Cmd` surface (import from /// "@native-sdk/core"). The emitter lowers references through these names /// onto the rt command builders. readonly cmdNames: Set; /// Local names bound to the SDK `Sub` surface, lowered onto the rt /// subscription builders the same way. readonly subNames: Set; } /// Manifest-owned boot routes for engine model persistence. The frontend /// receives these as plain strings so it stays independent of app.zon's file /// format while still checking the cross-file Msg contract. export interface PersistRoutes { readonly ok: string; readonly none: string; readonly err: string; } export class SubsetChecker { private readonly diagnostics: SubsetDiagnostic[] = []; private readonly warnings: SubsetDiagnostic[] = []; readonly cmdNames = new Set(); readonly subNames = new Set(); /// Local names bound by `import * as ns` over in-graph modules — a fast /// pre-filter for the NS1039 bare-alias check (confirmed by symbol). private readonly nsAliasNames = new Set(); private readonly tast: TypedAst; private readonly table: TypeTable; /// The core's modules in canonical order; files[0] is the entry /// (src/core.ts), the one module the entry-point exports may live in. private readonly files: readonly ts.SourceFile[]; private readonly entry: ts.SourceFile; private readonly fileSet: Set; /// Null means the app has no service registry. An empty set is distinct: /// service files exist, but none exported a callable operation. private readonly serviceOps: ReadonlySet | null; private readonly capabilities: Set; private readonly permissions: Set; private readonly persistRoutes: PersistRoutes | undefined; private readonly sdkCorePath: string; private readonly windowViews: ReadonlySet | undefined; private usesPersist = false; private usesStore = false; private usesSqlite = false; private usesCredentials = false; constructor( tast: TypedAst, table: TypeTable, files: readonly ts.SourceFile[] | ts.SourceFile, serviceOps: ReadonlySet | null = null, capabilities: readonly string[] = [], permissions: readonly string[] = [], persistRoutes?: PersistRoutes, sdkCorePath: string = sdkCoreModulePath, windowViews?: readonly string[], ) { this.tast = tast; this.table = table; this.files = Array.isArray(files) ? files : [files as ts.SourceFile]; this.entry = this.files[0]; this.fileSet = new Set(this.files); this.serviceOps = serviceOps; this.capabilities = new Set(capabilities); this.permissions = new Set(permissions); this.persistRoutes = persistRoutes; this.sdkCorePath = sdkCorePath; this.windowViews = windowViews === undefined ? undefined : new Set(windowViews); } check(): CheckResult { for (const file of this.files) this.findCmdNames(file); this.checkServiceCalls(); for (const file of this.files) this.checkModuleShape(file); this.checkEntryContract(); this.checkPersistRoutes(); this.checkNameCollisions(); this.checkGeneratedMetadataNames(); this.checkModelHoldsData(); this.checkModelTextIsBytes(); this.checkCmdPurity(); this.checkSubPurity(); this.checkModelBindingSurface(); this.checkMigrationHook(); this.checkThemePackHelper(); this.checkThemeStateHelper(); this.checkStatusItemHelper(); this.checkStatusItemsHelper(); this.checkWindowsHelper(); this.checkViewUnbound(); this.checkReservedContractConsts(); this.checkValueRecordAliases(); for (const file of this.files) this.walk(file); if (this.capabilities.has("persist") && !this.usesPersist) { this.warn("NS1028", "app.zon declares the `persist` capability, but this core has no `Cmd.persist()` call.", this.entry); } if (this.capabilities.has("store") && !this.usesStore) { this.warn("NS1069", "app.zon declares the `store` capability, but this core has no `Cmd.store.*` call.", this.entry); } if (this.capabilities.has("sqlite") && !this.usesSqlite) { this.warn("NS1070", "app.zon declares the `sqlite` capability, but this core has no raw or generated relational command/subscription.", this.entry); } if (this.capabilities.has("credentials") && !this.usesCredentials) { this.warn("NS1071", "app.zon declares the `credentials` capability, but this core has no `Cmd.credentials.*` call.", this.entry); } this.checkExceptions(); return { diagnostics: this.diagnostics, warnings: this.warnings, cmdNames: this.cmdNames, subNames: this.subNames, }; } /// NS1067 — once an app declares a service registry, every generic /// Cmd.host/request literal must resolve either to that registry or to /// the SDK-reserved native family. The service binding owns both command /// channels, so an unknown fire-and-forget host call would otherwise be /// dropped silently by ServiceHost.send. private checkServiceCalls(): void { if (this.serviceOps === null || this.cmdNames.size === 0) return; const visit = (node: ts.Node): void => { if ( ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && (node.expression.name.text === "request" || node.expression.name.text === "host") && ts.isIdentifier(node.expression.expression) && this.cmdNames.has(node.expression.expression.text) && this.isSdkReference(node.expression.expression) ) { const name = node.arguments[0]; if (!name || !ts.isStringLiteral(name)) { this.report("NS1067", "A service call name is not a string literal from the generated registry.", name ?? node); } else if (!name.text.startsWith("native-sdk.") && !this.serviceOps.has(name.text)) { this.report("NS1067", `\`${name.text}\` names no operation in services.contract.json.`, name); } } ts.forEachChild(node, visit); }; for (const file of this.files) visit(file); } /// app.zon owns these names, while the TypeScript core owns Msg. Validate /// the seam in the frontend so `native check` and the node dev host fail at /// the authoring boundary instead of waiting for generated Zig to compile. private checkPersistRoutes(): void { const routes = this.persistRoutes; if (routes === undefined) return; const msg = this.table.unions.get("Msg"); if (msg === undefined) return; // NS1062 owns the missing-root teaching. const checks = [ { role: "ok", route: routes.ok, payload: "void" }, { role: "none", route: routes.none, payload: "void" }, { role: "err", route: routes.err, payload: "bytes" }, ] as const; for (const check of checks) { const arm = msg.arms.find((candidate) => candidate.tag === check.route); if (arm === undefined) { this.report( "NS1033", `app.zon persistence restore route \`${check.route}\` (${check.role}) names no Msg arm.`, msg.decl.name, ); continue; } const valid = check.payload === "void" ? arm.fields.length === 0 : arm.fields.length === 1 && arm.fields[0].type.k === "bytes"; if (!valid) { this.report( "NS1033", `app.zon persistence restore route \`${check.route}\` (${check.role}) has the wrong Msg payload; ok/none must be void and err must carry one Uint8Array field.`, arm.fields[0]?.decl ?? msg.decl.name, ); } } } private report(id: RuleId, site: string, node: ts.Node): void { const file = node.getSourceFile(); const { line, column } = lineColumn(file, node.getStart()); this.diagnostics.push(makeDiagnostic(id, site, file.fileName, line, column)); } private warn(id: RuleId, site: string, node: ts.Node): void { const file = node.getSourceFile(); const { line, column } = lineColumn(file, node.getStart()); this.warnings.push(makeDiagnostic(id, site, file.fileName, line, column)); } // ---------------------------------------------------------- cmd namespace /// Local names bound to the SDK `Cmd`/`Sub` exports by an import from /// "@native-sdk/core" (usually just `Cmd`/`Sub`; renames are honored). private findCmdNames(file: ts.SourceFile): void { for (const stmt of file.statements) { if (!ts.isImportDeclaration(stmt)) continue; const spec = stmt.moduleSpecifier; if (!ts.isStringLiteral(spec)) continue; if (stmt.importClause?.isTypeOnly) continue; const bindings = stmt.importClause?.namedBindings; if (spec.text !== "@native-sdk/core") { // NS1039 (half 1): `import * as ns` over an in-graph module is the // supported dot-syntax alias; record the local name for the // bare-value check in walk(). if (bindings && ts.isNamespaceImport(bindings)) this.nsAliasNames.add(bindings.name.text); continue; } if (bindings && ts.isNamespaceImport(bindings)) { // NS1039 (half 2): the intrinsic SDK surface is imported by name — // the purity rules (NS1017/NS1025) and byte-text folds recognize // the factories by their imported names. this.report("NS1039", `\`import * as ${bindings.name.text}\` aliases the intrinsic SDK module.`, bindings); continue; } if (bindings && ts.isNamedImports(bindings)) { for (const el of bindings.elements) { if ((el.propertyName ?? el.name).text === "Cmd") this.cmdNames.add(el.name.text); if ((el.propertyName ?? el.name).text === "Sub") this.subNames.add(el.name.text); } } } } /// NS1017 — the purity rule: `Cmd` appears in exactly two places. As a /// type, in `update`'s or `initialModel`'s return annotation; as a value, /// in factory calls inside the command slot of that function's returned /// `[model, cmd]` tuple. Anywhere else a command could escape the /// dispatch cycle. private checkCmdPurity(): void { if (this.cmdNames.size === 0) return; const visit = (node: ts.Node): void => { if (ts.isImportDeclaration(node)) return; if (ts.isIdentifier(node) && this.cmdNames.has(node.text) && this.isSdkReference(node)) { if (!this.cmdUseIsLegal(node)) { this.report("NS1017", `\`${node.getText()}\` puts a Cmd outside update's return path.`, node); } } ts.forEachChild(node, visit); }; for (const file of this.files) visit(file); } /// NS1025 — the same purity rule for subscriptions: `Sub` appears as a /// type in `subscriptions`' return annotation and as factory calls in its /// return path, nowhere else. private checkSubPurity(): void { if (this.subNames.size === 0) return; const visit = (node: ts.Node): void => { if (ts.isImportDeclaration(node)) return; if (ts.isIdentifier(node) && this.subNames.has(node.text) && this.isSdkReference(node)) { if (!this.subUseIsLegal(node)) { this.report( "NS1025", `\`${node.getText()}\` puts a Sub outside subscriptions' return path.`, node, ); } } ts.forEachChild(node, visit); }; for (const file of this.files) visit(file); } /// Whether an identifier refers to an ambient global — i.e. is NOT /// shadowed by a declaration in one of the core's own modules (lib.d.ts /// declarations do not count as shadowing). private isAmbientRef(node: ts.Identifier): boolean { const decl = this.tast.declarationOf(node); return decl === undefined || !this.fileSet.has(decl.getSourceFile()); } /// A use of the imported name, not e.g. a property called `Cmd` or a /// shadowing local (a declaration in any of the core's own modules). private isSdkReference(node: ts.Identifier): boolean { const parent = node.parent; if (ts.isPropertyAccessExpression(parent) && parent.name === node) return false; if (ts.isPropertySignature(parent) || ts.isPropertyAssignment(parent)) { if (parent.name === node) return false; } const decl = this.tast.declarationOf(node); return decl === undefined || !this.fileSet.has(decl.getSourceFile()); } private cmdUseIsLegal(node: ts.Identifier): boolean { // Cmd may ride update's dispatch returns and initialModel's boot pair. return this.effectUseIsLegal(node, new Set(["update", "initialModel"]), true); } private subUseIsLegal(node: ts.Identifier): boolean { // Sub values are the whole return value of subscriptions, no tuple. return this.effectUseIsLegal(node, new Set(["subscriptions"]), false); } /// Shared purity shape for the inert-effect surfaces. Type position: the /// name inside the return type annotation of one of `fnNames`. Value /// position: `X.` reached from the effect slot of a `return` /// directly inside one of `fnNames` (not inside a callback) — the cmd /// slot of a returned `[model, cmd]` tuple when `tupleSlot`, else the /// whole return expression. private effectUseIsLegal(node: ts.Identifier, fnNames: Set, tupleSlot: boolean): boolean { // The effect surfaces belong to the ENTRY module's entry points: a // dispatch function in an imported file is not the app's (NS1014 // teaches the export; this keeps a private homonym from smuggling one). if (node.getSourceFile() !== this.entry) return false; if (ts.isTypeReferenceNode(node.parent) && node.parent.typeName === node) { let fn: ts.Node | undefined = node.parent; while (fn && !ts.isFunctionDeclaration(fn)) fn = fn.parent; if (!fn || !ts.isFunctionDeclaration(fn) || !fnNames.has(fn.name?.text ?? "") || !fn.type) return false; return node.getStart() >= fn.type.getStart() && node.getEnd() <= fn.type.getEnd(); } if (!ts.isPropertyAccessExpression(node.parent) || node.parent.expression !== node) return false; let cur: ts.Node = node; while (cur.parent && !ts.isReturnStatement(cur.parent)) { if ( ts.isFunctionDeclaration(cur.parent) || ts.isArrowFunction(cur.parent) || ts.isFunctionExpression(cur.parent) ) { return false; } cur = cur.parent; } const ret = cur.parent; if (!ret || !ts.isReturnStatement(ret)) return false; let fn: ts.Node | undefined = ret; while (fn && !ts.isFunctionDeclaration(fn) && !ts.isArrowFunction(fn) && !ts.isFunctionExpression(fn)) { fn = fn.parent; } if (!fn || !ts.isFunctionDeclaration(fn) || !fnNames.has(fn.name?.text ?? "")) return false; if (!tupleSlot) return true; let retExpr = ret.expression; while (retExpr && ts.isParenthesizedExpression(retExpr)) retExpr = retExpr.expression; if (!retExpr || !ts.isArrayLiteralExpression(retExpr) || retExpr.elements.length !== 2) return false; const slot = retExpr.elements[1]; return node.getStart() >= slot.getStart() && node.getEnd() <= slot.getEnd(); } /// NS1031 — exported single-Model-parameter helpers also emit as Model /// declarations markup binds by their own names (`doneCount` → /// `{doneCount}`), so each emitted name must be unique across the Model's /// fields, the helpers, and the `view_unbound` opt-out declaration. private checkModelBindingSurface(): void { const model = this.table.structs.get("Model"); if (!model) return; const taken = new Map(); for (const f of model.fields) taken.set(f.zigName, `Model field \`${f.tsName}\``); taken.set("view_unbound", "the `view_unbound` opt-out declaration"); for (const h of this.table.modelHelperDecls()) { const holder = taken.get(h.zigName); if (holder !== undefined) { this.report( "NS1031", `Exported helper \`${h.name}\` emits the Model declaration \`${h.zigName}\`, which collides with ${holder}.`, h.decl.name ?? h.decl, ); } taken.set(h.zigName, `exported helper \`${h.name}\``); } } /// `themePack(model)` is both an ordinary exported Model helper and a /// generated-launcher convention. Keep its shape exact here so a typo /// is taught in core.ts instead of failing inside the generated Zig /// adapter (or, worse, becoming an inert markup-only helper). private checkThemePackHelper(): void { let decl: ts.FunctionDeclaration | null = null; for (const stmt of this.entry.statements) { if ( ts.isFunctionDeclaration(stmt) && stmt.name?.text === "themePack" && hasExportModifier(stmt) ) { decl = stmt; break; } } if (decl === null) { for (const binding of exportListBindings(this.tast, this.entry)) { if ( binding.exportedName === "themePack" && binding.target !== null && binding.target !== undefined && ts.isFunctionDeclaration(binding.target) && binding.target.getSourceFile() === this.entry ) { decl = binding.target; break; } } } if (decl === null) return; const helper = this.table.modelHelperDecls().find( (candidate) => candidate.name === "themePack" && candidate.decl === decl, ); if (helper === undefined || decl.type === undefined) { this.report( "NS1033", "`themePack` is not a single-Model-parameter helper with an explicit return type.", decl.name ?? decl, ); return; } const returns = this.table.resolveTypeNode(decl.type); if ( returns.k !== "enum" || returns.members.length !== 2 || !returns.members.includes("house") || !returns.members.includes("geist") ) { this.report( "NS1033", "`themePack` does not return exactly the built-in `\"house\" | \"geist\"` theme-pack union.", decl.type, ); } } /// `themeState(model)` subsumes themePack with one exact, projection-safe /// record. Optional properties are intentional: omission is the manifest / /// system inheritance signal that crosses the helper ABI as null. private checkThemeStateHelper(): void { const decl = this.entryExportedFunction("themeState"); if (decl === null) return; if (this.table.modelHelperDecls().some((candidate) => candidate.name === "themePack")) { this.report("NS1033", "Export either `themePack` or `themeState`, not both.", decl.name ?? decl); } const helper = this.table.modelHelperDecls().find( (candidate) => candidate.name === "themeState" && candidate.decl === decl, ); if (helper === undefined || decl.type === undefined) { this.report( "NS1033", "`themeState` is not a single-Model-parameter helper with an explicit return type.", decl.name ?? decl, ); return; } const returns = this.table.resolveTypeNode(decl.type); const state = returns.k === "struct" ? this.table.structs.get(returns.name) : undefined; const names = state?.fields.map((field) => field.tsName).sort() ?? []; const field = (name: string) => state?.fields.find((candidate) => candidate.tsName === name); const pack = field("pack"); const colorScheme = field("colorScheme"); const accent = field("accent"); const optionalEnumMembersAre = (candidate: typeof pack, expected: readonly string[]): boolean => { if (candidate?.type.k !== "optional" || candidate.type.inner.k !== "enum") return false; const found = this.table.enums.get(candidate.type.inner.name)?.members.slice().sort() ?? []; return found.join(",") === expected.slice().sort().join(","); }; const valid = names.join(",") === "accent,colorScheme,pack" && optionalEnumMembersAre(pack, ["house", "geist"]) && optionalEnumMembersAre(colorScheme, ["light", "dark", "system"]) && accent?.type.k === "optional" && accent.type.inner.k === "string"; if (!valid) { this.report( "NS1033", "`themeState` must return the exact canonical `ThemeState` record (`pack?`, `colorScheme?`, `accent?`); import it from `@native-sdk/core/events`.", decl.type, ); } } /// Persistence migration is a pure entry hook, not a model helper. It /// receives the previous canonical snapshot and monotonic schema version; /// returning the current Model succeeds, while throwing closes as /// `migrate_failed` at the host boundary. private checkMigrationHook(): void { let decl: ts.FunctionDeclaration | null = null; for (const stmt of this.entry.statements) { if (ts.isFunctionDeclaration(stmt) && stmt.name?.text === "migrate" && hasExportModifier(stmt)) { decl = stmt; break; } } if (decl === null) { for (const binding of exportListBindings(this.tast, this.entry)) { if ( binding.exportedName === "migrate" && binding.target !== null && binding.target !== undefined && ts.isFunctionDeclaration(binding.target) && binding.target.getSourceFile() === this.entry ) { decl = binding.target; break; } } } if (decl === null) return; const params = decl.parameters; const snapshot = params[0]?.type === undefined ? null : this.table.resolveTypeNode(params[0].type); const version = params[1]?.type === undefined ? null : this.table.resolveTypeNode(params[1].type); const returns = decl.type === undefined ? null : this.table.resolveTypeNode(decl.type); if (params.length !== 2 || snapshot?.k !== "bytes" || version?.k !== "number" || returns?.k !== "struct" || returns.name !== "Model") { this.report( "NS1033", "`migrate` must be declared exactly as `export function migrate(snapshot: Uint8Array, fromVersion: number): Model`; throw to report `migrate_failed`.", decl.name ?? decl, ); } } /// `statusItem(model)` is the TypeScript launcher's live menu-bar /// declaration. Validate both records exactly here: the Zig adapter /// intentionally accepts only this one canonical, projection-safe /// shape, and a source diagnostic is much more useful than generated /// Zig reflection failing later. private checkStatusItemHelper(): void { let decl: ts.FunctionDeclaration | null = null; for (const stmt of this.entry.statements) { if (ts.isFunctionDeclaration(stmt) && stmt.name?.text === "statusItem" && hasExportModifier(stmt)) { decl = stmt; break; } } if (decl === null) { for (const binding of exportListBindings(this.tast, this.entry)) { if ( binding.exportedName === "statusItem" && binding.target !== null && binding.target !== undefined && ts.isFunctionDeclaration(binding.target) && binding.target.getSourceFile() === this.entry ) { decl = binding.target; break; } } } if (decl === null) return; const helper = this.table.modelHelperDecls().find( (candidate) => candidate.name === "statusItem" && candidate.decl === decl, ); if (helper === undefined || decl.type === undefined) { this.report( "NS1033", "`statusItem` is not a single-Model-parameter helper with an explicit return type.", decl.name ?? decl, ); return; } const returns = this.table.resolveTypeNode(decl.type); const state = returns.k === "struct" ? this.table.structs.get(returns.name) : undefined; const stateNames = state?.fields.map((field) => field.tsName).sort() ?? []; const iconPath = state?.fields.find((field) => field.tsName === "iconPath"); const tooltip = state?.fields.find((field) => field.tsName === "tooltip"); const activationCommand = state?.fields.find((field) => field.tsName === "activationCommand"); const alternateActivationCommand = state?.fields.find((field) => field.tsName === "alternateActivationCommand"); const openCommand = state?.fields.find((field) => field.tsName === "openCommand"); const presentationField = state?.fields.find((field) => field.tsName === "presentation"); const items = state?.fields.find((field) => field.tsName === "items"); const presentationType = presentationField?.type.k === "struct" ? presentationField.type : null; const presentation = presentationType === null ? undefined : this.table.structs.get(presentationType.name); const presentationNames = presentation?.fields.map((field) => field.tsName).sort() ?? []; const title = presentation?.fields.find((field) => field.tsName === "title"); const width = presentation?.fields.find((field) => field.tsName === "width"); const tone = presentation?.fields.find((field) => field.tsName === "tone"); const iconOpacity = presentation?.fields.find((field) => field.tsName === "iconOpacity"); const monospaced = presentation?.fields.find((field) => field.tsName === "monospaced"); const fontSize = presentation?.fields.find((field) => field.tsName === "fontSize"); const fontWeight = presentation?.fields.find((field) => field.tsName === "fontWeight"); const itemType = items?.type.k === "slice" && items.type.elem.k === "struct" ? items.type.elem : null; const item = itemType === null ? undefined : this.table.structs.get(itemType.name); const itemNames = item?.fields.map((field) => field.tsName).sort() ?? []; const id = item?.fields.find((field) => field.tsName === "id"); const label = item?.fields.find((field) => field.tsName === "label"); const command = item?.fields.find((field) => field.tsName === "command"); const separator = item?.fields.find((field) => field.tsName === "separator"); const enabled = item?.fields.find((field) => field.tsName === "enabled"); const detail = item?.fields.find((field) => field.tsName === "detail"); const role = item?.fields.find((field) => field.tsName === "role"); const key = item?.fields.find((field) => field.tsName === "key"); const modifiersField = item?.fields.find((field) => field.tsName === "modifiers"); const segmentedField = item?.fields.find((field) => field.tsName === "segmented"); const metricField = item?.fields.find((field) => field.tsName === "metric"); const chartField = item?.fields.find((field) => field.tsName === "chart"); const modifiersType = modifiersField?.type.k === "struct" ? modifiersField.type : null; const modifiers = modifiersType === null ? undefined : this.table.structs.get(modifiersType.name); const modifierNames = modifiers?.fields.map((field) => field.tsName).sort() ?? []; const optionalStruct = (field: typeof segmentedField) => field?.type.k === "optional" && field.type.inner.k === "struct" ? this.table.structs.get(field.type.inner.name) : undefined; const segmented = optionalStruct(segmentedField); const segmentedNames = segmented?.fields.map((field) => field.tsName).sort() ?? []; const segmentOptions = segmented?.fields.find((field) => field.tsName === "options"); const segmentOptionType = segmentOptions?.type.k === "slice" && segmentOptions.type.elem.k === "struct" ? segmentOptions.type.elem : null; const segmentOption = segmentOptionType === null ? undefined : this.table.structs.get(segmentOptionType.name); const segmentOptionNames = segmentOption?.fields.map((field) => field.tsName).sort() ?? []; const segmentOptionField = (name: string) => segmentOption?.fields.find((field) => field.tsName === name); const chart = optionalStruct(chartField); const chartNames = chart?.fields.map((field) => field.tsName).sort() ?? []; const chartFieldNamed = (name: string) => chart?.fields.find((field) => field.tsName === name); const metric = optionalStruct(metricField); const metricNames = metric?.fields.map((field) => field.tsName).sort() ?? []; const metricFieldNamed = (name: string) => metric?.fields.find((field) => field.tsName === name); const allByteFields = [iconPath, tooltip, activationCommand, alternateActivationCommand, openCommand].every( (field) => field?.type.k === "bytes", ); const enumMembersAre = (field: typeof tone, expected: readonly string[]): boolean => { if (field?.type.k !== "enum") return false; const found = this.table.enums.get(field.type.name)?.members.slice().sort() ?? []; return found.join(",") === expected.slice().sort().join(","); }; const boolModifierFields = modifiers !== undefined && modifiers.fields.every((field) => field.type.k === "bool"); const numericId = id !== undefined && ["number", "i64", "f64", "numAlias"].includes(id.type.k); const numericWidth = width !== undefined && ["number", "i64", "f64", "numAlias"].includes(width.type.k); const numericOpacity = iconOpacity !== undefined && ["number", "i64", "f64", "numAlias"].includes(iconOpacity.type.k); const optionalNumeric = (field: typeof fontSize): boolean => field?.type.k === "optional" && ["number", "i64", "f64", "numAlias"].includes(field.type.inner.k); const optionalEnumMembersAre = (field: typeof fontWeight, expected: readonly string[]): boolean => { if (field?.type.k !== "optional" || field.type.inner.k !== "enum") return false; const found = this.table.enums.get(field.type.inner.name)?.members.slice().sort() ?? []; return found.join(",") === expected.slice().sort().join(","); }; const valid = stateNames.join(",") === "activationCommand,alternateActivationCommand,iconPath,items,openCommand,presentation,tooltip" && allByteFields && presentationNames.join(",") === "fontSize,fontWeight,iconOpacity,monospaced,title,tone,width" && title?.type.k === "bytes" && numericWidth && enumMembersAre(tone, ["normal", "warning", "critical"]) && numericOpacity && monospaced?.type.k === "bool" && optionalNumeric(fontSize) && optionalEnumMembersAre(fontWeight, ["regular", "medium", "semibold", "bold"]) && item !== undefined && itemNames.join(",") === "chart,command,detail,enabled,id,key,label,metric,modifiers,role,segmented,separator" && numericId && label?.type.k === "bytes" && command?.type.k === "bytes" && separator?.type.k === "bool" && enabled?.type.k === "bool" && detail?.type.k === "bytes" && enumMembersAre(role, ["command", "info", "header", "hero", "agent", "context", "segmented", "chart"]) && key?.type.k === "bytes" && modifierNames.join(",") === "command,control,option,primary,shift" && boolModifierFields && segmentedNames.join(",") === "options" && segmentOptionNames.join(",") === "command,enabled,id,label,selected" && segmentOptionField("id") !== undefined && ["number", "i64", "f64", "numAlias"].includes(segmentOptionField("id")!.type.k) && segmentOptionField("label")?.type.k === "bytes" && segmentOptionField("command")?.type.k === "bytes" && segmentOptionField("selected")?.type.k === "bool" && segmentOptionField("enabled")?.type.k === "bool" && metricNames.join(",") === "accessibilityLabel,primaryText,secondaryText" && metricFieldNamed("primaryText")?.type.k === "bytes" && metricFieldNamed("secondaryText")?.type.k === "bytes" && metricFieldNamed("accessibilityLabel")?.type.k === "bytes" && chartNames.join(",") === "accessibilityLabel,leadingCaption,maxValue,minValue,trailingSummary,values" && chartFieldNamed("values")?.type.k === "slice" && chartFieldNamed("values")?.type.elem.k === "number" && chartFieldNamed("minValue") !== undefined && ["number", "i64", "f64", "numAlias"].includes(chartFieldNamed("minValue")!.type.k) && chartFieldNamed("maxValue") !== undefined && ["number", "i64", "f64", "numAlias"].includes(chartFieldNamed("maxValue")!.type.k) && chartFieldNamed("leadingCaption")?.type.k === "bytes" && chartFieldNamed("trailingSummary")?.type.k === "bytes" && chartFieldNamed("accessibilityLabel")?.type.k === "bytes"; if (!valid) { this.report( "NS1033", "`statusItem` must return the exact canonical `StatusItemState` record (live presentation, shell fields, and rich menu rows); import it from `@native-sdk/core/events`.", decl.type, ); } } /// One entry-module function exported under its own wiring name. The /// entry-contract pass separately teaches re-exports and renames; this /// query lets related channels prove that the generated launcher will /// actually have the named callback available. private entryExportedFunction(name: string): ts.FunctionDeclaration | null { for (const stmt of this.entry.statements) { if (ts.isFunctionDeclaration(stmt) && stmt.name?.text === name && hasExportModifier(stmt)) return stmt; } for (const binding of exportListBindings(this.tast, this.entry)) { if ( binding.exportedName === name && !binding.renamed && binding.target !== null && binding.target !== undefined && ts.isFunctionDeclaration(binding.target) && binding.target.getSourceFile() === this.entry ) { return binding.target; } } return null; } /// The generated launcher owns a closed, comptime-compiled registry keyed /// by direct `src/windows/