#!/usr/bin/env bun /** * Lint: a platform app's UI must use @nimblebrain/synapse for postMessage envelopes. * * Platform app UIs (`src/platform//ui/src/`) talk to the host iframe * bridge via the SDK. The bridge validates every inbound envelope against * `web/src/bridge/schemas.ts`; an envelope built by hand can drift from * the schema and silently break (this is exactly the bug that motivated * the SDK migration). Forcing all transport through the SDK structurally * rules out that class of bug. * * What this script flags: any `CallExpression` whose callee is * `.postMessage(...)` where the receiver chain contains * `parent` or `top`. That covers `window.parent.postMessage(...)`, * `parent.postMessage(...)`, `globalThis.parent.postMessage(...)`, etc. * * What it allows: * - `synapse.callTool / synapse.action / useCallTool` — typed SDK paths * - `window.postMessage(...)` — to-self, not to host * - A `// lint-ok:platform-app-transport` marker on the line immediately * above a violating call. The only legitimate use today is * home's `callServerTool` for cross-server internal-app calls; the * SDK doesn't expose `params.server` because it isn't part of the * ext-apps spec. * * Scope: only `src/platform/* /ui/**` is scanned. The rest of an app — * its source and its tool handlers — doesn't postMessage to anyone. */ import { readFileSync } from "node:fs"; import { join, relative } from "node:path"; import { Glob } from "bun"; import * as ts from "typescript"; const ROOT = join(import.meta.dirname ?? __dirname, ".."); const APPS_ROOT = join(ROOT, "src/platform"); const ALLOW_MARKER = "lint-ok:platform-app-transport"; interface Violation { file: string; line: number; column: number; snippet: string; } /** * Returns true iff `node` is a call to `.postMessage(...)` where * the receiver chain reaches `parent` or `top`. Examples that match: * parent.postMessage(...) * window.parent.postMessage(...) * globalThis.parent.postMessage(...) * window.top.postMessage(...) * * Examples that do NOT match: * window.postMessage(...) // to-self * channel.port1.postMessage(...) // MessageChannel, not host * socket.postMessage(...) // arbitrary other API */ function isHostPostMessage(node: ts.CallExpression): boolean { if (!ts.isPropertyAccessExpression(node.expression)) return false; if (node.expression.name.text !== "postMessage") return false; let receiver: ts.Expression = node.expression.expression; while (true) { if (ts.isPropertyAccessExpression(receiver)) { if (receiver.name.text === "parent" || receiver.name.text === "top") return true; receiver = receiver.expression; } else if (ts.isIdentifier(receiver)) { return receiver.text === "parent" || receiver.text === "top"; } else { return false; } } } /** * Allow an explicit escape comment on the line immediately above the call. * Multiple lines of allow are tolerated (consecutive comment lines), but * any non-comment line breaks the chain. */ function hasAllowMarker(node: ts.Node, sourceFile: ts.SourceFile, src: string): boolean { const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); if (line === 0) return false; const lines = src.split("\n"); for (let i = line - 1; i >= Math.max(0, line - 5); i--) { const lineText = lines[i] ?? ""; if (lineText.includes(ALLOW_MARKER)) return true; const trimmed = lineText.trim(); if ( trimmed === "" || trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*") ) { continue; // keep walking up through comments / blank lines } return false; // hit a code line without seeing the marker } return false; } async function scanFile(absPath: string, violations: Violation[]): Promise { const src = readFileSync(absPath, "utf-8"); const sourceFile = ts.createSourceFile( absPath, src, ts.ScriptTarget.Latest, true, absPath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, ); function visit(node: ts.Node): void { if (ts.isCallExpression(node) && isHostPostMessage(node)) { if (!hasAllowMarker(node, sourceFile, src)) { const { line, character } = sourceFile.getLineAndCharacterOfPosition( node.getStart(sourceFile), ); violations.push({ file: relative(ROOT, absPath), line: line + 1, column: character + 1, snippet: (src.split("\n")[line] ?? "").trim(), }); } } ts.forEachChild(node, visit); } visit(sourceFile); } async function main(): Promise { const violations: Violation[] = []; const glob = new Glob("*/ui/src/**/*.{ts,tsx}"); let scanned = 0; for await (const rel of glob.scan({ cwd: APPS_ROOT })) { const abs = join(APPS_ROOT, rel); if (abs.includes("/node_modules/") || abs.includes("/dist/")) continue; scanned++; await scanFile(abs, violations); } if (violations.length > 0) { console.error(`✗ Found ${violations.length} raw postMessage call(s) in platform app UIs:\n`); for (const v of violations) { console.error(` ${v.file}:${v.line}:${v.column}`); console.error(` ${v.snippet}\n`); } console.error( "A platform app UI must construct postMessage envelopes via @nimblebrain/synapse.", ); console.error("Legitimate exceptions (e.g. internal-app cross-server calls) require a"); console.error(` // ${ALLOW_MARKER}\n comment on the line above the call.`); process.exit(1); } console.log(`✓ No raw postMessage in ${scanned} platform app UI files`); } main().catch((err: unknown) => { console.error(err); process.exit(1); });