Files
openclaw-openclaw/scripts/check-protocol-event-coverage.mjs
Peter Steinberger e62cf76bd5 fix(ci): catch native-only mobile protocol drift (#100278)
* fix(ci): cover native-only protocol event drift

* fix(ci): ignore quoted Swift event constants
2026-07-05 09:23:45 -07:00

499 lines
17 KiB
JavaScript

#!/usr/bin/env node
// Guards gateway -> mobile-client event coverage drift.
//
// Source of truth for server->client event names is GATEWAY_EVENTS in
// src/gateway/server-methods-list.ts (the catalog advertised to clients in
// hello-ok `features.events`). packages/gateway-protocol only types the event
// frame envelope (`event: NonEmptyString`), so the gateway catalog is the most
// canonical single list of wire event names.
//
// Client "handled" sets are extracted with deliberately simple parsing over
// the mobile app sources: Swift `switch <x>.event { case "..." }` blocks plus
// `.event == "..."` comparisons, and Kotlin `when (event) { "..." -> }` blocks
// plus `event == "..."` comparisons scoped to `fun handle*Event(...)` bodies.
// Swift case labels may use qualified static string constants; those are
// resolved across the scanned source tree so deleting the real handler cannot
// hide behind an allowlist entry. Events a client intentionally does not
// consume live in scripts/protocol-event-coverage.allowlist.json.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const GATEWAY_EVENTS_FILE = "src/gateway/server-methods-list.ts";
const GATEWAY_EVENT_CONSTANTS_FILE = "src/gateway/events.ts";
const ALLOWLIST_FILE = "scripts/protocol-event-coverage.allowlist.json";
// Scan roots per client. The sentinel files are the primary event dispatch
// surfaces; if one moves, the check must fail loudly instead of silently
// passing with an empty handled set.
const IOS_SCAN_ROOTS = ["apps/ios/Sources", "apps/shared/OpenClawKit/Sources"];
const IOS_SENTINEL_FILE = "apps/ios/Sources/Chat/IOSGatewayChatTransport.swift";
const ANDROID_SCAN_ROOT = "apps/android/app/src/main/java/ai/openclaw/app";
const ANDROID_SENTINEL_FILES = [
"apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt",
"apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
];
// Minimum plausible catalog size; a partial parse below this means the
// GATEWAY_EVENTS array changed shape and the extractor needs updating.
const MIN_EXPECTED_GATEWAY_EVENTS = 10;
const GATEWAY_EVENTS_BLOCK_RE = /export const GATEWAY_EVENTS = \[([\s\S]*?)\];/u;
const SWIFT_EVENT_SWITCH_RE = /\bswitch\s+\w+(?:\.\w+)*\.event\s*\{/u;
const SWIFT_CASE_LABEL_RE = /^\s*case\s+(.+?):/u;
const SWIFT_TYPE_DECLARATION_RE =
/^\s*(?:(?:private|fileprivate|internal|public)\s+)?(?:enum|struct|class|actor|extension)\s+([A-Za-z_]\w*)[^{]*\x7b/u;
const SWIFT_STATIC_STRING_CONSTANT_RE = /^\s*static\s+let\s+([A-Za-z_]\w*)\s*=\s*"([^"]+)"/u;
const SWIFT_QUALIFIED_CONSTANT_RE = /\b([A-Za-z_]\w*\.[A-Za-z_]\w*)\b/gu;
const KOTLIN_EVENT_WHEN_RE = /\bwhen\s*\(\s*event\s*\)\s*\{/u;
// Kotlin gateway handlers follow the `handle*Event` naming convention
// (handleEvent, handleGatewayEvent, handleExecApprovalGatewayEvent, ...).
// Handlers named differently surface as loud "unhandled" failures, which is
// the safe direction for a coverage gate.
const KOTLIN_HANDLER_FUN_RE = /\bfun\s+handle\w*Event\s*\(/u;
const KOTLIN_CASE_LABEL_RE = /^\s*((?:"[^"]+"\s*,\s*)*"[^"]+")\s*->/u;
const SWIFT_EVENT_COMPARISON_RE = /\.event\s*==\s*"([^"]+)"/gu;
const KOTLIN_EVENT_COMPARISON_RE = /\bevent\s*==\s*"([^"]+)"/gu;
const STRING_LITERAL_RE = /"([^"]+)"/gu;
function isMainModule() {
return process.argv[1] ? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) : false;
}
/**
* Extracts the gateway event name list from server-methods-list.ts source.
* Bare identifiers in the array (e.g. GATEWAY_EVENT_UPDATE_AVAILABLE) are
* resolved against constantsSource (src/gateway/events.ts).
*/
export function extractGatewayEventNames(listSource, constantsSource) {
const block = GATEWAY_EVENTS_BLOCK_RE.exec(listSource);
if (!block) {
throw new Error(
`Could not find the GATEWAY_EVENTS array in ${GATEWAY_EVENTS_FILE}. ` +
"If the catalog moved, update scripts/check-protocol-event-coverage.mjs.",
);
}
const body = block[1].replace(/\/\*[\s\S]*?\*\//gu, "").replace(/\/\/[^\n]*/gu, "");
const names = [];
for (const token of body.matchAll(/"([^"]+)"|([A-Za-z_$][\w$]*)/gu)) {
if (token[1]) {
names.push(token[1]);
continue;
}
const identifier = token[2];
const constant = new RegExp(`export const ${identifier} = "([^"]+)"`, "u").exec(
constantsSource,
);
if (!constant) {
throw new Error(
`Could not resolve GATEWAY_EVENTS identifier "${identifier}" from ${GATEWAY_EVENT_CONSTANTS_FILE}.`,
);
}
names.push(constant[1]);
}
if (names.length < MIN_EXPECTED_GATEWAY_EVENTS) {
throw new Error(
`Extracted only ${names.length} gateway events from ${GATEWAY_EVENTS_FILE}; ` +
"the array shape likely changed. Update the extractor.",
);
}
return names;
}
// Neutralizes string literals and line comments so brace counting cannot be
// confused by braces inside strings or comments.
function sanitizeLineForBraces(line) {
return line
.replace(/\\"/gu, "")
.replace(/"[^"]*"/gu, '""')
.replace(/\/\/.*$/u, "");
}
// Collects case-label lines at depth 1 of blocks whose header matches
// headerRe, feeding each into extractLabels. Line-based on purpose: this is a
// drift check, not a parser; sources keep one case label per line.
function collectBlockCaseLabels(source, headerRe, extractLabels) {
const names = [];
const lines = source.split("\n");
for (let i = 0; i < lines.length; i += 1) {
if (!headerRe.test(lines[i])) {
continue;
}
let depth = 0;
for (let j = i; j < lines.length; j += 1) {
const line = lines[j];
if (depth === 1) {
extractLabels(line, names);
}
const braceSource =
j === i
? sanitizeLineForBraces(line.slice(line.indexOf("{")))
: sanitizeLineForBraces(line);
for (const char of braceSource) {
if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
}
}
if (j > i && depth <= 0) {
break;
}
}
}
return names;
}
function pushStringLiterals(segment, names) {
for (const literal of segment.matchAll(STRING_LITERAL_RE)) {
names.push(literal[1]);
}
}
/**
* Extracts qualified static string constants declared at Swift type scope.
* Type qualification avoids resolving unrelated constants that share a short
* member name elsewhere in the app.
*/
export function extractSwiftStaticStringConstants(source) {
const constants = new Map();
const lines = source.split("\n");
for (let i = 0; i < lines.length; i += 1) {
const declaration = SWIFT_TYPE_DECLARATION_RE.exec(lines[i]);
if (!declaration) {
continue;
}
const typeName = declaration[1];
let depth = 0;
for (let j = i; j < lines.length; j += 1) {
const line = lines[j];
if (depth === 1) {
const constant = SWIFT_STATIC_STRING_CONSTANT_RE.exec(line);
if (constant) {
constants.set(`${typeName}.${constant[1]}`, constant[2]);
}
}
const braceSource = sanitizeLineForBraces(line);
for (const char of braceSource) {
if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
}
}
if (j > i && depth <= 0) {
break;
}
}
}
return constants;
}
/** Extracts Swift gateway-event case labels, including qualified constants. */
export function extractSwiftHandledEvents(source, constants = new Map()) {
const names = collectBlockCaseLabels(source, SWIFT_EVENT_SWITCH_RE, (line, sink) => {
const label = SWIFT_CASE_LABEL_RE.exec(line);
if (label) {
pushStringLiterals(label[1], sink);
const constantReferences = sanitizeLineForBraces(label[1]);
for (const reference of constantReferences.matchAll(SWIFT_QUALIFIED_CONSTANT_RE)) {
const value = constants.get(reference[1]);
if (value) {
sink.push(value);
}
}
}
});
for (const comparison of source.matchAll(SWIFT_EVENT_COMPARISON_RE)) {
names.push(comparison[1]);
}
return new Set(names);
}
// Collects the bodies of Kotlin gateway event handler functions
// (`fun handle*Event(...)`). Signatures may span multiple lines, so scan
// forward from the declaration to the first `{` before brace counting.
function extractKotlinHandlerBodies(source) {
const bodies = [];
const lines = source.split("\n");
for (let i = 0; i < lines.length; i += 1) {
if (!KOTLIN_HANDLER_FUN_RE.test(lines[i])) {
continue;
}
let depth = 0;
let opened = false;
const body = [];
for (let j = i; j < lines.length; j += 1) {
const sanitized = sanitizeLineForBraces(lines[j]);
if (opened) {
body.push(lines[j]);
}
for (const char of sanitized) {
if (char === "{") {
depth += 1;
opened = true;
} else if (char === "}") {
depth -= 1;
}
}
if (opened && depth <= 0) {
break;
}
}
if (opened) {
bodies.push(body.join("\n"));
}
}
return bodies;
}
/**
* Extracts event names a Kotlin source handles: string-literal case labels of
* `when (event)` blocks plus `event == "..."` comparisons, both scoped to
* `fun handle*Event(...)` bodies. Scoping matters: bare `event == "..."`
* literals also appear in predicate helpers that are not called from the
* dispatch path (e.g. gatewayEventInvalidatesNodesDevices in NodeRuntime.kt),
* and counting those would silently mark events as covered. Swift extraction
* stays tree-wide because Swift consumption always reads `.event` off a
* received EventFrame, which does not have that false-positive shape.
*/
export function extractKotlinHandledEvents(source) {
const names = [];
for (const body of extractKotlinHandlerBodies(source)) {
names.push(
...collectBlockCaseLabels(body, KOTLIN_EVENT_WHEN_RE, (line, sink) => {
const label = KOTLIN_CASE_LABEL_RE.exec(line);
if (label) {
pushStringLiterals(label[1], sink);
}
}),
);
for (const comparison of body.matchAll(KOTLIN_EVENT_COMPARISON_RE)) {
names.push(comparison[1]);
}
}
return new Set(names);
}
/**
* Compares a client's handled events against the gateway catalog and its
* allowlist. Returns human-readable error strings. Client-only names (e.g. the
* client-synthesized "seqGap" pseudo-event) are intentionally ignored; this
* check only guards the server->client direction.
*/
export function compareEventCoverage(params) {
const { client, serverEvents, handledEvents, allowlist } = params;
const errors = [];
const serverSet = new Set(serverEvents);
for (const event of serverEvents) {
if (handledEvents.has(event) || event in allowlist) {
continue;
}
errors.push(
`[${client}] gateway event "${event}" has no handler and no allowlist entry. ` +
`Handle it in the ${client} app or add it to ${ALLOWLIST_FILE} with a reason.`,
);
}
for (const [event, reason] of Object.entries(allowlist)) {
if (typeof reason !== "string" || reason.trim() === "") {
errors.push(`[${client}] allowlist entry "${event}" needs a non-empty reason string.`);
}
if (!serverSet.has(event)) {
errors.push(
`[${client}] allowlist entry "${event}" is not a gateway event anymore; remove it from ${ALLOWLIST_FILE}.`,
);
} else if (handledEvents.has(event)) {
errors.push(
`[${client}] allowlist entry "${event}" is now handled; remove it from ${ALLOWLIST_FILE}.`,
);
}
}
return errors;
}
function listFilesRecursive(rootDir, extension, fsImpl) {
const files = [];
const queue = [rootDir];
while (queue.length > 0) {
const current = queue.pop();
let entries;
try {
entries = fsImpl.readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
// Test sources match the same event literals but are not product
// handlers; including them would mask real coverage gaps.
if (entry.name === "Tests" || entry.name === ".build" || entry.name === "build") {
continue;
}
queue.push(fullPath);
continue;
}
if (entry.isFile() && entry.name.endsWith(extension)) {
files.push(fullPath);
}
}
}
return files.toSorted((left, right) => left.localeCompare(right));
}
function readRequiredFile(rootDir, relativePath, fsImpl) {
const fullPath = path.resolve(rootDir, relativePath);
try {
return fsImpl.readFileSync(fullPath, "utf8");
} catch {
throw new Error(
`Required file ${relativePath} is missing. If it moved, update scripts/check-protocol-event-coverage.mjs.`,
);
}
}
function loadAllowlist(rootDir, fsImpl) {
const raw = readRequiredFile(rootDir, ALLOWLIST_FILE, fsImpl);
let parsed;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error(`${ALLOWLIST_FILE} is not valid JSON: ${String(error)}`, { cause: error });
}
for (const client of ["ios", "android"]) {
if (typeof parsed[client] !== "object" || parsed[client] === null) {
throw new Error(`${ALLOWLIST_FILE} must contain an object entry for "${client}".`);
}
}
return parsed;
}
function collectClientHandledEvents(params) {
const { rootDir, roots, extension, extract, buildExtractContext, sentinels, fsImpl } = params;
const handled = new Set();
const sources = new Map();
for (const root of roots) {
const rootPath = path.resolve(rootDir, root);
if (!fsImpl.existsSync(rootPath)) {
throw new Error(
`Scan root ${root} is missing. If it moved, update scripts/check-protocol-event-coverage.mjs.`,
);
}
for (const filePath of listFilesRecursive(rootPath, extension, fsImpl)) {
sources.set(filePath, fsImpl.readFileSync(filePath, "utf8"));
}
}
const extractContext = buildExtractContext?.(sources.values());
for (const source of sources.values()) {
for (const event of extract(source, extractContext)) {
handled.add(event);
}
}
for (const sentinel of sentinels) {
const source = readRequiredFile(rootDir, sentinel, fsImpl);
if (extract(source, extractContext).size === 0) {
throw new Error(
`Sentinel dispatch file ${sentinel} no longer matches any event names; ` +
"its event handling likely moved or changed shape. Update scripts/check-protocol-event-coverage.mjs.",
);
}
}
return handled;
}
function collectSwiftStaticStringConstants(sources) {
const constants = new Map();
for (const source of sources) {
for (const [name, value] of extractSwiftStaticStringConstants(source)) {
const existing = constants.get(name);
if (existing !== undefined && existing !== value) {
throw new Error(`Conflicting Swift string constant values for ${name}.`);
}
constants.set(name, value);
}
}
return constants;
}
/**
* Runs the full coverage check against a repo checkout and returns error
* strings plus a summary for logging.
*/
export function collectProtocolEventCoverageErrors(params = {}) {
const rootDir = params.rootDir ?? process.cwd();
const fsImpl = params.fs ?? fs;
const serverEvents = extractGatewayEventNames(
readRequiredFile(rootDir, GATEWAY_EVENTS_FILE, fsImpl),
readRequiredFile(rootDir, GATEWAY_EVENT_CONSTANTS_FILE, fsImpl),
);
const allowlist = loadAllowlist(rootDir, fsImpl);
const clients = [
{
client: "ios",
handledEvents: collectClientHandledEvents({
rootDir,
roots: IOS_SCAN_ROOTS,
extension: ".swift",
extract: extractSwiftHandledEvents,
buildExtractContext: collectSwiftStaticStringConstants,
sentinels: [IOS_SENTINEL_FILE],
fsImpl,
}),
},
{
client: "android",
handledEvents: collectClientHandledEvents({
rootDir,
roots: [ANDROID_SCAN_ROOT],
extension: ".kt",
extract: extractKotlinHandledEvents,
sentinels: ANDROID_SENTINEL_FILES,
fsImpl,
}),
},
];
const errors = [];
const summaryParts = [];
for (const { client, handledEvents } of clients) {
errors.push(
...compareEventCoverage({
client,
serverEvents,
handledEvents,
allowlist: allowlist[client],
}),
);
const handledCount = serverEvents.filter((event) => handledEvents.has(event)).length;
summaryParts.push(
`${client} handles ${handledCount}, allowlists ${Object.keys(allowlist[client]).length}`,
);
}
return {
errors,
summary: `${serverEvents.length} gateway events; ${summaryParts.join("; ")}.`,
};
}
if (isMainModule()) {
let result;
try {
result = collectProtocolEventCoverageErrors();
} catch (error) {
console.error(
`Protocol event coverage check failed: ${error instanceof Error ? error.message : String(error)}`,
);
process.exit(1);
}
if (result.errors.length > 0) {
console.error("Protocol event coverage check failed:");
for (const error of result.errors) {
console.error(` - ${error}`);
}
process.exit(1);
}
console.log(`Protocol event coverage OK: ${result.summary}`);
}