mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-07 05:16:19 +08:00
* fix(ci): cover native-only protocol event drift * fix(ci): ignore quoted Swift event constants
499 lines
17 KiB
JavaScript
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}`);
|
|
}
|