scripts/check-platform-app-transport.ts
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bun
/**
* Lint: a platform app's UI must use @nimblebrain/synapse for postMessage envelopes.
*
* Platform app UIs (`src/platform/<name>/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
* `<receiver>.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 `<receiver>.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<void> {
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<void> {
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);
});